package

github.com/HarbinsonAdam/luna.cr

master / published Mar 16, 2026 / repository

A rails like model framework built off of ActiveModel to work with SQL and NoSQL Databases

Luna.cr

Luna is an ActiveRecord-style ORM for Crystal, built on top of active-model.

It supports:

  • Model persistence (save, update, destroy)
  • Relation-style querying (where, order, limit, aggregates)
  • Associations (belongs_to, has_one, has_many)
  • Transactions
  • Migrations
  • Single Table Inheritance (STI)

Installation

Add to shard.yml:

dependencies:
  luna:
    github: HarbinsonAdam/luna.cr
  sqlite3:
    github: crystal-lang/crystal-sqlite3

Then:

shards install

Setup

Register at least one connection before using models:

require "luna/adapters/sqlite3"
require "luna"

Luna::Setup.register :default, "sqlite3:./db/app.db"
# Optional additional connections
Luna::Setup.register :reports, "sqlite3:./db/reports.db"

# Optional: Rails-style SQL logging with elapsed ms
Luna::Setup.enable_query_logging

Supported URL schemes include sqlite3:, postgres://, and mysql://.

Luna no longer hard-depends on every database adapter. Add only the adapter shard you need and require the matching adapter loader before require "luna":

require "luna/adapters/sqlite3"
require "luna"
require "luna/adapters/pg"
require "luna"
require "luna/adapters/mysql"
require "luna"

When query logging is enabled, logs include model/action labels, elapsed time, SQL, params, and transaction markers:

Product Load (0.3ms) SELECT * FROM products WHERE id = ? LIMIT ? -- params: [81, 1]
(0.0ms) begin transaction
Product Update (0.2ms) UPDATE products SET name = ? WHERE id = ? -- params: ["Desk", 81]
(0.4ms) commit transaction

Defining Models

class User < Luna::BaseModel
  primary_key id
  attribute name : String
  attribute email : String
  attribute active : Bool, default: true
end

Use a non-default connection:

class AuditLog < Luna::BaseModel
  connection :reports
  primary_key id
  attribute message : String
end

CRUD

# Create
user = User.new(name: "Ada", email: "ada@example.com")
user.save

# Read
found = User.find(user.id.not_nil!)     # User?
found! = User.find!(user.id.not_nil!)   # raises Luna::RecordNotFound if missing

# Update
found!.active = false
found!.save

# Delete
found!.destroy

Querying (Relation API)

all and where return a relation, so you can chain:

relation = User.where({active: true}).order("id DESC").limit(10)
users = relation.all
first_user = relation.first

Other helpers:

User.count
User.exists?
User.exists?({email: "ada@example.com"})
User.pluck("email", as: String)
User.sum("id", as: Int64)
User.avg("id", as: Float64)
User.min("id", as: Int64)
User.max("id", as: Int64)

Associations

class Author < Luna::BaseModel
  primary_key id
  attribute name : String
  has_many posts, Post
end

class Post < Luna::BaseModel
  primary_key id
  attribute author_id : Int64?
  attribute title : String
  belongs_to author, Author
end

Eager loading:

posts = Post.order("id ASC").includes(:author).all

Nested eager loading is supported:

authors = Author.includes(posts: :comments).all

Transactions

Global transaction:

Luna.transaction do
  User.new(name: "A", email: "a@example.com").save
  User.new(name: "B", email: "b@example.com").save
end

Model-scoped transaction (uses model connection):

User.transaction do
  # ...
end

Rollback without bubbling an error:

Luna.transaction do
  User.new(name: "Temp", email: "temp@example.com").save
  raise Luna::Rollback.new
end

STI (Single Table Inheritance)

Parent model defines discriminator column with sti. Child models define type with sti_type.

class Animal < Luna::BaseModel
  primary_key id
  sti kind
  attribute name : String
end

class Dog < Animal
  sti_type :dog
  attribute bark_volume : Int64?
end

class Cat < Animal
  sti_type :cat
  attribute lives_left : Int64?
end

Behavior:

  • Dog/Cat use the parent table
  • Saving a child sets kind automatically
  • Querying Animal hydrates the correct subclass
  • Querying Dog or Cat automatically scopes by type

State Machines

Luna supports enum-backed state machines with Rails-style transition events.

enum JobState
  ENQUEUED
  RUNNING
  FAILED
  COMPLETED
end

class RenderJob < Luna::BaseModel
  primary_key id
  attribute status : JobState, default: JobState::ENQUEUED

  state_machine status, JobState, {
    start:   {from: :enqueued, to: :running},
    fail:    {from: [:enqueued, :running], to: :failed},
    finish:  {from: :running, to: :completed, after: :emit_completed_event},
  }
end

What this gives you:

  • Enum values persist to the DB as lowercase strings (for example completed)
  • Event methods (start, start!, etc.) with transition guards
  • Exceptions on invalid transitions (Luna::InvalidStateTransition)
  • Save-time protection against invalid direct state assignment (model.status = ...; model.save)
  • Per-state predicate helpers (for example status_running?)

Migrations

Define a migration by inheriting Luna::BaseMigration. Migrations auto-register when inherited.

class V20260101000000_CreateUsers < Luna::BaseMigration
  def change
    create_table :users, id: true do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.boolean :active, default: true
      t.timestamps
    end

    add_index :users, :email, unique: true
  end
end

Run migrations:

runner = Luna::MigrationRunner.new(:default)
runner.run_migrations

Development

Run specs:

crystal spec

Luna.cr is currently in development. The project is mirrored from a private GitLab repository to GitHub for public access.

Contributors

API

  • Luna
  • UUID

    Represents a UUID (Universally Unique IDentifier).