State of ORMs in Crystal

Hi,

I’m looking for a ORM to use in a new project, looking at the current choices I have the impression that the community isn’t very well served in this area.

  • Clear - Seems to have a nice API but doesn’t compile, neither master branch or any recent tag I tested.
  • Avram - Seems well maintained, but the API with save operations, query objects, no “dirty” (in active record terms) models doesn’t match my taste :slightly_frowning_face: .
  • crecto - Last released in 2021, it was even removed from Amber.
  • granite - Looking for maintainers.
  • jennifer.cr - Last time I tried years ago it required the model to have all attributes nillable (or I missed something)

Besides Avran, that seems to be th eonly active project nowadays, what could be the best dead horse to bet?

2 Likes

These days I find DB::Serializable - db 0.13.1 to be totally sufficient for most of what I needed to do. Tho I guess it really comes down to what features are “required.” I.e. if you want something fully featured like AR then you’re kinda out of luck I think. But if you just want a simple API to allow doing some queries, using DB shard directly with DB::Serializable works quite well.

There’s also the Athena ORM, but it’s essentially just a proof of concept atm, and is somewhat blocked by Error: can't instantiate abstract generic struct EnumType(Test) · Issue #9621 · crystal-lang/crystal · GitHub.

2 Likes

Related: GitHub - jwoertink/crystal_orm_test: Benchmark different ORMs for crystal and postgres

I’d also aske the question whether you really need ORM or if you’re just grabbing for it because it’s a widely used tool?

I think you can build great DB interfaces without ORM in Crystal.
This document describes some good ideas for a “plain” database API.

2 Likes

Great article, I could imagine everything the guys suffered until come up with the current solution, that IMO looks interesting, I’m just afraid about a possible “explosion” of return types if the project start to have many queries, but this can be worked on and as good side the returned type will be more programmer friendly, just with the data that is really there for you to use.

Given the not so great options, I think Kemal + crystal-db will be the answer.

I was looking for an ORM because they give a fast start up, you create a model with few lines and done, a lot of basic queries is ready for you. However I see that over time the codebase gets messy and doing a lot of unneeded queries, I see that a lot at work in a rails application where the misuse of ActiveRecord translates to a lot of useless queries, or code that just run not too slow just because of ActiveRecord query cache.

Note:
With tree-sitter-crystal and nvim now you can highlight SQL queries in heredoc

1 Like

If you’re making an API could checkout GitHub - athena-framework/demo: Demo blog application using Athena Framework. It has a somewhat mini-ORM built on top of DB::Serializable to at least provide some structure/organization to things. Happy to answer any questions/issues as well.

1 Like

I’ll also mention Interro as a useful alternative. It’s Postgres-only, but that’s mostly due to the differences in parameterized queries between Postgres and other RDBMSes. Unlike most ORMs, it doesn’t use the Active Record pattern for querying and actually encourages immutable models to avoid the false sense of security that often comes with ORMs performing dirty tracking.

For the most part, it’s really just a convenience layer on top of DB designed to help you with the most common things and get out of your way otherwise:

  • SQL generation via chainable methods like ActiveRecord
    • Your model’s instance variables are the only columns that are fetched from the DB by default
  • A type-safety layer — inside your query methods you have full control but you expose a type-safe interface so the rest of your application can’t pass arbitrary columns and values unless you’re into that sort of thing
  • If you’re using DB replication, SELECT queries are automatically sent to replicas for easy horizontal scalability
    • Explicit transactions are routed to the primary, so if you need to be able to read your own writes, you can do it inside a transaction
  • Models are just DB::Serializable
  • Interro::QueryBuilder#each doesn’t load the full result set so you never have to reach for hacks like ActiveRecord’s find_each (where you can’t use LIMIT or ORDER BY clauses) because you get that for free — the currently yielded row is always the only result row in memory.
  • Validations for creating/updating records

If you need to do nontrivial things and the SQL generator would just get in your way, you can use Interro::Query instead and get all the same benefits on a SQL query you wrote.

