module Rotor

rotor

A functional-relational mapper for Quarz: models whose columns are declared in their class body and turned into typed accessors when the program is compiled, and (to come) queries written as Quarz blocks that are translated to SQL. It runs on PostgreSQL through pg.

# Cog.toml of an application
[dependencies]
Rotor = { git = "https://github.com/quarz-lang/rotor", branch = "main" }

The API reference is at https://rotor.api.quarz.dev.

Work in progress. What exists: the schema, queries and statements as values (to_sql), a repository that runs them on PostgreSQL, associations with preloading, migrations, and changesets.

class User < Rotor::Model
  table "users" do                      # `table do`: `users` from the class's name
    column :id, Int, primary: true      # Int, Float, String, Bool, Time, Bytes
    column :name, String
    column :email, String, null: true   # its getter returns String?
    column :age, Int, default: 0
    timestamps                          # created_at, updated_at: Time
    belongs_to Account                  # account_id: Int
    has_many Post                       # posts, through Post's user_id
  end
end

u = User.new(name: "Ann")   # User.new(name: 1) does not compile
u.email                # => nil
u.age                  # => 0
u.changed              # => [:name]
User.table_name        # => "users"
User.primary_key       # => :id
User.columns           # => [Rotor::Column(name: :id, sql: "bigint", ...), ...]
User.load({id: 1, name: "Bob"}).name   # => "Bob", unchanged

Why one block. Quarz runs the statements of a class body independently of each other (its docs/14 §2): a statement does not see what another generated, nor can it generate a member another already did. A column statement of its own could make its accessors, but none could know the other columns to make columns and primary_key; the one table statement sees them all.

Models are immutable and Shareable

A model never changes: with gives a new one, the one it was called on left as it was, and the repository's insert, update and touch give the model as saved.

u = repo.get(User, 1).as(User)
v = u.with(name: "Bob", email: nil)   # email: NULL; age left as it was
v.changed                             # => [:name, :email]
u.name                                # => "Ann"
v = repo.update(v)                    # saved: v.changed => []

Queries and statements

