moonorm

    An ORM and SQL toolkit for MoonBit.

    orm
    sql
    database
    query-builder
    sql-toolkit
    mapper
    data-mapper
    migration
    migrations
    schema
    session
    unit-of-work
    sqlite
    postgres
    postgresql
    mysql
    sqlalchemy
    moondb
    moonbit
    Download zip
    Version
    0.11.8
    License
    Apache-2.0
    Last updated
    12 hours ago
    Downloads
    369

    #moonorm

    An ORM and SQL toolkit for MoonBit: a typed schema, three dialects, values that are always bound, and a migration history that can branch.

    License mooncakes

    moonorm builds SQL from MoonBit values, and maps rows back into MoonBit records. Nothing is spliced into the statement: every value becomes a ? placeholder plus an entry in a params list, so a string carrying SQL is data, not syntax. How far that reaches depends on the driver — SQLite and PostgreSQL bind out of band, while the MySQL driver still renders parameters as escaped literals over the text protocol (see its README), so there the safety rests on the escaper matching the server's sql_mode.

    moonorm owns no driver contract of its own. It is written entirely against the moondb interface, so it is pure MoonBit with zero C and compiles on every backend (wasm / wasm-gc / js / native). A backend is a separate package you supply: the native SQLite driver lives in moonsqlite, the PostgreSQL and MySQL/MariaDB drivers in moonpostgres and moonmysql, and a Session drives any @moondb.Driver — including the dependency-free @moondb.MockDriver.

    Imports. Bound-value constructors (Int, Text, Null, …) are moondb's: the Value type is re-exported by moonorm, but you construct values as @moondb.Int / @moondb.Text (add moon add moonbitstack/moondb). A model field needs none of them: field binds the attribute itself, and Row::read reads a cell as the field's type. The examples that touch a real database import the SQLite driver under a short alias, "moonbitstack/moonsqlite" @sqlite in moon.pkg.

    #Packages

    Each layer is its own subpackage: the builder, the schema, the session, and migrations. The root re-exports all of them, so @moonorm.select and @moonorm.Session work.

    PackageWhat it holdsDepends on
    moonorm/querydialects, predicates, select / insert / update / delete, Table, is_identmoondb's Value
    moonorm/schemaColumnType, Column, Field, Model, Relation, ManyToMany, TableSpec, the three inheritance mappings, table and index DDLquery, moondb
    moonorm/sessionSession, row streams, transactions and savepoints, the unit of work, event hooks, pooled sessionsschema, query, moondb
    moonorm/migrateMigration, Revision, Op, Migrator, ColumnDiff, reflection, diffing and autogeneratesession, schema, query
    moonorm/engineEngine, Dsn, DsnError: a connection string names the driver and the dialectquery, session, moondb
    moonorm/mapperthe runtime behind #sql.*: reads a row into what a mapper function returnsmoondb
    moonorm/cmd/ormthe generator behind #orm.table, #orm.column, #orm.skip, #orm.index, #orm.has_many and #orm.belongs_tomoonast

    A program that only renders SQL imports query and nothing else.

    Constructors are written without the package. pub using re-exports types but not their constructors, so @moonorm.Postgres does not resolve. Write the bare constructor and let the expected type pick it: stmt.build_for(Postgres). Where nothing gives the type, annotate or name the subpackage: let levels : Array[@moonorm.IsolationLevel] = [ReadCommitted, Serializable], or catch { @session.LostUpdate(msg) => ... }.

    #Quickstart

    A table is an annotated struct, and a statement an annotated function:

    #orm.table(name="heroes")
    pub struct Hero {
    #orm.column(primary_key=true, autoincrement=true)
    id : Int64?
    name : String
    #orm.column(index=true)
    power : Double
    }

    #sql.select("SELECT id, name, power FROM heroes WHERE power > #{power}")
    declare fn strong(db : &@moondb.Driver, power : Double) -> Array[Hero] raise

    Hero::model() is the table for a session and for migrations, and strong runs its statement with power bound. Both come from generators run as build rules: see Annotations and SQL mappers.

    #The query builder

    For a query that is put together at run time:

    pub fn quickstart() -> Unit {
    let (sql, params) = @moonorm.select("users")
    .column("id")
    .column("name")
    .eq("age", @moondb.Int(18))
    .where_("name", "LIKE", @moondb.Text("bob%"))
    .order_by("name", Asc)
    .limit(10)
    .build()
    // SELECT id, name FROM users WHERE age = ? AND name LIKE ? ORDER BY name ASC LIMIT 10
    // params: [Int(18), Text("bob%")]
    ignore((sql, params))
    }

    pub fn joins() -> Unit {
    let orders : @moonorm.Table = { name: "orders", columns: [] }
    let (sql, params) = orders
    .select()
    .raw("users.name")
    .count()
    .join("users", "users.id = orders.user_id")
    .eq("orders.status", @moondb.Text("paid"))
    .group_by("users.name")
    .having("COUNT(*)", ">", @moondb.Int(3))
    .build()
    // SELECT users.name, COUNT(*) FROM orders JOIN users ON users.id = orders.user_id
    // WHERE orders.status = ? GROUP BY users.name HAVING COUNT(*) > ?
    // params: [Text("paid"), Int(3)]
    ignore((sql, params))
    }

    insert, update and delete follow the same shape: @moonorm.insert("users").set("name", ...) , .where_(...), then .build() for the statement text and its params.

    #Running against a real database

    The builder is half of it — moonorm also executes, against any @moondb.Driver. Add a driver and a Session runs the statements you built and hands back typed rows. Every fallible operation raises @moondb.DbError, so a decode or backend error surfaces at the call site instead of as a silent zero:

    // native target only — moonsqlite links the vendored amalgamation.
    pub fn demo() -> Unit raise @moondb.DbError {
    let sess = @moonorm.Session::new(@sqlite.SqliteDriver::open(":memory:"))
    sess.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", [])
    |> ignore

    // INSERT through the builder — values are bound, never spliced.
    sess.add(
    @moonorm.insert("users").set("name", @moondb.Text("alice")).set("age", @moondb.Int(30)),
    )
    |> ignore

    // SELECT through the builder — real rows come back, typed.
    let rows = sess.fetch(
    @moonorm.select("users").column("name").column("age").where_("age", ">", @moondb.Int(18)),
    )
    let _ = rows[0].text(0) // "alice" (raises TypeError if the column is not Text)
    let _ = rows[0].int(1) // 30
    sess.close()
    }

    Session also has modify (UPDATE), remove (DELETE), begin / commit / rollback (delegated to the driver's transaction bracket), nested transactions via savepoint / rollback_to / release, and raw execute / query.

    A session renders for one dialect, SQLite unless told otherwise, because a driver takes SQL and says nothing about what it speaks. Session::new(driver, dialect=Mysql) gives the ON DUPLICATE KEY UPDATE form and no RETURNING, and Session::with_pool takes the same label.

    #A connection string picks the driver and dialect

    An Engine parses a URL and remembers the dialect its sessions render for. The scheme names the driver — sqlite, postgresql (or postgres), mysql — and a +driver suffix overrides the driver name without changing the dialect. moonorm supplies no driver, so the session still takes the one you opened:

    pub fn from_url(driver : &@moondb.Driver) -> @moonorm.Session raise @moonorm.DsnError {
    let engine = @moonorm.Engine::new("postgresql://user:pass@db:5432/app?sslmode=require")
    // engine.driver() == "postgresql"; engine.dialect() == Postgres
    engine.session(driver) // a Session that renders for Postgres
    }

    #Models & relationships

    A Model[T] is the explicit mapping between a table and a MoonBit record: the typed columns plus the two mapping closures MoonBit cannot synthesise without reflection (Row -> record, record -> bound columns). From it you get CREATE TABLE DDL, typed inserts, and eager foreign-key loading. Because there is no attribute interception, parent.children cannot fire a SELECT behind your back; the load is an explicit call — session.load / session.load_one.

    The mapping closures read cells through @moondb.Row's typed accessors, which raise on a type or index mismatch — so from_row raises too, and a decode bug surfaces at the call site. Write them in arrow form so the raise effect is inferred:

    pub struct Team {
    id : Int
    name : String
    }

    pub struct Hero {
    id : Int
    name : String
    team_id : Int
    }

    pub fn models() -> (Array[@moonorm.Model[Team]], Array[@moonorm.Model[Hero]]) {
    let teams : @moonorm.Model[Team] = @moonorm.Model::new(
    "teams",
    [
    @moonorm.column("id", IntType, primary_key=true),
    @moonorm.column("name", TextType, nullable=false),
    ],
    (r) => { { id: r.int(0), name: r.text(1) } },
    (t) => [("id", @moondb.Int(t.id)), ("name", @moondb.Text(t.name))],
    )
    let heroes : @moonorm.Model[Hero] = @moonorm.Model::new(
    "heroes",
    [
    @moonorm.column("id", IntType, primary_key=true),
    @moonorm.column("name", TextType, nullable=false),
    @moonorm.column("team_id", IntType, references=("teams", "id")),
    ],
    (r) => { { id: r.int(0), name: r.text(1), team_id: r.int(2) } },
    (h) => [
    ("id", @moondb.Int(h.id)),
    ("name", @moondb.Text(h.name)),
    ("team_id", @moondb.Int(h.team_id)),
    ],
    )
    ([teams], [heroes])
    }

    Against a session those models give you the whole cycle:

    pub struct Team {
    id : Int
    name : String
    }

    pub struct Hero {
    id : Int
    name : String
    team_id : Int
    }

    pub fn mapping(sess : @moonorm.Session, teams : @moonorm.Model[Team], heroes : @moonorm.Model[Hero]) -> Unit raise @moondb.DbError {
    sess.create_table(teams) |> ignore // CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT NOT NULL)
    sess.create_table(heroes) |> ignore
    sess.insert_record(teams, { id: 1, name: "avengers" }) |> ignore
    sess.insert_record(heroes, { id: 10, name: "iron-man", team_id: 1 }) |> ignore

    // 1:N — a team's heroes, eagerly loaded as mapped records.
    let children = @moonorm.has_many(heroes, "team_id", (t : Team) => @moondb.Int(t.id))
    let kids = sess.load({ id: 1, name: "avengers" }, children)

    // N:1 — a hero's team.
    let parent = @moonorm.belongs_to(teams, "id", (h : Hero) => @moondb.Int(h.team_id))
    let team = sess.load_one({ id: 10, name: "iron-man", team_id: 1 }, parent)
    ignore((kids, team))
    }

    The relationship's match value is bound, never spliced, so eager loading is injection-safe like every other query.

    #Declarative models from a field list

    Writing to_columns by hand repeats what the columns already say. Model::from_fields takes that other half off you: give it a list of fields — each a column plus the one-line projection that reads it off a record — and it derives the column list, the CREATE TABLE DDL, the SELECT projection, and the INSERT binding. You still supply from_row, because turning a fetched row back into a T needs the record constructor, which MoonBit cannot synthesise without reflection; that one closure is the irreducible core of the mapping. A field binds anything that converts to a Value (moondb's ToValue), so the projection is the attribute itself and T? binds None as NULL:

    pub struct Hero {
    id : Int
    name : String
    team_id : Int
    }

    pub fn declarative() -> @moonorm.Model[Hero] {
    @moonorm.Model::from_fields(
    "heroes",
    [
    @moonorm.field("id", IntType, h => h.id, primary_key=true),
    @moonorm.field("name", TextType, h => h.name, nullable=false),
    @moonorm.field("team_id", IntType, h => h.team_id, references=("teams", "id")),
    ],
    (r) => { { id: r.read(0), name: r.read(1), team_id: r.read(2) } },
    )
    }

    An autoincrement key that is still None is left out of the INSERT, so the database assigns it.

    A storage type the built-in ColumnType variants do not name is declared from its three spellings. Values still bind through ToValue/FromValue, so the type is open end to end:

    pub fn handle_column() -> @moonorm.ColumnType {
    @moonorm.user_type(sqlite="TEXT", postgres="CITEXT", mysql="VARCHAR(255)")
    }

    An explicit model takes it as a field's column type, and the annotated form names it with kind="handle_column()", or writes the storage on the field with sqlite=, postgres= and mysql= together.

    #Annotations

    #orm annotations write that model for you. The generator reads a struct and emits T::model(), the same from_fields call as above:

    #orm.table(name="heroes")
    pub struct Hero {
    #orm.column(primary_key=true, autoincrement=true)
    id : Int64?
    name : String
    #orm.column(name="secret_name", unique=true)
    secret : String
    #orm.column(check="age >= 0")
    age : Int?
    #orm.column(references="teams.id", on_delete="SET NULL")
    team_id : Int64?
    #orm.skip
    visits : Int
    }

    let hero = Hero::model() // @moonorm.Model[Hero]

    A field without annotations is a column named after it, NOT NULL unless its type is T?. The table is named name=, or the struct's name in snake case.

    Field typeColumn type
    IntIntType
    Int64BigIntType
    DoubleRealType
    StringTextType
    BytesBlobType
    BoolBoolType

    #orm.column argumentMeaning
    namethe column's name
    kindthe column type by its SQL name: int, bigint, text, real, blob, bool, varchar(N), numeric, datetime, date, time, uuid, json
    primary_key, uniquethe constraints
    autoincrementon an integer primary key of type T?; None until the database assigns it
    defaultthe SQL default, written as SQL
    checka CHECK constraint; repeat it for several
    referencesa foreign key, "table.column", with on_delete and on_update
    indexan index over this column alone

    Beside #orm.table, a struct takes indexes and relations:

    On the structBecomes
    #orm.index(column="a", column="b", unique=true, name="...")an Index on the model; Session::create_table creates it, named ix_<table>_<columns> (uq_ when unique) unless named
    #orm.has_many(name="heroes", target="Hero", foreign_key="team_id")Team::heroes(), a Relation loaded with Session::load; key= names the field holding this side's key when the primary key is not a single field
    #orm.belongs_to(name="team", target="Team", foreign_key="team_id")Hero::team(), loaded with Session::load_one; key= names the target's column when the target is not annotated in the same file

    #orm.skip keeps a field out of the table and fills it from Default when a row is read. A field of a type of the program's own names its column type with kind="its_function()", or with sqlite=, postgres= and mysql=, and implements @moonorm.ToValue and @moonorm.FromValue; see 22-annotations, which also checks the generated model against the same model written by hand.

    Wire the generator into the build once. In moon.mod:

    rule(name: "orm", command: "moonx moonbitstack/moonorm/cmd/orm@0.11.7 -C \"$mod_dir\" \"$input\" -o \"$output\"")

    and in the moon.pkg of the package holding the structs:

    dev_build(rule: "orm", input: "models.mbt", output: "models.g.mbt") formatter(ignore: [ "models.g.mbt" ])

    moon check, moon build and moon run regenerate models.g.mbt whenever models.mbt changes; --check reports a stale file instead of writing it.

    #Without annotations

    The generator emits Model::from_fields; writing that yourself is the functional form. The class-style form writes Model::new with both mapping closures out in the open. All three produce the same Model[T], so the rest of a program is unchanged.

    Functional — a field list plus one row decoder:

    pub struct Hero {
    id : Int64?
    name : String
    power : Double
    }

    pub fn hero_model() -> @moonorm.Model[Hero] {
    @moonorm.Model::from_fields(
    "heroes",
    [
    @moonorm.field("id", BigIntType, h => h.id, primary_key=true, autoincrement=true),
    @moonorm.field("name", TextType, h => h.name, nullable=false),
    @moonorm.field("power", RealType, h => h.power),
    ],
    (r) => { { id: r.read(0), name: r.read(1), power: r.read(2) } },
    )
    }

    Class-style — the same model with both directions written out:

    pub struct Hero {
    id : Int64?
    name : String
    power : Double
    }

    pub fn hero_model() -> @moonorm.Model[Hero] {
    @moonorm.Model::new(
    "heroes",
    [
    @moonorm.column("id", BigIntType, primary_key=true, autoincrement=true),
    @moonorm.column("name", TextType, nullable=false),
    @moonorm.column("power", RealType),
    ],
    (r) => { { id: r.read(0), name: r.read(1), power: r.read(2) } },
    (h) => {
    let out : Array[(String, @moondb.Value)] = []
    if h.id is Some(v) {
    out.push(("id", @moondb.Int64(v)))
    }
    out.push(("name", @moondb.Text(h.name)))
    out.push(("power", @moondb.Double(h.power)))
    out
    },
    )
    }

    #SQL mappers

    When the query is the point, write it. #sql.* goes on a declare fn, and the generator gives the function its body:

    pub struct User {
    id : Int64
    name : String
    age : Int32?
    }

    #sql.select("SELECT id, name, age FROM user WHERE age > #{age}")
    declare fn adults(db : &@moondb.Driver, age : Int32) -> Array[User] raise

    #sql.select("SELECT id, name, age FROM user WHERE id = #{id}")
    declare fn find(db : &@moondb.Driver, id : Int64) -> User? raise

    #sql.insert("INSERT INTO user(name, age) VALUES(#{u.name}, #{u.age})")
    declare fn add(db : &@moondb.Driver, u : User) -> Int64 raise

    #sql.delete("DELETE FROM user WHERE id = #{id}")
    declare fn remove(db : &@moondb.Driver, id : Int64) -> Unit raise

    #{name} and #{name.field} are bound values, in the order they appear, and there is no way to splice text into a statement: a ${...} or a placeholder written by hand is refused when the code is generated. dialect="postgres" numbers the placeholders, and sqlite and mysql write ?.

    AnnotationMeans
    #sql.select(sql)runs a query; what the function returns says how the rows are read
    #sql.insert(sql, returning="id")an Int64 return is the new id, by RETURNING when asked and the driver's last id otherwise
    #sql.update(sql), #sql.delete(sql)an Int64 return is the rows touched, Unit drops it
    #sql.results(column="user_name", field="name")a column with another name than its field

    A select returningReads
    Array[T]every row
    T?at most one row, more is an error
    Texactly one row, anything else is an error
    a structeach field from the column of its name
    Int64, String and the other column typesthe first column

    A struct field the SELECT does not list is refused at generation, unless it is T?. Dynamic SQL belongs to the query builder, which every other part of this library uses. async works the same way, on declare async fn and a &@moondb.AsyncDriver. Run a mapper inside a transaction by passing the session's driver.

    The generator wires in like the other one, with cmd/sql and the package importing moonbitstack/moonorm/mapper:

    rule(name: "sql", command: "moonx moonbitstack/moonorm/cmd/sql@0.11.7 -C \"$mod_dir\" \"$input\" -o \"$output\"") dev_build(rule: "sql", input: "models.mbt", output: "mapper.g.mbt")

    23-sql-mapper runs each of these against a driver that logs what it is asked.

    #Batch loading

    Calling load once per parent is the N+1 trap — one SELECT per row. load_batch fetches the children of many parents in a single WHERE fk IN (…) round trip and buckets them back, index-aligned with the sources you passed. load_one_batch is the N:1 counterpart. Against the models declared above:

    pub struct Team {
    id : Int
    name : String
    }

    pub struct Hero {
    id : Int
    name : String
    team_id : Int
    }

    pub fn batch(
    sess : @moonorm.Session,
    heroes : @moonorm.Model[Hero],
    teams : Array[Team],
    ) -> Array[Array[Hero]] raise @moondb.DbError {
    let children = @moonorm.has_many(heroes, "team_id", (t : Team) => @moondb.Int(t.id))
    sess.load_batch(teams, children) // one query; result[0] is the first team's heroes
    }

    load, load_one, load_batch, load_one_batch and load_many take a strategy — SubqueryLoad (the default, one IN query), JoinedLoad (a JOIN against a derived table of the source keys) or RaiseLoad (refuse, so a caller can assert a relation is not touched here). load_many_batch batches an N:M relation through its junction the same way:

    sess.load_batch(teams, children, strategy=JoinedLoad)

    #Nested transactions & isolation

    begin_nested opens a SAVEPOINT and hands back a handle that names and depth-tracks it: finish it with release to keep the work or rollback to discard it back to the savepoint, both terminal and idempotent. For an isolation level or a read-only transaction, begin_with renders the backend's SET TRANSACTION (or SQLite PRAGMA) and orders it correctly around BEGIN per dialect:

    pub fn tx(sess : @moonorm.Session, stmt : @query.Insert, dialect : @query.Dialect) -> Unit raise @moondb.DbError {
    let sp = sess.begin_nested() // SAVEPOINT moonorm_sp_1; sess.savepoint_depth() == 1
    sess.add(stmt) |> ignore
    sp.rollback() // undo just this savepoint's work; depth back to 0

    sess.begin_with({ isolation: Some(Serializable), read_only: false }, dialect~)
    // BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
    }

    #Design and boundaries

    • Explicit, not magic. Every statement is issued explicitly through a Session. MoonBit has no attribute interception, so a mapped attribute cannot quietly fire SQL; reading a relationship is a call you can see.
    • Pure, zero C. The library is written entirely against the @moondb seam, so it compiles on every backend (wasm / wasm-gc / js / native) and drags no backend behind it. The C lives where a driver needs it — moonsqlite — never here.
    • Errors raise. A decode mismatch or a backend rejection is a @moondb.DbError at the call site, not a zero value.

    #Subqueries, CTEs & optimistic locking

    The builder does WITH common table expressions and IN (subquery) predicates, and both keep the binding guarantee — a subquery's values splice into the params list in the exact left-to-right order they appear in the SQL text:

    pub fn cte() -> (String, Array[@moondb.Value]) {
    let big = @moonorm.select("orders").column("user_id").where_("total", ">", @moondb.Int(100))
    @moonorm.select("users")
    .column("id")
    .column("name")
    .with_cte("big_spenders", big)
    .where_("active", "=", @moondb.Bool(true))
    .where_in("id", @moonorm.select("big_spenders").column("user_id"))
    .build()
    // WITH big_spenders AS (SELECT user_id FROM orders WHERE total > ?)
    // SELECT id, name FROM users WHERE active = ? AND id IN (SELECT user_id FROM big_spenders)
    // params: [Int(100), Bool(true)] <- the CTE value first, then the WHERE value
    }

    Optimistic concurrency control: write an UPDATE that bumps the version and guards on the one you read; modify_versioned runs it and raises LostUpdate if no row matched — the update was lost to a concurrent writer:

    pub fn optimistic(sess : @moonorm.Session, current : Int) -> Unit raise {
    let stmt = @moonorm.update("account")
    .set("balance", @moondb.Int(50))
    .set("version", @moondb.Int(current + 1))
    .where_("id", "=", @moondb.Int(1))
    .where_("version", "=", @moondb.Int(current)) // the guard
    sess.modify_versioned(stmt, what="account") |> ignore // raises LostUpdate on a stale version
    }

    #Event hooks

    A session fires at eleven points in its write path: before and after each insert, update and delete, around the flush, and around the transaction. One context is handed to every listener — the table, the row about to be written, and, for an After* hook, the statement that ran with its bound values.

    pub fn stamp_audit(sess : @moonorm.Session) -> Unit {
    sess.on(BeforeInsert, ctx => {
    // The row is mutable here: what the hook leaves behind is what gets written.
    ctx.set_column("updated_by", @moondb.Text("nightly-job"))
    })
    sess.on(BeforeInsert, ctx => {
    if ctx.table != "audit_log" {
    return
    }
    ctx.set_column("at", @moondb.Text("now"))
    })
    sess.on(AfterInsert, ctx => println(ctx.sql))
    }

    set_column replaces a value the row already carries or appends one it does not; set_columns swaps the whole list. A handler that raises aborts the flush before the statement is sent, and the row stays queued, so a refused flush is not a lost row. Session::listener_count and Session::off_all manage a session's listeners; listeners belong to the session that registered them and run in registration order.

    The names follow the SQLAlchemy events of the same spelling. A mapper-level listener there is a session listener here that checks ctx.table, since this layer has no mapper object to register on.

    #Hybrid attributes

    A hybrid is one definition read two ways: as SQL for filtering and ordering, and as a value computed off a record. Writing it once is what stops the two readings from drifting apart.

    pub fn full_name() -> @moonorm.Hybrid[Person] {
    @moonorm.hybrid(
    "full_name",
    "{t}.first || ' ' || {t}.last",
    p => @moondb.Text(p.first + " " + p.last),
    )
    }

    pub fn adults(people : @moonorm.Model[Person]) -> @moonorm.Select raise @moondb.DbError {
    people.select().where_hybrid(full_name(), "=", @moondb.Text("Ada Lovelace"))
    // SELECT id, first, last FROM people WHERE people.first || ' ' || people.last = ?
    }

    {t} is the table qualifier: where_hybrid, order_hybrid and project_hybrid fill it with the query's own table, or with the table= you pass when the query joins. A query that reads from a derived table has no name to fill in, and asking for one there raises rather than splicing a {t} into SQL the server would reject. Hybrid::value is the in-memory reading, and the two are expected to agree — that is what the tests compare, on a real database.

    #Inheritance mapping

    A class hierarchy is stored three ways, and each is one Model over one record type.

    Single table. The whole hierarchy lives in one table behind a discriminator. discriminate filters reads on it and forces it into inserts, so a subclass row cannot be written as a base row by accident.

    pub fn engineer_model() -> @moonorm.Model[Engineer] {
    @moonorm.Model::from_fields(
    "employee",
    [
    @moonorm.field("id", IntType, e => e.id, primary_key=true),
    @moonorm.field("name", TextType, e => e.name, nullable=false),
    @moonorm.field("engineer_name", TextType, e => e.engineer_name),
    ],
    engineer_row,
    )
    .discriminate(column="kind", identity="engineer")
    // SELECT id, name, engineer_name, kind FROM employee WHERE kind = ?
    }

    The base model still reads every row of the table; each subclass model reads only its own.

    Joined table. Each class's own columns live in its own table, joined on the key. joined projects the parent's columns qualified and then the subclass's, joins the parent, and splits writes: an insert writes the parent row and then the subclass row, which is the only order the foreign key allows, and a delete takes them in the opposite order.

    pub fn joined_engineer_model() -> @moonorm.Model[JoinedEngineer] {
    @moonorm.Model::from_fields("engineer", engineer_fields, joined_engineer_row)
    .joined(parent_table="person", parent_columns=["id", "name"])
    // SELECT person.id, person.name, engineer.engineer_name
    // FROM engineer JOIN person ON person.id = engineer.id
    }

    Concrete table. Every class has a table with all of its columns and there is no shared table to join through, so each class is simply its own model, and a polymorphic read is the union of the per-class reads:

    pub fn everyone(engineers : @moonorm.Select, managers : @moonorm.Select) -> @moonorm.Select {
    engineers.union_all(managers)
    }

    There is no instance dispatch in any of the three: MoonBit has no downcast, so a base read returns base records. Which columns exist is decided by the record type, which tables they come from by the model.

    #Migrations

    Two shapes, for two jobs.

    #Versioned steps

    The simple form: a Migration carries an integer version plus its up and down statement lists; a Migrator applies pending versions in ascending order, skips the ones already applied (so re-running is a no-op), rolls back to a target version in descending order, and reports the current version.

    pub fn migrations(sess : @moonorm.Session) -> Unit raise @moondb.DbError {
    let plan : Array[@moonorm.Migration] = [
    {
    version: 1,
    name: "create_users",
    up: ["CREATE TABLE users (id INTEGER PRIMARY KEY)"],
    down: ["DROP TABLE users"],
    },
    {
    version: 2,
    name: "add_email",
    up: ["ALTER TABLE users ADD COLUMN email TEXT"],
    down: ["ALTER TABLE users DROP COLUMN email"],
    },
    ]
    let m = @moonorm.Migrator::new()
    m.up(sess, plan) |> ignore // applies 1 then 2; returns how many ran
    let _ = m.current_version(sess) // 2
    m.down_to(sess, plan, 1) |> ignore // rolls back 2, leaving 1
    }

    #Revisions

    An integer cannot express two people branching from the same point and merging later, so the full form is a graph: a Revision names its parents — none for a base, one for a step, two or more for a merge — and a change is a list of Op values rather than SQL text.

    pub fn revisions() -> Array[@moonorm.Revision] {
    [
    @moonorm.Revision::new(
    id="0001",
    name="create_heroes",
    up=[
    @moonorm.create_table("heroes", [
    @moonorm.column("id", IntType, primary_key=true, autoincrement=true),
    @moonorm.column("name", TextType, nullable=false),
    ]),
    @moonorm.create_index("ix_heroes_name", "heroes", ["name"]),
    ],
    down=[
    @moonorm.drop_index("ix_heroes_name"),
    @moonorm.drop_table("heroes"),
    ],
    ),
    @moonorm.Revision::new(
    id="0002",
    parents=["0001"],
    name="create_teams",
    up=[@moonorm.create_table("teams", teams_columns)],
    down=[@moonorm.drop_table("teams")],
    ),
    ]
    }

    The operations are create_table, drop_table, add_column, drop_column, rename_table, rename_column, alter_column, add_foreign_key, drop_foreign_key, create_index, drop_index, rebuild_table and execute. Being values, they render for any dialect, compare in a test, and can be printed before anything runs.

    pub fn apply(sess : @moonorm.Session, plan : Array[@moonorm.Revision]) -> Unit raise @moondb.DbError {
    let m = @moonorm.Migrator::new()
    m.upgrade(sess, plan) |> ignore // to the single head; "heads" for every tip
    m.downgrade(sess, plan, target="0001") |> ignore
    m.stamp(sess, plan, target="0002") |> ignore // record it without running it
    let _ = m.heads(sess, plan) // where the database sits
    let _ = m.history(sess, plan) // newest first, each marked applied
    }

    Each revision and the row that records it run in one transaction, so an interrupted upgrade resumes at a revision boundary. Running the same set twice applies nothing.

    upgrade_sql and downgrade_sql return exactly the statements the online path executes, which is the offline mode and what a driver behind an async seam runs:

    pub fn plan_sql(plan : Array[@moonorm.Revision]) -> Array[@moonorm.Step] raise @moondb.DbError {
    @moonorm.Migrator::new().upgrade_sql(plan, [], target="heads", dialect=Postgres)
    }

    An engine that cannot express an operation is refused, not approximated: SQLite cannot add a foreign key or change a column's type in place, and alter_column there raises and points at rebuild_table, which is the four-step swap SQLite's batch alter performs.

    #Autogenerate

    Compare what the models declare against what the database has, and get the operations that close the gap — with the operations that undo them:

    pub fn drift(declared : Array[@moonorm.TableSpec], live : Array[@moonorm.TableSpec]) -> Array[@moonorm.Change] {
    @moonorm.autogenerate_changes(declared, live, dialect=Postgres)
    // up: ALTER TABLE heroes ADD COLUMN power INTEGER
    // down: ALTER TABLE heroes DROP COLUMN power
    }

    The comparison covers tables added and dropped, columns added and dropped, type and nullability changes (types compared as the dialect spells them, so an Int64 that SQLite stores as INTEGER is not a change), and indexes added, dropped and replaced. TableSpec is a model without its record type — model.spec() produces one — and reflect_schema(driver, dialect) produces the other side.

    Because every change carries its inverse, autogenerate_revision produces a revision with a downgrade already written:

    pub fn next_revision(declared : Array[@moonorm.TableSpec], live : Array[@moonorm.TableSpec]) -> @moonorm.Revision {
    @moonorm.autogenerate_revision(id="0005", parents=["0004"], name="hero powers", declared, live)
    }

    #Injection safety

    pub fn injection() -> (String, Array[@moondb.Value]) {
    let evil = "'; DROP TABLE users; --"
    @moonorm.select("users").eq("name", @moondb.Text(evil)).build()
    // SELECT * FROM users WHERE name = ? <- the attack string is not in the SQL
    // params: [Text("'; DROP TABLE users; --")] <- it is a bound parameter
    }

    #License

    Apache-2.0.

    AsSource

    What can stand where a table is expected. A String is the table name — so select("users") and "teams".join(…) keep reading as they always did — and a Source is anything richer (a derived table, an alias, a LATERAL).

    Change

    One change and the change that undoes it, both derived from the same comparison — so the downgrade of an autogenerated revision is written for you and cannot drift from the upgrade.

    Column

    One declared column of a Model: its name, storage col_type, whether it is the (or part of the) primary key, whether it accepts NULL, and an optional foreign-key references target (table, column). This is the explicit stand-in for SQLAlchemy's mapped_column(...) — every fact that framework reads off the annotated attribute is stated here as data.

    ColumnDiff

    One difference between a declared schema and a reflected one (Alembic-autogenerate style): a column Added in the model but absent in the database, one Removed from the model but still present, or one whose TypeChanged. Compared by column name; ordering follows the declared columns, then the leftover reflected ones.

    ColumnType

    A column's SQL storage type. These map onto SQLite's type affinities when a Model renders CREATE TABLE DDL: IntType/BoolType -> INTEGER, TextType -> TEXT, RealType -> REAL, BlobType -> BLOB. BoolType is a distinct declared type so DDL reads intently, but binds and reads as an integer 0/1.

    CustomColumnType

    A user-defined SQL storage type: the declared type keyword each of the three dialects emits for it. This is the extension point for a storage type the built-in ColumnType variants do not name — a citext, a tsvector, a domain type. Pair it with ToValue/FromValue on the field's type so values still bind and read, and the column stores in whatever native type each backend is told to use.

    Delete

    A DELETE statement builder. Generative like Select, and it shares the same WHERE clause.

    Dialect

    The SQL dialect a statement renders for. Most of the builder is dialect-neutral — it emits ? placeholders and standard clauses that every backend accepts — but a few constructs genuinely differ: upsert is ON CONFLICT ... DO UPDATE on SQLite and PostgreSQL versus ON DUPLICATE KEY UPDATE on MySQL, and RETURNING exists on SQLite and PostgreSQL but not MySQL. build_for takes a Dialect so those render correctly; the no-argument build renders the SQLite/PostgreSQL form, which is also what the ?-placeholder default targets.

    Dsn

    A parsed connection string. The scheme picks the driver and the dialect; the rest is what a driver needs to connect. A +driver suffix (SQLAlchemy's postgresql+asyncpg://) overrides driver while leaving the scheme's dialect.

    DsnError

    Why a connection string could not be read.

    Engine

    A backend selected by its connection string: the parsed Dsn plus the dialect its sessions render for. session binds one to a driver the caller supplies.

    Field

    One field of a declarative model: a declared [Column] paired with the projection that reads the field's value out of a record T. A Field is the column metadata plus the "how to persist this one attribute" half of the mapping, factored to one place so a Model can be assembled from a list of fields rather than a hand-written to_columns.

    This is the shape moonctl's model generator emits: from a #orm-annotated struct it produces one field(...) per attribute (the encode closure is a trivial projection like h => Int(h.id)), and a single from_row decoder. The generated [Field] array then drives the column list, the CREATE TABLE DDL, the SELECT projection, and the INSERT binding — everything except decoding a row back into T, which needs the record constructor MoonBit cannot synthesise without reflection (see [Model::from_fields]).

    FromValue

    A MoonBit value read back from a cell: Go's sql.Scanner, Diesel's FromSql. The conversions are the ones [Row]'s typed accessors make. A refusal is a TypeError whose message reads after "column N is", such as "Text, not Int".

    HistoryEntry

    One line of a migration history: the revision, its parents, its message, and whether the database has it.

    HookContext

    What a listener is told. columns is the row the unit of work is about to write, as the model's to_columns projected it — and it is mutable, so a Before* hook can stamp a timestamp, normalise a value, or drop a column, and the flush renders what the hook left behind. For a delete it holds the primary key that addresses the row. sql and params carry the statement that ran, filled in for After* hooks and empty before it.

    HookEvent

    The point in the unit of work a listener fires at. The Before* write hooks see the row before it is rendered and may rewrite its columns; the After* hooks see the statement that ran. BeforeFlush / AfterFlush bracket the whole flush, and BeforeCommit / AfterCommit / AfterRollback bracket the transaction.

    The names follow SQLAlchemy's before_insert, after_insert and friends, which is why they are read as the phase they fire in rather than as statements.

    Hybrid

    A computed attribute with two readings: expr is SQL over the model's columns, and read computes the same value from a record. expr writes {t} where the table qualifier belongs, so one definition serves an unqualified single-table query and a qualified one over a join.

    Index

    An index on a model's table: on one column or several, and unique to enforce a key that is not the primary key, as SQLAlchemy's Index does. Without a name it is called ix_<table>_<columns>, or uq_ when unique, after SQLAlchemy's naming convention.

    Insert

    An INSERT statement builder, with optional upsert (ON CONFLICT) and RETURNING support. Generative like Select.

    IsolationLevel

    A SQL transaction isolation level, in the four-rung ANSI ladder from weakest to strongest. Passed via TxOptions to Session::begin_with, which renders the backend's SET TRANSACTION (or SQLite PRAGMA) statement.

    LoadStrategy

    How a relationship is loaded. SubqueryLoad is the default: one IN query for a whole list of sources (selectinload). JoinedLoad fetches through a JOIN against a derived table of the source keys (joinedload). RaiseLoad refuses, so a caller can assert a relation is not touched here (raiseload).

    LockClause

    A row-locking clause on a SELECT (SQLAlchemy with_for_update): the lock mode, the tables it is restricted to (OF …, empty = all), and whether it fails rather than waits (NOWAIT) or skips locked rows (SKIP LOCKED).

    LockMode

    The kind of row lock a FOR UPDATE / FOR SHARE clause takes.

    LostUpdate

    A lost update was detected: an optimistic-lock UPDATE matched no row because the row's version had already moved on since it was read. Raised by Session::modify_versioned.

    ManyToMany

    A many-to-many relationship from a source S to a target T through a junction (association) table — SQLAlchemy's relationship(secondary=...). The junction's source_fk column matches a key extracted from the source, and its target_fk column matches the target's target_key. Resolving the relationship JOINs the target through the junction. Load it with Session::load_many; like the 1:N / N:1 relations, MoonBit's lack of attribute interception makes the load an explicit call rather than a lazy attribute access.

    Migration

    One schema change: a monotonically increasing version, a human name, and the ordered SQL statements that apply it (up) and undo it (down). Each statement runs through Session::execute, so a backend that prepares one statement per call (SQLite) still applies a multi-statement migration.

    Migrator

    Tracks and applies migrations against a bookkeeping table (its name is configurable for coexistence with other tools). Numeric migrations record their version in table; revision migrations record their ids in revisions_table. Build one with Migrator::new.

    Model

    A declarative model: the mapping between a database table and a MoonBit record type T. It bundles the table name, the ordered columns, and the two mapping closures that MoonBit cannot synthesise for want of reflection: from_row decodes a fetched Row into a T, and to_columns projects a T back into the (name, Value) pairs an INSERT binds. Build one with Model::new.

    This is the faithful explicit equivalent of a SQLAlchemy declarative class — the columns are the mapped_columns, and the two closures are the automatic attribute<->column mapping made visible. Being a plain value it stays pure and compiles on every backend.

    Nulls

    Where NULLs sort in an ORDER BY term (SQLAlchemy's nulls_first() / nulls_last()). Auto emits nothing and leaves the placement to the server, which differs: PostgreSQL sorts NULLs last ascending, SQLite first. First / Last say it explicitly; MySQL has no such syntax and rejects it.

    One schema change. AlterColumn carries only what changes, so a rename is a rename rather than a full column redefinition; RebuildTable is SQLite's batch alter, where the column set is supplied whole because the engine cannot alter a column in place.

    Order

    Sort direction for an ORDER BY term.

    Predicate

    A boolean predicate tree — the parameter-safe way to express arbitrary AND/OR/NOT groupings (SQLAlchemy's and_() / or_() / not_()), which the flat where_/eq API (always joined by AND) cannot. Every comparison binds its value as a placeholder, so a (a = ? OR b = ?) filter stays fully parameterised. Attach one to a query with Select::where_pred.

    ReflectedIndex

    One index read back from a database: the table it belongs to, its name, its columns in order, and whether it is unique. Reflection groups it into a TableSpec index.

    Relation

    A foreign-key relationship from a source record S to a target model T, resolved by matching target.key_column against a Value extracted from the source. to_many records the cardinality (a 1:N parent->children link versus a N:1 child->parent link) so callers know whether to expect many rows or one.

    Because MoonBit has no attribute interception, touching hero.team cannot silently emit a SELECT the way SQLAlchemy's lazy load does. The relationship is therefore a first-class value and the load is explicit — Session::load / Session::load_one — exactly the eager, explicit shape Diesel's belonging_to / preload and GORM's Preload take.

    Revision

    One revision: an identifier, the revisions it follows (parents), a message, and the operations that apply and undo it.

    parents is empty for a base revision, holds one id for a linear step, and two or more for a merge. That is the whole difference between a chain and a DAG — the rest of the runner walks whichever shape the parents describe.

    RowStream

    A typed streaming cursor: each next decodes one row into a record via the model. The record-level counterpart to [Session::stream], the streaming form of [fetch_as] (SQLAlchemy's yield_per).

    Savepoint

    A nested transaction opened by Session::begin_nested, wrapping one SAVEPOINT whose name and depth the session tracks for you. It is the faithful equivalent of the object SQLAlchemy's Session.begin_nested() returns: finish it by release-ing (keep the work, merging it into the enclosing transaction) or rollback-ing (discard the work since the savepoint). Both are terminal and idempotent — calling either a second time is a no-op — and both decrement the session's savepoint depth.

    Schema

    A set of tables — a declared metadata tree, or everything reflected off a database. The unit autogenerate compares and the unit a snapshot renders.

    Select

    A SELECT statement builder. Every method is generative — it returns a new statement and leaves the receiver alone — so a half-built query is a safe base to branch from, which is what SQLAlchemy's statement API guarantees.

    Session

    An explicit unit-of-work over a @moondb.Driver. Unlike SQLAlchemy's implicit autoflush and attribute-triggered SQL, every statement here is issued explicitly — the faithful MoonBit equivalent given the absence of attribute interception, exactly as Diesel and GORM also require. It holds the backend as a &@moondb.Driver trait object, so one Session type drives every backend.

    Source

    What a FROM or a JOIN names. A bare table is Tbl; Sub is a derived table (a whole Select, whose bound values thread into the enclosing statement's params at the position the subquery renders); Alias names either of those, which PostgreSQL and MySQL require of a derived table; Lateral lets a derived table reference the columns of the sources to its left (PostgreSQL and MySQL 8.0.14+; SQLite has no LATERAL).

    Statement

    One statement to run, with the values it binds.

    Step

    One revision's worth of work: the operations rendered, in order, plus the bookkeeping statement. A runner executes every statement of a step in one transaction.

    Table

    A lightweight table descriptor: a table name plus its known columns. It is the hand-written stand-in for SQLAlchemy's Table(...) metadata object — MoonBit has no reflection, so the schema is declared explicitly rather than introspected. select() turns it into a Select with every column projected.

    TableSpec

    One table described by its columns and indexes — the record-type-free view of a Model. Two TableSpecs are what a schema comparison needs: a name, the ordered columns, and the indexes on them.

    ToValue

    A MoonBit value that binds as a parameter: Go's driver.Valuer, Diesel's ToSql. Implemented for the type behind each [Value] case and for T?, where None binds NULL. A type of the program's own implements it to be stored as one of those cases.

    TxOptions

    Options for a transaction opened by Session::begin_with: an optional isolation level and a read_only flag. This is the explicit counterpart of SQLAlchemy's connection.execution_options(isolation_level=…) — a plain value the session renders into the backend's transaction-characteristics statement.

    Update

    An UPDATE statement builder. Generative like Select, and it shares the same WHERE clause, so where_pred / where_in narrow an update exactly as they narrow a query.

    Value

    A single dialect-neutral database value — the unit of everything that crosses the driver boundary in either direction. Bound parameters travel in as Values (never spliced into the SQL string, so binding is injection-safe by construction) and result columns come back as Values.

    This is the moondb analogue of Go's driver.Value and Python DB-API's input type objects: a small, closed sum every backend can map onto its own wire types. Drivers normalise their column types down to these cases; a query layer built on moondb reads them back through [Row]'s typed accessors.

    • Null — SQL NULL / a missing value.
    • Bool — a boolean; backends without a native boolean map 0/1.
    • Int — a 32-bit signed integer.
    • Int64 — a 64-bit signed integer (rowids, BIGINT, counters).
    • Double — an IEEE-754 double (REAL / FLOAT8).
    • Text — a UTF-8 string (TEXT / VARCHAR).
    • Blob — an opaque byte string (BLOB / BYTEA).
    • Time — a temporal value as normalized ISO-8601 text (DATE, TIME, TIMESTAMP, TIMESTAMPTZ). It stays text so this interface package keeps zero dependencies; a caller parses it with a date-time library of its choice.
    • Numeric — an exact decimal (NUMERIC / DECIMAL) as its canonical digit text, so no precision is lost the way it would through Double.
    • Uuid — the 16 raw bytes of a UUID (UUID), kept binary rather than text.
    • Json — the JSON text of a JSON / JSONB column, unparsed.
    • Array — a one- or multi-dimensional SQL array (INTEGER[], TEXT[], …), elementwise.

    add_column

    Add one column.

    add_foreign_key

    fn add_foreign_key(table : String, name : String, columns : Array[String], ref_table : String, ref_columns : Array[String], on_delete? : String, on_update? : String) ->
    Op

    Add a foreign-key constraint to an existing table. SQLite cannot add one without rebuilding the table, so it raises there.

    alter_column

    fn alter_column(table : String, name : String, new_name? : String, col_type? :
    ColumnType
    , nullable? : Bool, default? : String, drop_default? : Bool) ->
    Op

    Change a column: its name, its type, its nullability, or its default. Only the parts given are rendered. SQLite cannot change a type or a nullability in place, so that combination raises and points at rebuild_table.

    autogenerate

    Just the operations an upgrade needs, in order — what a migration body contains.

    autogenerate_changes

    The difference between a declared schema and a reflected one, as up/down pairs.

    The order is the order a migration must run in: create missing tables and their indexes first, then bring existing tables in line column by column, then drop the indexes and tables that are gone. Columns are compared by name; a type compares as dialect spells it, so an Int64 reflected as INTEGER on SQLite is not a change; nullability is compared only off the primary key, which SQLite reports as nullable because INTEGER PRIMARY KEY is the rowid.

    autogenerate_revision

    A revision built from the difference, with its up and down both filled in. The downgrade is exact over the reflected state, which is what makes it safe to run.

    belongs_to

    fn[S, T, V :
    ToValue
    ] belongs_to(target :
    Model
    [T], target_key : String, child_key : (S) -> V) ->
    Relation
    [S, T]

    A N:1 relationship: child -> its single parent in target, matched by the parent's target_key column (usually its primary key) equalling the foreign key extracted from the child by child_key. Load it with Session::load_one.

    case_

    fn case_(whens : Array[(String, String)], else_? : String) -> String

    A searched CASE expression: each (condition, result) pair becomes one WHEN … THEN …, and a non-empty else_ the trailing ELSE. Identifier-level like func. With no branches there is nothing to test, so it folds to else_ (or NULL), which is what an all-branchless CASE would evaluate to anyway.

    cast

    fn cast(expr : String, ty : String) -> String

    A type conversion: cast("price", "NUMERIC(10,2)") renders CAST(price AS NUMERIC(10,2)). Identifier-level like func.

    column

    fn column(name : String, col_type :
    ColumnType
    , primary_key? : Bool, autoincrement? : Bool, nullable? : Bool, unique? : Bool, default? : String, checks? : Array[String], references? : (String, String), on_delete? : String, on_update? : String) ->
    Column

    Declare a Column. primary_key and references default off; a primary-key column is NOT NULL implicitly (SQLite treats INTEGER PRIMARY KEY as the rowid), and nullable defaults to true for every other column, matching SQLAlchemy's nullable=True default.

    create_index

    fn create_index(name : String, table : String, columns : Array[String], unique? : Bool, if_not_exists? : Bool) ->
    Op

    Create an index.

    create_table

    fn create_table(name : String, columns : Array[
    Column
    ], if_not_exists? : Bool) ->
    Op

    Create a table from its column list.

    custom_column_type

    fn custom_column_type(sqlite~ : String, postgres~ : String, mysql~ : String) ->
    CustomColumnType

    Declare a user-defined storage type from its per-dialect SQL spelling.

    delete

    fn delete(table : String) ->
    Delete

    Start a DELETE from table.

    diff_schema

    Diff a model's declared columns against the reflected ones from a live table: declared-only columns are Added, reflected-only are Removed, and a name in both with a different col_type is TypeChanged. An empty result means the table matches the model. This is the write half of autogenerate — feed the diff to DDL to synthesise the migration.

    Scope: reflection recovers only a column's storage type, so declare a model at the same granularity to avoid spurious diffs — a Bool stored as INTEGER reflects as Int, Uuid/Json stored as TEXT reflect as Text. And the diff reports add/remove/type only, not nullable / default / unique / PK / FK changes.

    With dialect the types compare as that engine writes them, so a BigIntType declared against SQLite, where every integer is INTEGER, is no change; without it they compare as declared.

    drop_column

    fn drop_column(table : String, name : String) ->
    Op

    Drop one column.

    drop_foreign_key

    fn drop_foreign_key(table : String, name : String) ->
    Op

    Drop a foreign-key constraint by name.

    drop_index

    fn drop_index(name : String, table? : String, if_exists? : Bool) ->
    Op

    Drop an index. table matters on MySQL, which names an index as table.index.

    drop_table

    fn drop_table(name : String, if_exists? : Bool) ->
    Op

    Drop a table, optionally tolerating its absence.

    execute

    fn execute(sql : String) ->
    Op

    Run SQL verbatim. For the changes the operation set does not model — a trigger, an engine-specific clause — and for data migrations.

    field

    fn[T, V :
    ToValue
    ] field(name : String, col_type :
    ColumnType
    , encode : (T) -> V, primary_key? : Bool, autoincrement? : Bool, nullable? : Bool, unique? : Bool, default? : String, checks? : Array[String], references? : (String, String), on_delete? : String, on_update? : String) ->
    Field
    [T]

    Declare a model field: its column metadata and the projection encode that reads the field out of a record. The column knobs (primary_key, nullable, references) mirror [column]. encode returns anything that binds as a parameter, so it is usually the attribute itself, h => h.id, and T? binds None as NULL; a [Value] binds as itself.

    func

    fn func(name : String, args : Array[String]) -> String

    A function call as a column expression: func("coalesce", ["a", "b"]) renders coalesce(a, b). This is SQLAlchemy's func.* without the attribute magic — the arguments are identifier-level SQL, rendered verbatim like Select::raw, so bind a value by placing a ? and passing it through the surrounding clause.

    has_many

    fn[S, T, V :
    ToValue
    ] has_many(target :
    Model
    [T], foreign_key : String, parent_key : (S) -> V) ->
    Relation
    [S, T]

    A 1:N relationship: parent -> its children in target, matched by the child's foreign_key column equalling the parent key extracted by parent_key, any value a column takes. Load it with Session::load, which returns every matching child record.

    hybrid

    fn[T] hybrid(name : String, expr : String, read : (T) ->
    Value
    ) ->
    Hybrid
    [T]

    Define a hybrid attribute. expr is trusted SQL over column names, like Select::raw: it may contain literals and operators, but no bound values — a placeholder there would have no parameter to bind to. read must produce the value the expression evaluates to; the pair is what a test comparing both readings checks.

    index

    fn index(columns : Array[String], name? : String, unique? : Bool) ->
    Index

    index_ddl

    fn index_ddl(name : String, table : String, columns : Array[String], unique? : Bool) -> String

    Render a CREATE INDEX statement (SQLAlchemy's Index / index=True). With unique=true it is a CREATE UNIQUE INDEX; multiple columns make a composite index. Names are trusted identifiers, never bound. Pair it with a Migration's up, and a DROP INDEX in its down.

    indexes_of_rows

    Group index rows — (table, index, unique, column), one row per indexed column, in column order — into indexes. The shape is the one postgres_indexes_query and mysql_indexes_query return, so one parser serves both; SQLite builds the same rows from its two pragmas.

    insert

    fn insert(table : String) ->
    Insert

    Start an INSERT into table. Values are bound, never spliced, so a column holding a quote or a semicolon is a value and not syntax.

    many_to_many

    fn[S, T, V :
    ToValue
    ] many_to_many(target :
    Model
    [T], junction~ : String, source_fk~ : String, target_fk~ : String, target_key~ : String, source_key~ : (S) -> V) ->
    ManyToMany
    [S, T]

    Define a many-to-many relationship through junction. source_fk / target_fk are the junction columns pointing at the source key and the target_key.

    mysql_columns_query

    fn mysql_columns_query(table : String) -> String raise
    DbError

    The columns of one MySQL table (SHOW COLUMNS): Field, Type, Null, Key, Default,Extra.

    mysql_indexes_query

    fn mysql_indexes_query() -> String

    Every index in the current MySQL database, one row per indexed column: TABLE_NAME, INDEX_NAME, is_unique, COLUMN_NAME. NON_UNIQUE is inverted in the query so all three backends report the same thing in the same column.

    mysql_tables_query

    fn mysql_tables_query() -> String

    The base tables in the current MySQL database.

    parse_dsn

    Parse dsn into a Dsn. It accepts the SQLAlchemy URL forms:

    • sqlite://, sqlite:///:memory:, sqlite:///file.db, sqlite:////abs/file.db
    • postgresql://user:pass@host:5432/db?sslmode=require (and postgres://)
    • mysql://user:pass@host:3306/db (and mysql+pymysql://)

    A +driver suffix overrides the driver name without changing the dialect. An absent or unknown scheme raises DsnError.

    postgres_columns_query

    fn postgres_columns_query() -> String

    The columns of one PostgreSQL table, in declaration order. The table arrives as the statement's only bound parameter, so this is the text alone.

    postgres_indexes_query

    fn postgres_indexes_query() -> String

    Every index in the current PostgreSQL schema that was created as an index, one row per indexed column: tablename, indexname, is_unique, attname.

    PostgreSQL backs a PRIMARY KEY or UNIQUE constraint with an index, and those are not indexes the schema declares — reporting them would make every key look like a stray index autogenerate wants to drop.

    postgres_tables_query

    fn postgres_tables_query() -> String

    The base tables in the current PostgreSQL schema.

    rebuild_table

    fn rebuild_table(name : String, columns : Array[
    Column
    ], copy : Array[String]) ->
    Op

    SQLite's batch alter: build the table again from columns, carrying over the listed copy columns, and swap it in. Indexes on the old table are dropped with it, so pair this with the create_index operations that re-establish them.

    reflect_columns

    Parse PRAGMA table_info rows into Columns — the reflection core, testable without a live database. The pragma projects, in order, cid, name, type,notnull, dflt_value, pk; a non-zero pk marks the primary key and notnull its NOT NULL. This is the read half of Alembic-style autogenerate: compare the reflected columns against a Model's declared columns to diff a schema.

    reflect_columns_mysql

    Parse MySQL SHOW COLUMNS rows (Field, Type, Null, Key, Default, Extra) into Columns, recovering the primary key (Key == "PRI") and autoincrement (Extra contains auto_increment).

    reflect_columns_pg

    Parse PostgreSQL information_schema.columns rows (column_name, data_type, is_nullable) into Columns — the PG counterpart of reflect_columns.

    reflect_schema

    Reflect a whole database: every base table, its columns, and its indexes — the read half of autogenerate, and what a round-trip test compares before and after.

    The per-dialect query texts are public (sqlite_tables_query and friends) so a driver reached over a network can issue the same statements and feed the same parsers, which is how the PostgreSQL round trip runs without a synchronous driver.

    reflect_table

    Reflect a table's columns off a live connection: run PRAGMA table_info and parse it. The thin driver wrapper over reflect_columns.

    reflect_table_for

    Reflect a table's columns off a live connection for a specific Dialect: SQLite via PRAGMA table_info, PostgreSQL via information_schema.columns, MySQL via SHOW COLUMNS. The multi-dialect counterpart of reflect_table.

    rename_column

    fn rename_column(table : String, from : String, to : String) ->
    Op

    Rename a column. Every dialect here spells this RENAME COLUMN, so it is the one column change that does not need a table rebuild on SQLite.

    rename_table

    fn rename_table(from : String, to : String) ->
    Op

    Rename a table.

    revision_bases

    fn revision_bases(revisions : Array[
    Revision
    ]) -> Array[String]

    The revisions with no parents, ascending in declaration order.

    revision_heads

    fn revision_heads(revisions : Array[
    Revision
    ]) -> Array[String]

    The revisions nothing else descends from — the tips of the graph. More than one means the history has branches that have not been merged yet.

    revisions_table_sql

    fn revisions_table_sql(table : String) -> String raise
    DbError

    The CREATE TABLE IF NOT EXISTS for a revisions table. Public because a caller that executes a plan itself — an offline run, or a driver that lives behind the async seam — has no Session to call ensure_revisions_table on, and the bookkeeping table has to exist before the plan's inserts run.

    select

    Start a SELECT over src — a table name, or a Source for a derived table, an alias or a LATERAL.

    sqlite_columns_query

    fn sqlite_columns_query(table : String) -> String raise
    DbError

    PRAGMA table_info for one table: cid, name, type, notnull, dflt_value, pk.

    sqlite_index_info_query

    fn sqlite_index_info_query(index : String) -> String raise
    DbError

    PRAGMA index_info for one index: seqno, cid, name.

    sqlite_index_list_query

    fn sqlite_index_list_query(table : String) -> String raise
    DbError

    PRAGMA index_list for one table: seq, name, unique, origin, partial. Only rows with origin = 'c' are indexes the schema declared; 'u' and 'pk' are the implicit ones a UNIQUE or PRIMARY KEY constraint creates, which have no name to drop.

    sqlite_tables_query

    fn sqlite_tables_query() -> String

    The tables in the current SQLite database, excluding SQLite's own.

    table_names_of

    The table names in a result set whose first column is the name.

    update

    fn update(table : String) ->
    Update

    Start an UPDATE of table. Without a where_ it rewrites every row, which is what SQL does and what the caller asked for.

    user_type

    fn user_type(sqlite~ : String, postgres~ : String, mysql~ : String) ->
    ColumnType

    A ColumnType for a user-defined storage type — the open counterpart of the built-in variants. It renders sqlite on SQLite, postgres on PostgreSQL and mysql on MySQL.

    Source Files