Record (ORM)

Altair::Record is the ORM: SQLite3 and PostgreSQL adapters behind one interface, a migrations DSL, compile-time column metadata, validations, callbacks and associations. Every value travels as a bind parameter — SQL strings stay constant, so values can never be interpolated into a query.

Connecting

The application points at a database; ALTAIR_DB_URL overrides it at run time:

class Blog < Altair::Application
  config.db_url = ENV["ALTAIR_DB_URL"]? || "sqlite3://./db/blog.db"
end
# SQLite (default) — or PostgreSQL:
ALTAIR_DB_URL="postgres://postgres:secret@localhost:5433/blog" altair server

Models

A model maps to a table and gets typed accessors for every column, generated from db/schema.cr at compile time:

class Post < Altair::Record::Model
  table :posts

  has_many :comments, dependent: :destroy
  validates_presence_of :title
end

CRUD and finders

post = Post.new(title: "Hello")
post.save                                  # false when invalid
post.save!                                 # raises Altair::Record::Error instead

Post.create(title: "Hello")                # save + validate, one call
post.title = "Edited"
post.update(title: "Edited")               # save changes in place
post.delete                                # delete the row

Post.find(5)                               # Post?  — nil when missing
Post.find!(5)                              # Post   — raises when missing
Post.find_by_title("Hello")                # Post?  — find_by_<column>
Post.find_by_title!("Hello")
Post.all                                   # Relation(Post)
Post.count                                 # Int64
Post.exists?(5)
Post.pluck(:title)                         # Array(Value)

Post.all is lazy and caches after iteration; chain .where to filter:

Post.all.where(published: true)
Post.all.where(:views, :>=, 15).order(:created_at).to_a

Migrations

Migrations live in db/migrations/ as timestamped files; altair db:migrate runs pending ones and altair db:rollback undoes the last.

class CreatePosts < Altair::Record::Migration
  def up(schema : Altair::Record::Schema) : Nil
    schema.create_table(:posts) do |t|
      t.string :title
      t.text :body
      t.boolean :published
      t.datetime :created_at
      t.datetime :updated_at
    end
    schema.add_index(:posts, :title)
  end

  def down(schema : Altair::Record::Schema) : Nil
    schema.drop_table(:posts)
  end
end

Column types are string, text, integer, bigint, float, decimal, boolean, datetime and json. Timestamps aren't automatic — add created_at / updated_at columns and the model fills them when they exist. db/schema.cr is regenerated after every run and feeds the compile-time column metadata, so the model's accessors always match the database.

Validations

Validations run before save; failures land in errors and valid? returns false:

class Post < Altair::Record::Model
  validates_presence_of :title
  validates_length_of :title, maximum: 140
  validates_numericality_of :views, greater_than: 0, integer: true
  validates_uniqueness_of :slug, scope: :category_id
  validates_inclusion_of :status, in: %w[draft published]
  validates_exclusion_of :slug, in: %w[admin login]
  validates_format_of :email, with: /\A[^@\s]+@[^@\s]+\z/
  validates_confirmation_of :password
end
post = Post.new(title: "")
post.valid?            # false
post.errors[:title]    # ["Title can't be blank"]
post.errors.full_messages

Callbacks

before_save, after_save, before_create, after_create, before_update, after_update, before_destroy, after_destroy — each takes method names to run around the lifecycle:

class Post < Altair::Record::Model
  before_save :slugify
  after_create :notify

  private def slugify : Nil
    self.slug = title.downcase.gsub(' ', "-")
  end
end

Associations

belongs_to, has_many and has_one generate typed accessors and foreign keys. dependent: clears the child rows when the parent is destroyed, and includes eager-loads a whole relation in one batched query — no query per row:

class Post < Altair::Record::Model
  has_many :comments, dependent: :destroy
  has_one :profile
end

class Comment < Altair::Record::Model
  belongs_to :post
end
post.comments.each { |c| puts c.body }
Post.all.includes(:comments).to_a          # 2 queries total

Loading and counting without N+1

Association accessors are lazy: post.comments runs one query the first time it is touched. That is free for a single record and quietly catastrophic inside a loop — posts.each { |p| p.comments.size } is one query per post. The rule: an association accessed inside a loop belongs in includes.

posts = Post.all.includes(:comments).to_a   # one batched query per association
posts.each { |p| p.comments.size }          # no extra queries

Relation#count and size never materialize the rows — they run COUNT(*) with the scoped where clauses (and reuse the cached rows once loaded):

Post.all.where(published: true).count       # SELECT COUNT(*), no row loading
Post.all.includes(:comments).size           # 2 queries, rows not materialized

find_each streams in bounded batches ordered by primary key and keeps the scoped filters and preloaders across batches:

Post.all.where(published: true).includes(:comments).find_each(batch_size: 100) do |post|
  post.comments.each { |c| c.touch }
end

In the Development environment the framework watches every query for the N+1 signature — the same SQL firing more than config.n_plus_one_threshold (3) times within one request — and logs a warning naming the statement. Production never pays the detector's cost; disable it with config.detect_n_plus_one = false if it is ever noisy.

Transactions

transaction runs its block atomically; a raise rolls it back:

Post.transaction do
  post.save!
  comment.save!
end

Adapters

SQLite3 ships in the framework; PostgreSQL is enabled by requiring the adapter and crystal-pg in the project's shard:

require "altair/record/adapters/postgresql"

The same application code runs on either — the adapter interface is the only thing that changes.

Performance defaults

Altair ships performance-sane defaults, all overridable through config:

  • The server resizes Crystal's execution context to the available workers on boot, so requests fan out across cores instead of running on the single OS thread the runtime starts with. Set the CRYSTAL_WORKERS environment variable to your CPU limit inside containers; disable with config.parallel_execution = false.
  • The connection pool opens warm and stays warm — db_initial_pool_size 2, db_max_idle_pool_size 2, db_max_pool_size 10 — avoiding connection-creation bursts and reconnect churn under load. Tune with the config.db_* properties.