A query is a value, built from blocks that are never run: each is received as its tree (Quarz's Quoted, docs/14 §6) and translated to SQL when the query is built, with the values it captured as parameters.

min = 18
q = User.where { age >= min && !email.nil? }.order { name }.desc.limit(10)
q.to_sql
# => ["SELECT \"id\", ... FROM \"users\" WHERE \"age\" >= $1 AND NOT \"email\" IS NULL ORDER BY \"name\" DESC LIMIT 10", [18]]

User.where { |u| ids.include?(u.id) }        # "id" = ANY($1), the array one parameter
User.where { name.start_with?(prefix) }      # "name" LIKE $1, the prefix escaped
User.select { Summary.new(id, name) }        # Query[Summary]: fetches "id", "name"
User.where { admin }.count                   # Query[Int]: SELECT count(*) ...
User.where { admin }.exists                  # Query[Bool]: SELECT EXISTS (...)
User.where { age < 13 }.delete               # Statement: DELETE FROM ...
User.where { admin }.update { {age: age + 1, name: "x"} }   # UPDATE ... SET
Rotor::Statement.insert(u).returning(:id)
Rotor::Statement.update(u)                    # the changed columns, nil for none
Rotor::Statement.delete(u)                    # by the primary key (refused once it was assigned)

Parameter types

PostgreSQL types a parameter by what it meets; rotor casts one where that type would not be Quarz's. What follows from it:

  1. How a parameter is typed. Beside a column it is left untyped and takes the column's type ("age" > $1; a Float against a Float column too, which may be real or numeric: r == 0.1 finds the real 0.1). An Int that meets anything but a column (a literal, an expression: age < big + 1, where $1 would be an integer) is cast ::bigint, and a Float that meets anything but a Float column ::double precision (age > 25.5, which would be read as a bigint). A parameter of any class is cast by its value's class (::bigint, ::double precision, ::text, ::boolean, ::timestamptz, ::bytea) in a CASE's branch or bare condition, under a unary minus (age > -x), beside another parameter (lo < hi, compared as text otherwise) and under IS NULL (a nil there as ::text). In arithmetic a Float parameter is double precision against any column, and an Int one a bigint against any column but an Int's, so that r + 0.1 on a real column is computed as Quarz does on the value read. An array is one parameter: pg sends an array of Ints as a bigint[] (of Bools, boolean[]; of Bytes, bytea[]), the column decides the others' type (Floats beside a column that is not a Float's are cast, $1::double precision[]).
  2. integer columns of tables rotor did not create. rotor's schema makes an Int column a bigint. A table made otherwise may have integer ones, and rotor, which does not know a column's SQL type, does not cast by it (decided 2026-11-07, #28). A parameter beside such a column is an integer: n == x is exact for an x in its range, and one beyond it is an error from the server (value "3000000000" is out of range for type integer) where Quarz finds no row; the column's arithmetic with such a parameter or an Int literal is an integer's, which overflows the same way (n * 100000000). A parameter cast ::bigint compares with the column through PostgreSQL's cross-type operators (integer = bigint is in the integer_ops btree family), exactly and served by an index on the column: n > (flag ? x : y) ("n" > CASE WHEN $1::boolean THEN $2::bigint ELSE $3::bigint END) and xs.include?(n) ("n" = ANY($1) of a bigint[]) both use an index on n; arithmetic with one is a bigint's. (test/repo_test.qz checks these on such a table.)
  3. A Time in a CASE's branch is cast ::timestamptz, the type rotor's schema gives a Time column. Against a timestamp (without time zone) column of a table rotor did not create, PostgreSQL compares through its cross-type operators, reading the column's values in the session's time zone. Keep such columns timestamptz, or set the session's TimeZone to the one their values are in. (Decided 2026-11-06, #31: left untyped, the comparison fails, as it did before; cast ::timestamp, timestamptz columns would have the mirror problem.)
  4. Arithmetic on what may be an Int or a Float ((if b then age else score end) + 1): its +, - and * are computed in double precision for both, as SQL's CASE of a bigint and a double precision is one, so an Int row differs from Quarz beyond 2**53 (#31); its / and % are refused (above).

Running them: Rotor::Repo

PG::Pool.open("postgres://app@localhost/shop") do |pool|
  repo = Rotor::Repo.new(pool: pool)       # or connection: a PG::Connection
  u = repo.insert(User.new(name: "Ann"))   # key, defaults and timestamps loaded back
  repo.all(User.where { age >= 18 })       # Array[User]
  repo.one(User.where { email == e })      # User?; MultipleResultsError for more
  repo.one!(q); repo.first(q); repo.get(User, 1)
  repo.count(q); repo.exists?(q)
  u = repo.update(u.with(age: 31))         # the changed columns, and updated_at
  u = repo.touch(u)                        # updated_at alone
  repo.delete(u)                           # the model; StaleError when its row is gone
  repo.delete_all(q); repo.update_all(q) { {age: age + 1} }   # Int
  repo.transaction { |tx| tx.insert(other) }                  # a Repo on the transaction
  repo.exec("VACUUM members")                                 # rows changed
  repo.query(User, "SELECT * FROM users WHERE age > $1", [18])  # Array[User]
  repo.query("SELECT id, name FROM users") { |r| Summary.new(r.int(0), r.string(1)) }
end

Associations

class User < Rotor::Model
  table "users" do
    column :id, Int, primary: true
    belongs_to Account                  # account_id; user.account -> Account?
    has_many Post                       # user.posts -> Rotor::HasMany[Post]
  end
end

u = repo.get(User, 1).as(User)
u.posts.loaded?                         # => false
u.posts.size                            # NotLoadedError: User#posts is not loaded
repo.all(u.posts.query.where { published })   # the association as a query
u = repo.preload(u) { [posts, account] }      # one query per association
u.posts.map { |p| p.title }             # Post's, typed
u.account                               # Account?
users = repo.preload(repo.all(User.query)) { nest(posts) { comments } }
users = repo.preload(users) { nest(posts) { [nest(comments) { author }, tags] } }   # any depth
repo.all(User.where { age > 18 }.preload { posts })   # preloaded after loading

Many to many: has_many ..., through:

class Post < Rotor::Model
  table do
    column :id, Int, primary: true
    has_many Tag, through: PostTag      # post.tags -> Rotor::HasMany[Tag]
  end
end

class PostTag < Rotor::Model             # the join table is a model
  table do
    column :id, Int, primary: true
    belongs_to Post
    belongs_to Tag
    column :position, Int, default: 0   # columns of its own are ordinary
  end
end

repo.all(post.tags.query.where { name.start_with?("q") })
posts = repo.preload(posts) { tags }      # one query
repo.insert(PostTag.new(post_id: post.id, tag_id: tag.id))   # writing: the join model

Joins

A query joins other tables in one SELECT, each table with its alias ("u0", "p1"):

# A `for` comprehension over several queries: an INNER JOIN on its
# conditions (the inner query's and the `when`), a CROSS JOIN for none.
for u in User.query, p in Post.where { published } when p.user_id == u.id do [u.name, p.title] end
# Through an association, joined by its key, with more conditions.
for u in User.query, p in u.posts.query when p.views > 10 do p end
# Explicit forms, giving pairs: by an association, or by a condition.
User.query.join { posts }.where { |u, p| p.published }   # Query[[User, Post]]
User.query.join(Post) { |u, p| p.editor_id == u.id }

Migrations

A database's schema changes through migrations, as in Ecto: classes with a version, kept apart from the models (there is no schema.rb; the models say what the program reads, the migrations what the database is). The command that runs them (rotor migrate, rollback, status) and the generator of migration files come with bezel; this cog gives the API they call.

class CreateUsers < Rotor::Migration
  version 20261107120000          # an Int, unique; the generator will use the UTC time

  def change
    create_table :users do |t|    # with `id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY`
      t.column :email, String     # Int, Float, String, Bool, Time, Bytes, as in `table`
      t.column :name, String, null: true
      t.column :age, Int, default: 0
      t.column :price, sql: "numeric(12,2)", null: true
      t.timestamps                # created_at, updated_at: timestamptz NOT NULL
    end
    create_index :users, [:email], unique: true          # users_email_index
    add_reference :posts, :user, on_delete: :cascade     # user_id REFERENCES users (id), and an index
    execute "UPDATE posts SET views = 0", down: "SELECT 1"
  end
end

class SplitNames < Rotor::Migration
  version 20261108090000

  def up
    add_column :users, :last_name, String, null: true
    repo.exec("UPDATE users SET last_name = split_part(name, ' ', 2)")
  end

  def down
    remove_column :users, :last_name
  end
end

migrator = Rotor::Migrator.new(repo, [CreateUsers, SplitNames])
migrator.migrate                    # every pending one, ascending: the versions run
migrator.migrate(to: 20261107120000)
migrator.rollback                   # the highest version applied; rollback(steps: 3), rollback(to: version)
migrator.pending                    # => [20261108090000]
migrator.status                     # => [[20261107120000, "CreateUsers", :up], [20261108090000, "SplitNames", :pending]]

Tables from the models: Rotor::Schema.create

For a development database or a test's, Rotor::Schema.create(repo, [User, Post]) creates the models' tables from their table blocks in one transaction: the columns (an Int primary key as an identity, the models' defaults as the columns'), an index on each belongs_to column, and its foreign key, added once every table exists (so models may refer to each other in a cycle). if_not_exists: true leaves the tables that exist. Rotor::Schema.drop(repo, models, if_exists:) drops them in one DROP TABLE, without CASCADE. A production database goes through migrations.

Changesets

Input from outside (a form, JSON) goes through a changeset, as in Ecto: an immutable Rotor::Changeset[M] of the changes cast to the columns' types, validated, and carrying the errors meant for a user. The repository saves it and answers with a Rotor::Outcome rather than raising for what the user got wrong. Models keep working as they are (with, repo.insert(model)); a changeset is the layer for untrusted input.

class User < Rotor::Model
  table "users" do
    column :id, Int, primary: true
    column :email, String
    column :name, String
    column :age, Int, null: true
    column :role, String, default: "user"
    column :password, String, null: true, redact: true   # **redacted** in inspect
    timestamps
  end

  def self.registration(params: Hash[String, Object?]) -> Rotor::Changeset[User]
    Rotor::Changeset.cast(User, params, [:email, :name, :age, :password])   # cast(user, ...) for an existing one
      .validate_required([:email, :name])
      .validate_length(:name, max: 100)
      .validate_format(:email, /\A[^@\s]+@[^@\s]+\z/)
      .validate_number(:age, min: 0, max: 150)
      .validate_confirmation(:password)                    # password_confirmation in params
      .validate(:email) { |u| u.email.end_with?("@example.com") ? "is reserved" : nil }
      .unique_constraint(:email)                           # the index users_email_index
  end
end

# The form flow: cast and validate, save, show the errors.
case repo.insert(User.registration(request.form_params))
in Rotor::Outcome::Ok(user) then redirect_to(user)
in Rotor::Outcome::Invalid(cs) then render(cs.errors_on(:email), cs.errors_on(:name))
end

Developing

To work on rotor against a local checkout of pg, write Cog.local.toml beside Cog.toml (git ignores it):

[patch]
PG = { path = "../quarz-pg" }

The lock ignores it: switching the local patch on or off changes no committed file, and CI and users get pg's git main.

Tests

quarz test (and --release, --doc). The repository's tests run on PostgreSQL through pg's PG::Testing: PG_TEST_URL when it is set, else a throwaway cluster where PostgreSQL's server programs are installed; with neither they are omitted.

Classes and modules