Athena ORM Alpha Version - Testers wanted!

In addition to Athena 0.22.0 | Now with real-time updates!, I have another surprise I’m excited to share. After years of on and off again work, the Athena ORM is now in a state that is functional/usable enough to start gathering feedback. The demo.cr file on the develop branch serves as a good example of what its usage looks like in practice.

Unlike other ORMs in Crystal land at the moment, it has a few unique qualities:

  • No custom DSL, entities (models) are normal Crystal classes the use annotations on properties to apply DB related metadata (columns, relationships, PKs, etc).
  • Data-Mapper instead of ActiveRecord, a separate service is used to manage entities; e.g. persisting, querying, etc.
    • This has some benefits like smarter queries, query batching, and powerful change detection. E.g. when updating an entity only changed values are included in the UPDATE query.
  • Built-in native relationships/lazy loading, relationships are handled essentially as a Array(T) whose contents are not loaded until the collection is interacted with.
  • Repository pattern, simple/one-off queries can handled via basic find, find_all, find_by, find_one_by methods, while more complex/reusable/specialized queries can be abstracted to a Repository type that provides better type safety/documentation.

However, as I mentioned, this is still in an Alpha state, so nothing is really set in stone. There are a few things on my near-term list that I am aware of at the moment:

  • It’s lacking on the query side of things. find_by works enough for simple things, but isn’t really all that type safe nor sufficient to handle more complex queries/joins/etc
    • Some type of QueryBuilder is on the list, but is of course not small feat
    • I do have some ideas on improving type saftey of queries tho, so stay tuned!
    • NativeQuery is a somewhat verbose but useable escape hatch that can handle more complex/arbitrary SQL
    • EDIT: It’s also possible access the raw DB::Connection instance to use DB API directly
  • How to best use it outside of an Athena context. It’s expected there is a single shared EntityManager per-request if in a web context for example. I have some ideas for how to go about this, but want some actual examples of how DBs are used in existing projects, esp those using DB::Serializable and not an actual ORM before committing
  • Limited platform/column type support, I have the basics and plan to add more of both in the future
  • No testing utilities as of now
  • No migration support as of now
  • Bugs, there are probably quite a few of them at this point

What I ultimately would like now is for you all to try it for your use cases to identity any bugs, rough edges, our missing features that you run into in the process. Feel free to ping me in this thread, in the Crystal or Athena Discord server to chat more about it!

Nice, though at least when it comes to only updating changed values, nothing stops good ActiveRecord implementations from doing the same.

Have made some more progress and think I’m getting close to at least getting off the develop branch. Some of the new highlights:

Entity Proxies

class User < AORM::Entity
  # ...
  @[AORMA::OneToOne]
  property avatar! : AORM::Proxy(Avatar)
end

In order to combat N+1 queries/eager loads, ToOne relationship properties may be typed as the new AORM::Proxy type. This will make so when you load the entity, say via user = em.find! User, 1, that a proxy obj is used as the value of the relationship ivar instead of making a query for the real value. This allows you to do things like user.avatar.id # => 321 without making a query, but if you did user.avatar.url that would trigger a query once to populate the actual Avatar entity.

If the type of the property is Avatar itself, then anytime the User entity is loaded, its Avatar will be eagerly loaded as well.

RETURNING

Most databases today support RETURNING within INSERT statements in order to return the PK(s) of the newly inserted record. The ORM component now will use this more robust method when supported by the underlying DB. It’ll still fallback on SELECT LAST_INSERT_ID(); (or equivalent) if needed.

Eventing

There is now initial support for defining both lifecycle callbacks and entity-specific/global event listeners. The former can be useful for setting created_at/updated_at timestamp values in an opt-in basis. While event listeners can be used to implement more custom logic for a given entity type, or all entities. For example:

@[AORMA::PrePersist]
# :nodoc:
def set_created_at : Nil
  @created_at = Time.utc
end

Most commonly this could be be a part of a CreatedAtAware module that could define the getter and this callback function and simply included into entities that need this behavior. A similar setup could be done for updated_at, or similar columns.

Event Listeners

For more complex logic, there will an optional Athena::EventDispatcher integration

class MyEntityListener
  # Only invoked when a new `Avatar` entity instance is persisted.
  @[AEDA::AsEventListener]
  def only_avatars(event : PrePersist(Avatar)) : Nil
    # ...
  end
  
  # Invoked when _any_ new entity instance is persisted.
  @[AEDA::AsEventListener]
  def any_entity(event : PrePersist(AORM::Entity)) : Nil
    # ...
  end
end

These can be useful as an alternative to DB triggers, and to allow for event based logic such as sending an email when a new user is created anywhere in the app, etc.

Misc

  • Add support for Time columns
  • More test coverage
  • Fix bugs
  • Improve compile time perf