Example
require "interro"

Interro.config do |c|
  c.db = DB.open("postgres:///")
end

abstract struct Query(T) < Interro::QueryBuilder(T)
  # Your app-wide query methods go here
end

# Using structs for immutable models
struct User
  include DB::Serializable

  getter id : UUID
  getter email : String
  getter name : String
  getter role : Role
  getter created_at : Time
  getter updated_at : Time

  enum Role
    Member
    Admin
  end
end

struct UserQuery < Query(User)
  table "users"

  def find(id : UUID) : User?
    where(id: id).first?
  end

  def find(*, email : String) : User?
    where(email: email).first?
  end

  def with_role(role : User::Role) : self
    where role: role.value
  end

  def set_name(user : User, name : String) : User
    Result(User).new
      .validate_presence(name: name)
      .validate_size("name", name, 2.., "characters") # names must be >= 2 characters
      .valid { where(id: user.id).update(name: name).first }
  end

  def create(
    *,
    email : String,
    name : String,
    role : Role = :member,
  ) : User | Failure
     # Inheriting from Interro::QueryBuilder gives us Interro::Validations, including
     # `Result` and `Failure`
    Result(User).new
      .validate_presence(email: email, name: name)
      .validate_format(email, /\w@\w/, failure_message: "email must be in the format `user@domain.tld`")
      .validate_size("name", name, 2.., "characters") # names must be >= 2 characters
      .validate_uniqueness("email") { find(email: email) }
      .valid do
        insert email: email, name: name, role: role.value
      end
  end
end

# INSERT query goes to the primary
case user = UserQuery.new.create(email: "user@example.com", name: "Test User")
in User
  # success
in Failure
  # do something with user.errors
end

# SELECT query goes to a replica
UserQuery.new.each do |user|
  # do something with the `User` instance
end

# UPDATE goes to the primary
# Note also that we're returning a new instance of User
user = UserQuery.new.set_name(user, "New Name")
1 Like

I used avram for my project, if don’t mind it only support pg, the new pattern is not bad anyway.

One more concern need to consider, because avram build SQL with strong typing support, it create many methods use macro based on column name, It can guarantee that many runtime errors will not occur, but this add many restrictions. unless avram support that pattern, othersize, you can’t write SQL yourself use raw format. e.g. you can’t build JOINS SQL more than one condition, like following:

universities left join foos on universities.id = foos.university_id AND foos.user_id = ?

I once wanted to contribute to this code, but I got delayed because it involves a lot of macros, making it difficult to understand, and I was delayed because of other matters.

This is legitimately a hard problem for ORMs. You can provide type safety in the query DSL (type safety can come in multiple forms here and can even apply to dynamic languages like Ruby) or you can allow arbitrary SQL columns/expressions to be used, but getting both is a huge challenge.

This is actually the exact reason Interro allows arbitrary SQL inside query objects, but disallows it outside of them. This way, if you change something about the schema, only the query objects need to be updated. The rest of your application doesn’t need to know about it.

That JOIN clause with Interro might look something like this:

struct UniversityQuery < Query(University)
  table "universities"

  def with_user(user : User)
    self
      # Avoid string concatenation without coupling to a table this
      # object doesn't own by passing the join table name and giving
      # it a unique alias that only this method knows about.
      .left_join(FooQuery.new.sql_table_name, as: "university_with_user", on: "universities.id = university_with_user.university_id")
      .where("university_with_user.user_id": user.id)
  end
end

I keep meaning to add parameterized-query support to the *_join methods so it can just go in there, but the where clause is a decent tradeoff. When that’s added, that with_user method could just call left_join:

def with_user(user : User)
  left_join FooQuery.new.sql_table_name,
    as: "university_with_user",
    on: <<-SQL,
      universities.id = university_with_user.university_id
      AND university_with_user.user_id = $1
      SQL
    args: [user.id]
end
2 Likes

Just for the sake of exhaustiveness : GitHub - spider-gazelle/pg-orm: Postgres ORM for Crystal Lang

2 Likes