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
tableruns its block at compile time on aRotor::Schema, then generates, for each column, a getter typed by the column (T, orT?fornull: true), a getter for each association (Associations, below), the constructors (below),with(below), and the class methodstable_name,columns(Array[Rotor::Column]),primary_key(Symbol?) andassociations(Array[Rotor::Association]).- A model holds its values in one hash (
to_h) and remembers the columns assigned since it was made or loaded (changed,changed?;clear_changesgives it without them).newtakes each column as an optional keyword, aswithdoes (User.new(name: "Ann", age: 3), checked when the program is compiled), and starts from the columns' defaults for the others: the columns given are changed, in the columns' order; reading a column that has no default and was never set raisesRotor::MissingAttributeError(anull: trueone reads nil).load(values)checks each value against its column's type. - A model, and a class between it and
Rotor::Model, does not writeinitialize:tablegenerates the model's constructors (def_initialize, Quarz's docs/14 §5), the keyword one and the onewithand the repository build through (initialize(point: Rotor::Checkpoint)). One of the same parameters does not compile ("already has aninitializewith the same parameters"), a writteninitialize()makesnewwithout arguments ambiguous, and one in a class between makes the model'stablean error. belongs_to Accountandhas_many Posttake the associated model's class (compile-time code cannot turn a name into one), and declare the association (account,posts; orname:) and, forbelongs_to, its key column (account_id; orforeign_key:, as forhas_many's key in the other table, by defaultuser_idfrom this model's name). Two models may name each other, in one file or two. A target without atableblock compiles, and raisesRotor::SchemaErrorwhen its metadata is read; a generic target's type arguments are not kept. Their getters and preloads are below (Associations).- Every model runs its own
table. Its superclass isRotor::Modelor a class between them without atable(for methods models share): a model whose superclass has a table does not compile (at itstable, when the superclass's ran first: in a cog it depends on, or earlier in its own cog's order), and a subclass that inherits a table without one of its own raisesRotor::SchemaErrorat its first use ("Guest has no table: it inherits User's; a model declares its own; single-table inheritance is not provided"). The memberstablegenerates belong to the class it ran on (itswithis typed so), and single-table inheritance (one table for a hierarchy, with a type column) is not provided. - A
Timecolumn takes nodefault:(the compiler cannot bake a time into the program). - A model without a
tableblock raisesRotor::SchemaErrorat its first use; a secondtableblock in one class does not compile (already has a class method table_name).
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 => []
withtakes each column as an optional keyword of the column's type (T?fornull: true, soemail: nilsets NULL, while a column left out keeps its value):with(age: "x"),with(name: nil)for a column that is notnull: true, and an unknown column do not compile. The primary key may be given (User.new(id: 1)), but a model whose key was assigned is not updated or deleted by it (the row it named is not known).- So models are
Shareable(Quarz's docs/03): one can be captured byTask.spawn, sent on aChannelor held by anAtom, and read in another task with no copy and no lock. - As in Ecto (structs and changesets) and Slick (case classes and
copy), and unlike Active Record, a model is a value: what to save is the model given, not a state the object accumulated. - A model class is
Shareable, so it cannot declare avarfield (the checker refuses it); a value derived from the columns is a method. - Queries and statements are
Shareabletoo (below).
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)
- The row is the block's receiver (
where { age > 18 }) or its parameter (where { |u| u.age > 18 }, the formforcomprehensions use). A block that names nothing of the row (where { true }) fits both and does not compile: writewhere { |u| true }. - What translates: the columns' getters;
== != < <= > >=,+ - * / %(+of strings is||) on numbers, strings, booleans and times;&&,||,!;nil?;if/?:(CASE WHEN);include?on a captured array or a literal one of literals (= ANY($1), the array one parameter whatever its length; an array literal with other expressions is anINlist);String#start_with?,end_with?andinclude?of a string value (LIKE, escaped);Rotor::SQL.like,ilike,lower,upperandnot_nil(below); literals and captured values. Anything else raisesRotor::UntranslatableErrorwhen the query is built, naming the method and where it is. ==and!=keep Quarz's meaning of nil. Against a value they are decided by the value:IS NULLfor nil,=otherwise (which an index serves), so a query's SQL can differ by what it captured (pg's statement cache prepares each text once);!=of a nullable column and a value isIS DISTINCT FROM, which counts nil as different. Of values whose classes never meet (name == true,name != 1;Ints andFloats meet, nil isIS NULL) they are refused when the query is built, as always false (!=: always true), where PostgreSQL would refuse them or compare text (#41); where both can be nil, equal only then, the message says to writex.nil? && y.nil?. Between columns or expressions they are=and<>when neither side can be NULL (age == (flag ? x : y)with capturedInts is"age" = CASE ... END, which an index serves), andIS [NOT] DISTINCT FROM, which no index serves, when one can: a column that admits NULL, or aCASEwith a nil branch, withoutelseor with such a column in a branch.include?finds nil withIS NULL.- Every form rotor translates keeps Quarz's meaning; the shapes it
cannot are refused when the query is built
(
Rotor::UntranslatableError, saying what to write instead). A comparison of something that can be NULL (email == e,=in SQL;ids.include?(email),IN) is NULL where Quarz has false, which is the same as false in a condition: awhere,filteror join condition, an operand of&&and||, anif's condition or branches. Anywhere else it is refused: under!(!(email == e)would leave out the rows Quarz takes: writeemail != e), and as a value (anorderkey, anupdatevalue, an operand of==or another call).select's block is not translated (below):select { email == e }is false on a NULL row. col.nil?and!col.nil?areIS NULLandNOT ... IS NULL, the explicit form, and what the left side of a&&(!email.nil? && ...), of a||(email.nil? || ...) or anif's condition says of a column holds on the other side: there it is not NULL, so its comparisons are two-valued. SQL's<>on a nullable column, which an index can serve, is written out:!email.nil? && email != eisNOT "email" IS NULL AND "email" <> $1(whereemail != ealone isIS DISTINCT FROM), andemail.nil? || !(email == e)translates.Int#/floors andInt#%takes the divisor's sign, as in Quarz (floor(a::numeric / b)::bigint,(a % b + b) % b);age / 2.0is aFloat's division, and%with aFloatis refused (PostgreSQL has none). AnInt's/or%on what may be anIntor aFloat((if b then age else score end) / 2) is refused: which one SQL does would depend on the row. Its+,-and*are computed in double precision for both (see Parameter types).- Parameters are typed by what they meet, and cast by their value's
class where PostgreSQL would take another type than Quarz's (see
Parameter types). An
ifthat is nil either way isNULL; one whose condition is a captured value other than aBoolis the branch Quarz's truthiness takes, and so is an&&or||whose left operand is one (s && adminisadminfor a non-nils,sfor a nil one), and!of one isTRUEorFALSE; any other condition that is not aBool(a column, an expression; of anif,&&,||or!) is tested as Quarz does, true unless nil:email ? 1 : 0isCASE WHEN "email" IS NOT NULL THEN 1 ELSE 0 END(one that may be aBoolor another value is refused, but for anif, whose branches are each tested in theCASE:(on ? label : level) ? 1 : 0isCASE WHEN CASE WHEN "on" THEN "label" IS NOT NULL ELSE "level" IS NOT NULL END THEN 1 ELSE 0 END, so that they need no type in common, #46). A column so tested on the left of&&(or under!on the left of||) is not NULL on its right:!(email && email == x)isNOT ("email" IS NOT NULL AND "email" = $1). Anupdatechecks against the column the class of its value, as Quarz gives it: a known value's, a column's, an expression's (anif's branches, arithmetic by its operands:{qty: -f}with aFloat,{qty: price}and{name: qty}are refused, where PostgreSQL would round or write the number as text; anIntinto aFloatcolumn is accepted; a column that can be NULL is nil there, so{name: email}into a non-null column is refused, as a nil is: writeemail || name, #42).a && banda || bare one of their operands in Quarz. As a condition they test their operands as above ("email" IS NOT NULL OR "name" IS NOT NULL); as a value (anupdate's, an order key, an operand, a branch of anifthat is not aBool) they are that operand:email || nameisCOALESCE("email", "name"),email && nameisCASE WHEN "email" IS NOT NULL THEN "name" ELSE NULL END, and of twoBools,vip && adminandvip || adminareANDandOR, or, whenvipcan be NULL,CASE WHEN "vip" IS TRUE THEN "admin" ELSE "vip" ENDandCASE WHEN "vip" IS TRUE THEN TRUE ELSE "admin" END(Quarz'snil && xis nil). One that may be aBoolor another value (admin && name,email || admin) is refused as a value, and so is one of other classes that do not meet (email || age;Ints andFloats do), and anifof them as a value other than anupdate's (on ? level : labelas an order key, #43): no SQL type holds both (decided 2026-11-07, #36). Arithmetic of twointegers, as PostgreSQL typesIntliterals and CASEs of them, casts the left one to a bigint:2000000000 * 2is2000000000::bigint * 2, where anintegerwould overflow;age + 1stays as it is. include?iscol = ANY($1)with the array as one parameter (pg's array parameters): one statement and one translation for every length. A nil item is left out of the array andOR col IS NULLadded (= ANYfinds no NULL); an array of nils only iscol IS NULL; the empty array is{}, in which= ANYfinds nothing. Items of a class the target's never equals are refused as==is:[1, 2].include?(name)is always false, and in a literal of expressions the item that cannot be equal is named (#45). The items are of one class, orInts andFloats (sent asFloats); pg types an array ofInts,Bools orBytes, the column decides the others' (Floats beside a column that is not aFloat's are cast,$1::double precision[], as aFloatis). An array literal with expressions ([age, 3].include?(id)) is anIN (...)list instead: its SQL is fixed by the literal's length in the source, so it makes no statement per length (the reason a captured array is one parameter,= ANY($1)), andINis the form every SQL dialect has, where= ANY(array)is PostgreSQL's.- Comparing a nullable column:
where { age > 18 }is allowed in query blocks, withageanInt?. Every query block is checked withusing Rotor::Compare(Quarz's docs/01 §7), whose extensions givenilandInt,Float,StringandTimethe comparisons<,<=,>and>=with a nil (IntandFloatmixed either way): nil compares false (Quarz's meaning in these blocks), which is what SQL's NULL does in a condition. With a literal nil (level > nil, or anif,unlessor?:whose branches are literal nils, a missingelsecounting as one) such a comparison is always false, and refused: compare with a value, or usenil?(level == nilandlevel != nilareIS NULLandIS NOT NULL). A captured nil is an unset value, and kept (> $1, or> NULLthrough anif). Where Quarz's false and SQL's NULL would differ (!(age > 18), anorderkey, anupdatevalue), the query is refused, as above. Outside query blocksInt? > Intis still a type error, and nil has no arithmetic (age + 1is one in a block too). (As for any column, SQL compares strings by the database's collation, Quarz by bytes.)Rotor::SQL.not_nil(age)is the column as aTfor the explicit cases, where the condition before it says it is not NULL:where { !age.nil? && Rotor::SQL.not_nil(age) + 1 > 18 }; anywhere else it is refused. - Identifiers are always quoted. The SQL is PostgreSQL's
(
Rotor::Dialect), the one dialect for now. - A block's translation is kept for the process, by its call site
(Quarz's
Quoted#site) and the models of the query's tables: building a query again where it was built before translates nothing, and fills in the parameters with the values captured this time. The SQL may depend on what kind of values the block captured (nil isIS NULL, an array's items' class and whether it holds a nil, not its length), so each kind seen at a site is an entry of its own. A key (a call site over its models) keeps at most 8 entries, never evicted; a kind beyond that is translated on each build, as without the cache: the kinds of several captures multiply (four nullable ones make 16). Awhereof three conditions with an order and a limit builds in about 10 µs compiled (22 µs translating each time; 19 µs interpreted, 52 µs before). - Queries are
Shareable(Quarz's docs/03): a query can be a constant (ACTIVE = User.where { active }), be captured byTask.spawnor sent on aChannel, and run in another task; so can aRotor::Statement. select's block is evaluated in Quarz, not in SQL: the query fetches the columns the block reads (every column when it calls another method of the row or passes it on), and each row, loaded with those only, gives the block's value. Reading a column it did not fetch raisesRotor::NotLoadedError. Counting iscount, a query's shape.
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:
- How a parameter is typed. Beside a column it is left untyped and
takes the column's type (
"age" > $1; aFloatagainst aFloatcolumn too, which may berealornumeric:r == 0.1finds thereal0.1). AnIntthat meets anything but a column (a literal, an expression:age < big + 1, where$1would be aninteger) is cast::bigint, and aFloatthat meets anything but aFloatcolumn::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 aCASE's branch or bare condition, under a unary minus (age > -x), beside another parameter (lo < hi, compared as text otherwise) and underIS NULL(a nil there as::text). In arithmetic aFloatparameter is double precision against any column, and anIntone a bigint against any column but anInt's, so thatr + 0.1on arealcolumn is computed as Quarz does on the value read. An array is one parameter: pg sends an array ofInts as abigint[](ofBools,boolean[]; ofBytes,bytea[]), the column decides the others' type (Floats beside a column that is not aFloat's are cast,$1::double precision[]). integercolumns of tables rotor did not create. rotor's schema makes anIntcolumn abigint. A table made otherwise may haveintegerones, 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 aninteger:n == xis exact for anxin 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 anIntliteral is aninteger's, which overflows the same way (n * 100000000). A parameter cast::bigintcompares with the column through PostgreSQL's cross-type operators (integer = bigintis in theinteger_opsbtree 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) andxs.include?(n)("n" = ANY($1)of abigint[]) both use an index onn; arithmetic with one is abigint's. (test/repo_test.qz checks these on such a table.)- A
Timein aCASE's branch is cast::timestamptz, the type rotor's schema gives aTimecolumn. Against atimestamp(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 columnstimestamptz, or set the session'sTimeZoneto the one their values are in. (Decided 2026-11-06, #31: left untyped, the comparison fails, as it did before; cast::timestamp,timestamptzcolumns would have the mirror problem.) - Arithmetic on what may be an
Intor aFloat((if b then age else score end) + 1): its+,-and*are computed in double precision for both, as SQL'sCASEof a bigint and a double precision is one, so anIntrow 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
run(statement)gives the rows changed andrun(query)the rows; the others are conveniences over them.- Values are decoded by the columns' types through pg's readers, times in UTC.
- Timestamps are set by the repository, never by SQL:
insertsetscreated_at(unless the model has one) andupdated_atto now in UTC, to the microsecond (what PostgreSQL keeps, so a value read back equals the one written);updatesetsupdated_atonly when a column changed, and runs no UPDATE when none did;touchwrites it alone.update_alland raw SQL set none. insert,updateandtouchgive the model as saved, the one given unchanged (so a statement that fails leaves nothing to put back).update,touchanddeleteof a model whose row no longer exists (the statement changed no row) raiseRotor::StaleError, naming the model and its key (Ecto'sStaleEntryError). Transactions do not nest.Rotor::Adapter(query,transaction) is the boundary to the database;Rotor::Adapters::PGis pg's, over a pool or a connection.
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
- No lazy loading (as in Ecto): a getter gives what was preloaded and
raises
Rotor::NotLoadedErrorotherwise.has_many's getter is aRotor::HasMany[T],Enumerableover the preloaded models, whosequeryis the association as a query (refined and run as any) andloaded?says whether they are there;belongs_to's gives the model or nil, nil for a NULL key even when not preloaded. repo.preload(models) { ... }andrepo.preload(model) { ... }load what the block names, the getterstablegenerated, so that a misspelling does not compile:{ posts },{ [posts, account] }, and withnestthe associations of their models ({ nest(posts) { comments } },nest(account) { users }), and so on at any depth ({ nest(posts) { nest(comments) { author } } }); a name that is not an association of its level's model is refused naming that model. One query per association and level,WHERE "user_id" = ANY($1)(the keys one array parameter, however many), a model's children in the order of their primary key; an association already preloaded is not loaded again, and one named twice is one preload. The models given are left as they were: preloading gives new ones.query.preload { ... }(andUser.preload { ... }) hasrepo.all/one/firstpreload after loading, as Ecto'spreload:; not withselect(a projection has no models).- Preloaded associations are carried by
with,restoreand the repository'supdate, and are not into_h; awiththat changes a key forgets what was preloaded for it (reading it raisesRotor::NotLoadedError): abelongs_to's foreign key its association, the primary key everyhas_many(loaded for the old key). Ecto keeps the stale structs. A target's key column is checked when a query or a preload uses it (Rotor::SchemaErrorwhen it has none), not at the statement: the target'stablemay run later. - An association's name may not be a column's or one of the model's own methods'.
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
- The join table is always a model (no model-less join table). Its
belongs_tothe owner and itsbelongs_tothe target give the keys;source:names the latter when it has several (has_many User, name: :followed, through: Follow, source: :followee). They are found when the association is first used (a query, a preload), when everytableblock has run, whatever their order (the owner names the join model, which names the owner); a join model without them raisesRotor::SchemaErrornaming both classes. - Read-only (Ecto's
has_many :through):post.tags.queryis the target's table alone,WHERE "id" IN (SELECT "tag_id" FROM "post_tags" WHERE "post_id" = $1), refined and run as any query; a preload is one query joining the join table,SELECT "t0".*, "p1"."post_id" FROM "tags" AS "t0" INNER JOIN "post_tags" AS "p1" ON "p1"."tag_id" = "t0"."id" WHERE "p1"."post_id" = ANY($1), grouped by owner, nested as any level. A target appears once per owner, however many join rows pair them (the join rows themselves are the join model's). Join rows are written as the join model's (insert,delete_all(PostTag.where { ... })); replacing the set at once may come with changesets. Not injoin { ... }(join 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 }
- In a
for, the inner query is a model's (Post.where { ... }, itsquery) or an association's (u.posts.query), withwhere,filter,orderanddesc, then a lastmapor another iterator: anything else (alimit, a query in a variable) raisesRotor::UntranslatableError. Orders come after the outer query's. - A
for's rows are made by its body as Quarz code, with the columns it reads: the body runs for each distinct outer row, building the inner query again (its translation kept, about 29 µs a row compiled, 35 µs translating each time). For large results, preferjoin, whose pairs are built directly;where,order,select(select { |u, p| ... }),countandlimitfollow it, with a block parameter per table. - A column's getter of a model a block captured is its value:
Post.where { user_id == u.id }. - Joins are INNER only; LEFT joins and
throughassociations come later.
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]]
- The DSL.
create_table(name, primary_key: true | false | :name, if_not_exists:) { |t| ... }witht.column,t.timestamps,t.references(:user, to_table:, on_delete:, null:, index:),t.index(columns, unique:, name:, where:);drop_table,rename_table,add_column,remove_column,rename_column,change_column(table, name, null:, default:, from:)(nil leaves a setting as it is,Rotor::Migration::NO_DEFAULTdrops the default; a change of type goes throughexecute),add_reference,remove_reference,create_index(table, columns, unique:, name:, where:, concurrently:),drop_index(table, columns: or name:),execute(sql, down:)(one statement each). A default is a literal of the column's type, orRotor::SQL.raw("now()");on_delete:is:cascade,:set_null,:restrictor:no_action. Names are quoted; one that is empty, holds a NUL or has 64 bytes or more is anArgumentError, as is an index name made from the table and columns (<table>_<columns>_index) that would be: givename:. Each call records an operation (Rotor::Operation) thatRotor::Migrator.renderwrites as SQL, the one place the dialect is;Migration.plan(:up)lists them without running anything. - A class.
versionis checked where the class is compiled: a positiveInt, and eitherchange, orupanddown, not both. The idcreate_tableadds isGENERATED BY DEFAULT AS IDENTITY, so that a model inserted with its id (a fixture, a copy) is accepted. - Reversal. Forward,
change's calls run as they are made, so thatrepo(the migrator's repository, in the migration's transaction) sees what the calls above it did. Rolling back recordschange's calls without running them and runs their reverses in the reverse order. A call that cannot be reversed raisesRotor::IrreversibleMigrationbefore any of them runs:drop_tablewithout its columns (give them in a block, ascreate_table's),remove_columnwithout its type,change_columnwithoutfrom:(from: Rotor::Was.new(null: true, default: Rotor::Migration::NO_DEFAULT)),drop_indexby name alone,executewithoutdown:, andrepoused inchange. Writeupanddownfor those. As in Ecto,create_table ..., if_not_exists: truereverses todrop_table ..., if_exists: true(a table that existed before is dropped too),rename_tableleaves the indexes' names, androllbackgoes by version, highest first. - Transactions. Each migration runs in a transaction of its own
with its row in the versions table (
schema_migrations, ortable:), whichmigratecreates when it is missing: a failure raisesRotor::MigrationError(version,name, and the error it wraps as itscause, nil when the migrator refused it), its migration rolled back whole and those before it applied. A class that saysdisable_transaction!runs outside one (forcreate_index ..., concurrently: true), its row written after it succeeds. - The lock.
migrateandrollbacktake one connection of the repository's for their whole run and a session-levelpg_advisory_lockon it (its key from the versions table's name), and run every migration there: two processes migrating at once run one after the other, and the second finds nothing pending.lock_timeout:bounds the wait (aPG::ServerErrorwhen it runs out), the session's ownlock_timeoutrestored after it. The repository must not be inside a transaction (ArgumentError). When the run ends, a transaction a migration left open is rolled back and the lock released; a connection taken from a pool has its settings reset (RESET ALL), so that a migration'sSETdoes not reach the pool, while a repository over one connection keeps its session as the migrations left it (the program owns that session: its settings, and those a migration'sSETmade, stay). A migration withdisable_transaction!that leaves a transaction open fails. - Order.
migraterefuses a pending version lower than the highest one applied (Rotor::MigrationError, before running anything) unlessallow_out_of_order: true. Ecto and Rails run it; here the versions are timestamps, and an older one appearing late is usually a merge that went wrong, to look at before it runs. Applied versions with no migration in the list show instatusas:unknown;rollbackrefuses them. - Logging.
log: ->(line: String) { puts line }gets== 20261107120000 CreateUsers: migratingand... migrated (0.12s)(reverting,reverted); nothing is printed without it.
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
- Casting.
Changeset.cast(model_or_class, params, permitted)reads thepermittedfields ofparams(byStringname, as form and JSON input give them, or bySymbol) and ignores the rest. Each value is cast to its column's type: strings toInt,Float,Bool(true/false,1/0,on/off) andTime(ISO 8601; without an offset, as an HTMLdatetime-localgives it, UTC);Bytesonly fromBytes. A value that does not cast (letters for a number, digits other than ASCII, anIntbeyond 64 bits, an infiniteFloat, a timeTimecannot hold) is anis invaliderror (validation: :cast) and no change. A string of spaces or nothing is nil, except for aStringcolumn that admits no NULL; nil for a column that admits none iscan't be blank. A value equal to the model's is no change. A permitted name that is not a column raisesRotor::SchemaError. - Validations each give a new changeset:
validate_required,validate_length(min:, max:, is:)(characters as a reader sees them: grapheme clusters, as Ecto counts),validate_format,validate_number(min:, max:, greater_than:, less_than:),validate_inclusion,validate_exclusion,validate_acceptance(a checkbox parameter; skipped forChangeset.change, which has no input),validate_confirmation(cast as the field is),validate(field) { |model| message? }andvalidate_change(field, name) { ... }, whose block gets the model with the changes applied (typed: read the field through its getter) and runs only when the field changed and cast, andvalidate { |cs| cs }for rules across fields. A field without a change is skipped, except byvalidate_required, which looks at the value the model would have; a fieldvalidate_requiredfound blank is skipped by the others. - Errors.
errorsareRotor::FieldErrors:field(:basefor the whole),messageas a template (should be at most %{count} character(s)),validation(:required,:length, ...), andmeta(count,kind, ...), so that an application can translate byvalidationandmeta.textanderrors_on(field)give the messages filled in. - Saving.
repo.insert(cs),repo.update(cs)andrepo.delete(cs)giveRotor::Outcome::Ok(model)orRotor::Outcome::Invalid(changeset);insert!andupdate!raiseRotor::InvalidChangeset(itserrors, and the changeset as aShareable: the typed one comes frominsert's outcome). Timestamps are set as for a model.applygives the model with the changes (nil when invalid),apply!raises. - Constraints.
unique_constraint(field, name:)(by default<table>_<field>_index, the namecreate_indexgives),foreign_key_constraint(<table>_<field>_fkey, PostgreSQL's),check_constraint(field, name:)andno_assoc_constraint(assoc)(a delete refused by rows that refer to the model) turn the database's violation of that constraint into an error on the field and anInvalid; an undeclared violation stays an exception. The save runs in a transaction, or under a savepoint when the repository is in one, so that anInvalidleaves the transaction usable (Ecto aborts it). unsafe_validate_unique(fields, repo, query:)looks for a row with the same values first, for a message before the save: racy, so no replacement for the unique index and itsunique_constraint.put_change,get_change,get_field,add_error;put_assoc(:tags, tags)makes ahas_many ..., through:exactly those (saved) models on save, replacing the join rows, the saved model having them preloaded. Two saves of one model at once may both insert a pair: a unique index on the join table's two keys stops that.- A changeset is
Shareable: params keep scalar values only (an array or a hash given for a field is a cast error). - A column declared
redact: trueshows as**redacted**in the model'sinspectand in the changeset'sinspectandto_h(the model'sto_hkeeps it: the repository writes through it). A violation of a constraint the changeset does not declare stays aPG::ServerError, whose detail the server writes with the value (Key (token)=(...) already exists): declare the constraints of such columns. - Not yet: changesets of associations (
cast_assoc), embedded schemas.
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
RotorRotor::AdapterWhat a repository runs its statements through: a database and its driver. Rotor::Adapters::PG is PostgreSQL's, over pg.Rotor::AdaptersRotor::Adapters::PGPostgreSQL, through pg: over a pool (each statement takes a connection; a transaction keeps one) or one connection.Rotor::Adapters::PGRowpg's row as a Rotor::Row, through its typed readers.Rotor::Adapters::PGRowspg's result as Rotor::Rows.Rotor::AssociationAn association of a model, as its table block declares it: what the queries of a later version follow.Rotor::BindWhere a parameter made from a quoted block's captures (Quarz's Quoted#captures) takes its value: a capture, an item of a captured array, a column of a captured model, or what is made of one of those (a Float from an Int, a LIKE pattern from a string).Rotor::BindsThe values of Binds, from a block's captures.Rotor::CacheThe translations of the queries' blocks, kept for the process: a call whose block was translated over the same models, with captures of the same kinds (Guard), takes the translation kept and fills in its parameters. At most LIMIT entries per key, never evicted: the kinds of several captures multiply (nil or not, a class, an array's items' class; not an array's length, which the SQL does not depend on), so a kind beyond them is translated on each build, as before the cache, and not kept.Rotor::CacheKeyA call's translation key: the site of its block (Quarz's Quoted#site) and the models of the query's tables it is over.Rotor::CachedA translation kept, with the guards it holds for.Rotor::ChangesetChanges to a model from untrusted input (a form, JSON), cast to the columns' types and validated, with the errors meant for a user, as Ecto's changesets: an immutable value, each validation giving a new one; Rotor::Repo#insert and Rotor::Repo#update take it and answer with a Rotor::Outcome.Rotor::CheckpointA model's values, the columns assigned since it was loaded, the columns a query fetched (nil: all), and the associations preloaded (by name: the associated models, none or one for a belongs_to): what a model is made of (Rotor::Model#checkpoint, Rotor::Model#restore).Rotor::ColumnA column of a model's table, as its table block declares it.Rotor::ColumnDefA column a migration declares: its name, SQL type, whether it admits NULL, its default, whether it is the primary key (and an identity column), and what it references.Rotor::CompareThe comparisons a query's block may write on a nullable column: User.where { age > 18 } with age an Int?. Every query block (where, filter, a join's condition, order, update, preload, select) is checked with using Rotor::Compare (Quarz's docs/01 §7), so nil compares false there, as SQL's NULL acts in a condition; the translator gives them SQL's comparison, which can be NULL, and refuses them where NULL and false differ (under !, as a value). Outside query blocks Int? > Int is still a type error. Only <, <=, > and >=: nil has no arithmetic, and ==/!= are Quarz's own.Rotor::ConstraintA constraint of the database a changeset expects to be violated by user input: on save, a violation of it (its SQLSTATE and name) is an error on field rather than an exception.Rotor::DialectWhat is PostgreSQL's in the SQL rotor writes: quoting, parameters. (The one dialect for now.)Rotor::ErrorThe root of every error this cog raises: rescue Rotor::Error catches them all. (An argument a method cannot take is an ArgumentError, as anywhere.)Rotor::FetchedThe values of a row's columns from one of a query's tables, and whether those are all of the table's columns.Rotor::FieldErrorAn error of a changeset: the field it is on (:base for the whole), its message as a template (should be at most %{count} characters), the validation that made it (:required, :length, :cast, a constraint's kind...), and the values the template names (count, kind...), so that an application can translate by validation and meta rather than by text.Rotor::GuardA captured value's kind, as a translation saw it: the same tree with values of the same kinds there translates the same.Rotor::GuardsThe guards a translation records, shared by the translators of its nested blocks.Rotor::HasManyA model's has_many association, as its getter gives it (user.posts): the associated models once preloaded (Rotor::Repo#preload), and the query that finds them either way. There is no lazy loading: reading the models of one not preloaded raises Rotor::NotLoadedError.Rotor::IndexDefAn index: its table, columns, name, whether it is unique, its condition (SQL, for a partial index), and whether it is built CONCURRENTLY.Rotor::InflectorEnglish inflections. The schema's default names come from underscore and pluralize: a table is its model's name in snake_case, plural (BlogPost's is blog_posts), and a foreign key the owner's in snake_case with _id. The rules are few on purpose; write the name when they guess wrong.Rotor::InnerPlanWhat a flat_map block's translation gives, whatever the outer query's own conditions: the columns it reads of each outer table, the tables it joins and its orders (their parameters Rotor::Piece::Bound, Rotor::Cache keeps it).Rotor::InvalidChangesetRotor::Changeset#apply!, Rotor::Repo#insert! or Rotor::Repo#update! of a changeset with errors: its errors, and the changeset itself as a Shareable (its model's type is not known where it is rescued: for the typed changeset, use insert and its Rotor::Outcome). The message lists the errors.Rotor::IrreversibleMigrationA migration step that cannot be undone: rolling back a change that drops a table without its columns, removes a column without its type, runs execute without down:, and the like (Rotor::Migration), or that uses repo. Raised before any of the rollback runs.Rotor::JoinsThe translation of joins: a for comprehension's inner queries (Rotor::Query#flat_map) and associations (Rotor::Query#join).Rotor::KindWhat a translation looked at of a captured value: its class (nil for nil) and, for an array, its items' class (Binds.item_class) and whether it holds a nil. Not its length: an array is one parameter.Rotor::MigrationA change of a database's schema, with its version: a subclass says version (an Int, unique among the migrations; the generator will use the UTC time, 20261107120000) and either change, which rolling back reverses, or up and down.Rotor::MigrationErrorA migration failed (Rotor::Migrator): its version and class name; cause is the error it raised (a PG::ServerError, a Rotor::IrreversibleMigration...), nil when the migrator refused to run it (a version older than one applied). Its transaction was rolled back; the migrations before it stay applied.Rotor::MigratorRuns migrations (Rotor::Migration classes) against a database and records which ran in a table (schema_migrations unless table: says otherwise: version bigint PRIMARY KEY, inserted_at), which migrate and rollback create when it is missing.Rotor::MissingAttributeErrorA read of a column that is not set: one without a default that admits no nil, on a model made with new, or loaded without it.Rotor::ModelThe base class of a model: a row of a table, with typed accessors that its table block generates when the program is compiled.Rotor::ModelOpsWhat a query of models does with them for a preload (Rotor::Query#preload): a row as a model, and a row with the associations preloaded for it.Rotor::MultipleResultsErrorRotor::Repo#one found more than one row.Rotor::NoDefaultRotor::Migration::NO_DEFAULT's type: what change_column takes to drop a column's default.Rotor::NoResultsErrorRotor::Repo#one! found no row.Rotor::NotLoadedErrorA read of a column a query did not fetch: a model loaded for a select, with the columns its block reads only.Rotor::OperationWhat a migration does, one step: its DSL records these, and Rotor::Migrator.render writes their SQL (the one place a dialect is rendered).Rotor::OutcomeWhat saving a changeset gave: the model as saved, or the changeset with its errors (validation errors, or a declared constraint's).Rotor::PieceA piece of SQL: text, a column of one of a query's tables (by the table's index among them: qualified by the table's alias in a join), or the value of a parameter, numbered ($n) when the statement is put together. A translation the cache keeps (Rotor::Cache) holds Bound pieces, parameters made from the block's captures, which each build fills in with its own (Rotor::Binds.fill).Rotor::PlanWhat Rotor::Query#flat_map reads of its block's tree: the tables the block's query joins to the outer query's (after them, with their conditions and the columns each is fetched with) and its orders.Rotor::PreloadAn association a preload names, with those to preload of its models (Rotor::Repo#preload: { [nest(posts) { comments }, account] }).Rotor::PreloadsPreloading: reading a preload's block, and loading what it names with one query per association and level (WHERE key = ANY($1), the keys one array parameter).Rotor::QueryA query over a model's table, as a value: what Rotor::Model.where and the others build, and a repository runs. Each method gives a new query; none changes the one it is called on. Its blocks are not run: they are translated to SQL when the query is built, so that a block SQL cannot express raises Rotor::UntranslatableError there, and to_sql gives the statement and its parameters.Rotor::Query::ShapeWhat a query gives: its rows, their number, or whether there is one.Rotor::ReferenceA column's foreign key: the table and column it references, and its ON DELETE action (:cascade, :set_null, :restrict, :no_action; nil: none written).Rotor::RepoRuns queries and statements (Rotor::Query, Rotor::Statement) against a database, through an adapter, and loads what they give.Rotor::RowA row's values, read by column index with the column's type: nil for NULL.Rotor::RowsThe rows a statement gave, and how many it changed.Rotor::SQLSQL functions a query's block may call, which the translator knows by name: Rotor::SQL.like(name, "A%") is "name" LIKE $1. Called outside a query they compute the same (a like pattern's % and _, case folding), so a block keeps its meaning wherever it runs.Rotor::SQL::RawSQL written as is where a migration takes a value: a column's default that is not a literal (Rotor::SQL.raw("now()")).Rotor::SchemaWhat a model's table block runs on, at compile time: each of its calls declares a column or an association. Its column takes the column's type as a class: Int, Float, String, Bool, Time or Bytes, one overload each, so that a default is of the column's type.Rotor::SchemaErrorA model used without a schema: its class body has no table block.Rotor::ScopeA block of a query's tree as the translator sees it: its parameters, the table (an index among the query's) each one is a row of, or nil, and whether it has a receiver and which table's row that is. known is false for a block given to a call the translator does not know, which may or may not have a receiver. whole lists the tables of a join whose row its one parameter (or its receiver) is as a whole: what it reads of each is then all of it.Rotor::SourceA table a query reads: the first, or one joined to it, with the conditions of its JOIN (INNER JOIN ... ON them, CROSS JOIN for none), the columns it fetches (nil for all of them), and those it fetches whatever a select reads (the key an association's join needs).Rotor::SqlA translated expression: its pieces, its precedence (to parenthesize it where it is an operand), and what the translator knows of it: the column it is, or the value it has (known) when it is a literal or a capture, and then (bind) the capture it is made from. nulls names what makes it NULL where Quarz has nil (a nullable column, shown as written), tri what makes it NULL where Quarz has false: a comparison, include? or LIKE form of something that can be NULL, three-valued in SQL. A tri expression is kept where NULL acts as false (a condition, an operand of &&/||, a CASE WHEN's), and refused anywhere else. narrow marks what PostgreSQL types as an integer where Quarz has an Int: an Int literal, or a CASE of them. A column never is: rotor does not know the SQL type of a table it did not create, whose integer columns are left as they are (README, "Parameter types").Rotor::StaleErrorRotor::Repo#update, Rotor::Repo#touch or Rotor::Repo#delete of a model whose row no longer exists: the statement changed no row.Rotor::StatementA statement that changes rows, as a value: what a repository runs. to_sql gives its SQL and parameters; returning asks for columns of the rows it changed.Rotor::TableBuilderThe columns of a table that create_table (or a reversible drop_table) declares: the block's t.Rotor::TableInfoWhat a model's table is, for the statements made from its values and the repository that sets its timestamps.Rotor::ThroughThe keys of associations through a join model, from its belongs_tos, found when an association is used (a query, a preload): the table blocks of the owner, the join model and the target have all run then, in whatever order. (A few comparisons of the join model's associations: found again at each use, not kept.)Rotor::ThroughKeysThe keys of a has_many ..., through: (a join model's): the join model, its column that holds the owner's key and the one that holds the target's, and the target's primary key.Rotor::TranslatorTranslates the tree of a query's block (Meta::Expr, Quarz's docs/14 §6) to SQL, with the values its captures had when the query was built. The block's row is its receiver (where { age > 18 }) or its parameter (where { |u| u.age > 18 }), one per table in a join (where { |u, p| p.user_id == u.id }); a call of a column's getter on a row is the column, and on a model the block captured, its value.Rotor::UntranslatableErrorA block of a query that SQL cannot express: a call of a method the translator does not know, or a construct it does not take. Raised when the query is built (where, order, select, update), not when it runs, naming what it met and where.Rotor::WasWhat a column was, for change_column to be reversible: its NULL admission and its default (Rotor::Migration::NO_DEFAULT for none); nil for what the change leaves as it is.