Sign in

    This version of the module is deprecated: Moved to moonbitstack/moonorm.

    moonorm

    moonorm — an ORM / SQL toolkit for MoonBit (← SQLAlchemy / SQLModel): a parameterized, injection-safe query builder (select / insert / update / delete).

    sqlalchemy
    orm
    sql
    query-builder
    database
    moonbit
    Download zip
    Author
    Version
    0.7.1
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    159

    #moonorm

    An ORM / SQL toolkit for MoonBit — ← SQLAlchemy / SQLModel.

    Check and Test License mooncakes

    moonorm is the MoonBit counterpart to SQLAlchemy: the heart of SQLAlchemy Core — a parameterized, injection-safe query builder — plus a model/session execution layer. Values are never spliced into the SQL the builder produces: every bound value becomes a ? placeholder plus an entry in a params list. How far that reaches depends on the driver — SQLite and Postgres 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 rather than on the wire format.

    moonorm owns no driver contract of its own. It is written entirely against the moondb interface — the standard database-access seam for MoonBit — so it is pure MoonBit with zero C and compiles on every backend (wasm / wasm-gc / js / native). A concrete backend is a separate package you supply: the native SQLite driver lives in moon-sqlite, and a Session drives any @moondb.Driver — including the dependency-free @moondb.MockDriver for tests.

    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 Lfan-ke/moondb). The SQLite driver's package name is hyphenated, so import it under an alias in moon.pkg.json ({"path": "Lfan-ke/moon-sqlite", "alias": "sqlite"}) and reach it as @sqlite.

    #Quickstart

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

    let (isql, ivals) = @moonorm.insert("users")
    .set("name", @moondb.Text("bob")).set("age", @moondb.Int(30)).build()
    // "INSERT INTO users (name, age) VALUES (?, ?)"

    @moonorm.update("users").set("age", @moondb.Int(31)).where_("id", "=", @moondb.Int(1)).build()
    @moonorm.delete("users").where_("id", "=", @moondb.Int(9)).build()

    // JOIN + GROUP BY + HAVING, with aggregates and a Table descriptor:
    let orders : @moonorm.Table = { name: "orders", columns: [] }
    let (jsql, jparams) = 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)]

    #Running against a real database

    The builder is only half the story — moonorm also executes, against any @moondb.Driver. Add the native SQLite driver and a Session runs your built statements against an actual database and hands back typed rows. Every fallible operation raises @moondb.DbError (a decode or backend error surfaces at the call site, never as a silent zero value), so call them inside a function that propagates that error:

    // native target only — moon-sqlite links the vendored amalgamation.
    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 isn't 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 (SQLAlchemy's begin_nested), and raw execute / query. Everything travels as bound parameters as far as the driver carries them; see the note above on the MySQL text protocol.

    #Models & relationships

    A Model[T] is the explicit declarative mapping between a table and a MoonBit record — the typed columns plus the two mapping closures MoonBit cannot synthesise for want of reflection (Row -> record, record -> bound columns). From it you get CREATE TABLE DDL, typed inserts, and eager foreign-key loading. Because MoonBit has no attribute interception, parent.children cannot silently fire a SELECT the way SQLAlchemy's lazy load does; the load is an explicit call — session.load / session.load_one — exactly the shape Diesel's preload and GORM's Preload take.

    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, not as a silent zero. Write them in arrow form so the raise effect is inferred:

    // A parent model and a child model with a foreign key back to it.
    let teams : @moonorm.Model[Team] = @moonorm.Model::new(
    "teams",
    [@moonorm.column("id", @moonorm.IntType, primary_key=true),
    @moonorm.column("name", @moonorm.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", @moonorm.IntType, primary_key=true),
    @moonorm.column("name", @moonorm.TextType, nullable=false),
    @moonorm.column("team_id", @moonorm.IntType, references=Some(("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))],
    )

    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, eager-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) // [Hero{...}, ...]

    // 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) // Some(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, and MoonBit — with no reflection — cannot synthesise it; that one closure is the irreducible core of the mapping. This is exactly the shape moonctl's model generator emits from an #orm-annotated struct — a [field(...)] array and a from_row:

    let heroes : @moonorm.Model[Hero] = @moonorm.Model::from_fields(
    "heroes",
    [
    @moonorm.field("id", @moonorm.IntType, (h : Hero) => @moondb.Int(h.id), primary_key=true),
    @moonorm.field("name", @moonorm.TextType, (h : Hero) => @moondb.Text(h.name), nullable=false),
    @moonorm.field("team_id", @moonorm.IntType, (h : Hero) => @moondb.Int(h.team_id),
    references=Some(("teams", "id"))),
    ],
    (r) => { id: r.int(0), name: r.text(1), team_id: r.int(2) }, // the one closure that stays
    )

    #Batch loading (avoiding N+1)

    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. This is the explicit equivalent of SQLAlchemy's selectinload:

    let children = @moonorm.has_many(heroes, "team_id", (t : Team) => @moondb.Int(t.id))
    let teams : Array[Team] = [{ id: 1, name: "avengers" }, { id: 2, name: "x-men" }]
    let grouped = sess.load_batch(teams, children) // one query; grouped[0] = avengers' heroes, …

    #Nested transactions & isolation

    begin_nested opens a SAVEPOINT and hands back a handle that names and depth-tracks it for you (SQLAlchemy's Session.begin_nested()): 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:

    let sp = sess.begin_nested() // SAVEPOINT moonorm_sp_1; sess.savepoint_depth() == 1
    sess.add(insert_stmt) |> ignore
    sp.rollback() // undo just this savepoint's work; depth back to 0

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

    #Design & boundaries (honest)

    • Explicit, not magic. SQLAlchemy issues SQL implicitly when you touch a mapped attribute; MoonBit has no attribute interception, so every statement is issued explicitly via Session — the same faithful trade Diesel and GORM make.
    • Pure, zero C. moonorm is written entirely against the @moondb seam, so the whole library compiles on every backend (wasm / wasm-gc / js / native) and drags no backend behind it. The C lives in one isolated place — moon-sqlite — not here. A Postgres wire-protocol backend and a JS node:sqlite backend are next.
    • Tested against a real driver. The Session/Model/relationship layer is covered on every backend against @moondb.MockDriver; real SQL execution is proven end to end in moon-sqlite's native integration tests, which open an actual SQLite database and are mutation-verified (neutering the C bind path turns them red).

    #Subqueries, CTEs & optimistic locking

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

    let big = @moonorm.select("orders").column("user_id").where_("total", ">", @moondb.Int(100))
    let (sql, params) = @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)] ← CTE value first, then the WHERE value

    Optimistic concurrency control mirrors SQLAlchemy's version_id_col. 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:

    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

    #Migrations

    Versioned schema migrations, tracked in a schema_migrations table — the Alembic / diesel-migrations counterpart. A Migration carries an integer version plus its up and down statement lists; a Migrator applies pending versions in ascending order, skips ones already applied (so re-running is a no-op), rolls back to a target version in descending order, and reports the current version.

    let migrations : 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, migrations) |> ignore // applies 1 then 2; returns how many ran
    let _ = m.current_version(sess) // 2
    m.down_to(sess, migrations, 1) |> ignore // rolls back 2, leaving 1

    #Injection safety

    let evil = "'; DROP TABLE users; --"
    let (sql, params) = @moonorm.select("users").eq("name", @moondb.Text(evil)).build()
    // sql = "SELECT * FROM users WHERE name = ?" ← the attack string is NOT in the SQL
    // params = [Text("'; DROP TABLE users; --")] ← it's a bound parameter

    Verified across all backends (wasm, wasm-gc, js, native) in CI, 0 warnings under --deny-warn.

    #Roadmap (transliterating SQLAlchemy)

    select / insert / update / delete with WHERE / ORDER BY / LIMIT / OFFSET, inner/left JOIN, GROUP BY / HAVING, aggregate columns (count() / raw()), WITH CTEs, IN (subquery) and IN (values) predicates, window functions (OVER (PARTITION BY … ORDER BY …)), dialect-aware RETURNING and upsert (ON CONFLICT … DO UPDATE / ON DUPLICATE KEY UPDATE), and a Table descriptor are all here — and they execute against any @moondb.Driver via an explicit Session (add / fetch / modify / remove / commit / rollback, optimistic-locked updates, isolation-level and read-only transactions via begin_with, depth-tracked nested savepoints via begin_nested, plus declarative models — hand-built or from_fields from moonctl-generated metadata — eager-loaded relationships with N+1-avoiding batch loading, and versioned migrations). Connection pooling lives in moondb as Pool[D]: idle reuse, a size ceiling, a pre_ping health probe that evicts dead connections on acquire, max_lifetime recycling, a non-blocking try_acquire, and close-all. The native SQLite backend is moon-sqlite; Postgres and MySQL/MariaDB backends live in moon-postgres and moon-mysql. Still to come: Alembic-style schema-diff migrations reflected from a live database, and multiple-inheritance / polymorphic mapping.

    #License

    Apache-2.0.

    Value

    using @Lfan-ke/moondb { type Value }

    The bound-value type is moondb's dialect-neutral [@moondb.Value], re-exported here so the builder's public surface reads as Value (and its constructors — Int, Text, Null, …) stay unqualified) while the actual type is the one every moondb driver speaks. Values are always carried out-of-band as parameters, never interpolated into the SQL string — that is what makes the builder injection-safe.

    LostUpdate

    pub(all) suberror LostUpdate {
    LostUpdate(String)
    }

    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.
    impl Show for LostUpdate

    Column

    pub(all) struct 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?
    }

    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.

    Column::ddl

    fn Column::ddl(self : Column, inline_pk? : Bool) -> String

    Render this column as a single CREATE TABLE column definition, e.g. "user_id INTEGER NOT NULL REFERENCES users(id)". A PRIMARY KEY column emits that keyword; a non-nullable column emits NOT NULL; a references target emits a REFERENCES table(column) clause. Column and table names are trusted identifiers declared in code, never bound values.

    Column::ddl_for

    fn Column::ddl_for(self : Column, dialect : Dialect, inline_pk? : Bool) -> String

    A column definition rendered for a specific Dialect. An autoincrement primary key takes each engine's idiom: SQLite INTEGER PRIMARY KEY AUTOINCREMENT, PostgreSQL SERIAL PRIMARY KEY, MySQL INT AUTO_INCREMENT PRIMARY KEY. Otherwise it is sql_for plus the same constraint clauses as ddl.

    ColumnDiff

    pub(all) enum ColumnDiff {
    Added(name~ : String, col_type~ : ColumnType)
    Removed(String)
    TypeChanged(name~ : String, from~ : ColumnType, to~ : ColumnType)
    } derive(Eq)

    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

    pub(all) enum ColumnType {
    IntType
    TextType
    RealType
    BlobType
    BoolType
    VarcharType(Int)
    NumericType
    DateTimeType
    DateType
    TimeType
    UuidType
    JsonType
    } derive(Eq)

    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.

    ColumnType::sql_for

    fn ColumnType::sql_for(self : ColumnType, dialect : Dialect) -> String

    The declared type keyword for a ColumnType in a given SQL Dialect. SQLite is the affinity-based default; PostgreSQL and MySQL render their native spellings (BOOLEAN/BYTEA/DOUBLE PRECISION/UUID/JSONB on PG; INT/TINYINT(1)/DOUBLE/JSON on MySQL). This is the per-dialect counterpart of ColumnType::sql.

    Cond

    type Cond

    Conflict

    type Conflict

    The ON CONFLICT / ON DUPLICATE KEY clause of an upsert: the conflict-target columns (the unique key that may collide — used by SQLite/PostgreSQL, implicit on MySQL), the DO UPDATE assignments, and whether the intent is DO NOTHING.

    Cte

    type Cte

    A common table expression: name AS (subquery). CTEs render in a single leading WITH clause and their bound parameters come first in the final params list, matching their leading position in the SQL text. When recursive, the whole clause is a WITH RECURSIVE (the keyword modifies the clause, not the entry, so one recursive CTE turns the leading WITH recursive for all of them).

    Delete

    pub struct Delete {
    table : String
    conds : Array[Cond]
    }

    A DELETE statement builder.

    Delete::build

    fn Delete::build(self : Delete) -> (String, Array[
    Value
    ])

    Render (sql, params).

    Delete::where_

    fn Delete::where_(self : Delete, col : String, op : String, val :
    Value
    ) -> Delete

    Narrow the delete with col op ?. Conditions are ANDed together.

    Dialect

    pub(all) enum Dialect {
    Sqlite
    Postgres
    Mysql
    } derive(Eq)

    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.

    Field

    pub struct Field[T] {
    column : Column
    encode : (T) ->
    Value

    }

    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]).

    Field::name

    fn[T] Field::name(self : Field[T]) -> String

    The name of the column this field maps to.

    InCond

    type InCond

    A col IN (?, ?, …) / col NOT IN (?, …) predicate over an explicit value list (as opposed to SubCond, whose right-hand side is a subquery). Each value binds as its own placeholder, so a set-membership filter — the workhorse of an N+1-avoiding batch load, WHERE fk IN (all the parent keys) — stays fully parameterised.

    Insert

    pub struct Insert {
    table : String
    cols : Array[String]
    vals : Array[
    Value
    ]
    extra_rows : Array[Array[
    Value
    ]]
    conflict : Conflict?
    returning : Array[String]
    }

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

    Insert::build

    fn Insert::build(self : Insert) -> (String, Array[
    Value
    ])

    Render to (sql, params) for the SQLite/PostgreSQL dialect. Equivalent to build_for(Sqlite); kept as the no-argument default because plain inserts and the ?-placeholder convention target this form.

    Insert::build_for

    fn Insert::build_for(self : Insert, dialect : Dialect) -> (String, Array[
    Value
    ])

    Render to (sql, params) for dialect, with ? placeholders for every bound value. Bound values appear in params in SQL-text order: the inserted VALUES first, then any DO UPDATE SET col = ? values. Upsert renders as ON CONFLICT on SQLite/PostgreSQL and ON DUPLICATE KEY UPDATE on MySQL; RETURNING is emitted on SQLite/PostgreSQL and omitted on MySQL.

    Insert::do_nothing

    fn Insert::do_nothing(self : Insert) -> Insert

    On a conflict, keep the existing row and change nothing (ON CONFLICT DONOTHING). MySQL has no such form, so build_for(Mysql) renders a no-op self-assignment instead.

    Insert::do_update

    fn Insert::do_update(self : Insert, col : String, val :
    Value
    ) -> Insert

    On a conflict, set col to a bound value (col = ?). The value binds as a parameter after the inserted values, so an upsert stays injection-safe.

    Insert::do_update_excluded

    fn Insert::do_update_excluded(self : Insert, col : String) -> Insert

    On a conflict, set col to the value the failed insert tried to write (excluded.col on SQLite/PostgreSQL, VALUES(col) on MySQL). This is the "overwrite with the incoming row" upsert.

    Insert::on_conflict

    fn Insert::on_conflict(self : Insert, targets : Array[String]) -> Insert

    Turn this INSERT into an upsert keyed on targets — the columns of the unique index that may collide (SQLAlchemy's index_elements). Chain do_update / do_update_excluded to say what to change on a collision, or leave it and call do_nothing. SQLite and PostgreSQL name the conflict target explicitly; build_for(Mysql) ignores it (MySQL infers the key), so pass it regardless and the right dialect uses it.

    Insert::returning

    fn Insert::returning(self : Insert, col : String) -> Insert

    Return col from each inserted (or upserted) row. On SQLite and PostgreSQL this appends RETURNING, so a caller reads the server-assigned id or a default/trigger-computed value back in the same round trip instead of a second SELECT. Repeatable. build_for(Mysql) drops it — MySQL has no RETURNING.

    Insert::returning_all

    fn Insert::returning_all(self : Insert) -> Insert

    Return every column of each inserted row (RETURNING *). See returning.

    Insert::set

    fn Insert::set(self : Insert, col : String, val :
    Value
    ) -> Insert

    Set a column to a value.

    Insert::values

    fn Insert::values(self : Insert, row : Array[
    Value
    ]) -> Insert

    Append another row to a multi-row INSERT, in the same column order the first row established via set (SQLAlchemy's multi-values / executemany). Every value binds as its own placeholder, so a bulk insert stays injection-safe; each row must supply one value per column.

    IsolationLevel

    pub(all) enum IsolationLevel {
    ReadUncommitted
    ReadCommitted
    RepeatableRead
    Serializable
    } derive(Eq)

    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.

    IsolationLevel::keyword

    fn IsolationLevel::keyword(self : IsolationLevel) -> String

    The ANSI keyword for an isolation level ("READ COMMITTED", "SERIALIZABLE", …).

    JoinClause

    type JoinClause

    A single JOIN clause: its keyword (JOIN / LEFT JOIN), the joined table, and the raw ON predicate (an identifier-level expression, never a bound value).

    LockClause

    pub(all) struct LockClause {
    mode : LockMode
    of : Array[String]
    nowait : Bool
    skip_locked : Bool
    } derive(Eq,
    Debug
    )

    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

    pub(all) enum LockMode {
    ForUpdate
    ForShare
    } derive(Eq,
    Debug
    )

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

    ManyToMany

    pub struct ManyToMany[S, T] {
    target : Model[T]
    junction : String
    source_fk : String
    target_fk : String
    target_key : String
    source_key : (S) ->
    Value

    }

    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.

    ManyToMany::query

    fn[S, T] ManyToMany::query(self : ManyToMany[S, T], source : S) -> Select

    The Select resolving this relationship for a source: the target rows joined to the junction on junction.target_fk = target.target_key, filtered to junction.source_fk = ? with the source key bound (never spliced). The target columns are projected qualified (target.col) so a same-named junction column never makes the projection ambiguous. Session::load_many runs it and maps.

    Migration

    pub(all) struct Migration {
    version : Int
    name : String
    up : Array[String]
    down : Array[String]
    }

    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

    pub struct Migrator {
    table : String
    }

    Tracks and applies migrations against a schema_migrations-style bookkeeping table (its name is configurable for coexistence with other tools). Build one with Migrator::new.

    Migrator::applied_versions

    fn Migrator::applied_versions(self : Migrator, sess : Session) -> Array[Int] raise
    DbError

    Every applied version, ascending.

    Migrator::current_version

    fn Migrator::current_version(self : Migrator, sess : Session) -> Int raise
    DbError

    The highest applied version, or 0 when nothing has been applied yet.

    Migrator::down_to

    fn Migrator::down_to(self : Migrator, sess : Session, migrations : Array[Migration], target : Int) -> Int raise
    DbError

    Roll back every applied migration whose version is greater than target, in descending version order, running each one's down statements and removing its bookkeeping row. Returns how many were rolled back. down_to(0) unwinds everything.

    Migrator::ensure_table

    fn Migrator::ensure_table(self : Migrator, sess : Session) -> Unit raise
    DbError

    Create the bookkeeping table if it is absent. Idempotent, so it is safe to call before every up/down.

    Migrator::new

    fn Migrator::new(table? : String) -> Migrator raise
    DbError

    A migrator recording applied versions in table (default schema_migrations). The name must be a bare SQL identifier — it is interpolated as an identifier, never bound — so a non-identifier is refused up front.

    Migrator::up

    fn Migrator::up(self : Migrator, sess : Session, migrations : Array[Migration]) -> Int raise
    DbError

    Apply every pending migration (those whose version is not yet recorded), in ascending version order, running each one's up statements and recording it. Returns how many were applied. Already-applied versions are skipped, so this is safe to run repeatedly (it converges the schema to the latest version).

    Model

    pub struct Model[T] {
    table : String
    columns : Array[Column]
    from_row : (
    Row
    ) -> T raise
    DbError

    to_columns : (T) -> Array[(String,
    Value
    )]
    }

    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.

    Model::column_names

    fn[T] Model::column_names(self : Model[T]) -> Array[String]

    The declared column names, in order.

    Model::create_table_sql

    fn[T] Model::create_table_sql(self : Model[T], if_not_exists? : Bool) -> String

    Render CREATE TABLE DDL for this model from its declared columns. With if_not_exists=true the statement is idempotent (CREATE TABLE IF NOT EXISTS). This is the explicit counterpart of SQLAlchemy's metadata.create_all().

    Model::create_table_sql_for

    fn[T] Model::create_table_sql_for(self : Model[T], dialect : Dialect, if_not_exists? : Bool) -> String

    CREATE TABLE DDL for this model rendered for a specific Dialect — the multi-dialect counterpart of create_table_sql. Uses Column::ddl_for, keeps a composite primary key as a table-level constraint, and appends ENGINE=InnoDB on MySQL.

    Model::from_fields

    fn[T] Model::from_fields(table : String, fields : Array[Field[T]], from_row : (
    Row
    ) -> T raise
    DbError
    ) -> Model[T]

    Build a Model[T] from a list of [Field]s and a single row decoder — the declarative path that removes the hand-written to_columns. The columns and the binding projection are both derived from fields: columns is each field's declared column, and to_columns maps a record to (name, field.encode(record)) pairs. You still supply from_row, because reconstructing a T from a fetched row needs the record constructor, and MoonBit — lacking reflection — cannot synthesise it; that one closure is the irreducible core of the mapping.

    This is the constructor moonctl-generated model metadata targets: the generator emits the [Field] array and the from_row decoder, and from_fields turns them into a live Model. Written by hand it reads just as directly:

    Model::from_fields( "hero", [ field("id", IntType, primary_key=true, h => Int(h.id)), field("name", TextType, nullable=false, h => Text(h.name)), ], r => { id: r.int(0), name: r.text(1) }, )

    Model::insert_of

    fn[T] Model::insert_of(self : Model[T], record : T) -> Insert

    Build an Insert that persists record, binding the pairs from to_columns. Values travel as ? placeholders exactly like the rest of the builder.

    Model::map_row

    fn[T] Model::map_row(self : Model[T], row :
    Row
    ) -> T raise
    DbError

    Decode a single fetched Row into a record via the model's from_row.

    Model::map_rows

    fn[T] Model::map_rows(self : Model[T], rows : Array[
    Row
    ]) -> Array[T] raise
    DbError

    Decode a whole result set into records, preserving row order.

    Model::new

    fn[T] Model::new(table : String, columns : Array[Column], from_row : (
    Row
    ) -> T raise
    DbError
    , to_columns : (T) -> Array[(String,
    Value
    )]) -> Model[T]

    Define a Model[T]. from_row should read the record's fields off the @moondb.Row (via Row::text / Row::int / Row::by_name …) — those accessors raise @moondb.DbError on a type or index mismatch, so from_row raises too and a decode bug surfaces at the call site. to_columns should list the column/value pairs to persist (typically every column except an autoincrement primary key). Write the closures in arrow form so the raise effect is inferred, e.g. (r) => \{ id: r.int(0), name: r.text(1) \}.

    Model::select

    fn[T] Model::select(self : Model[T]) -> Select

    A Select over this model's table with every declared column projected explicitly (so the projection order is fixed and known to from_row). Add where_ / order_by / limit to it as usual, then run it with Session::fetch_as to get mapped records back.

    Model::table_descriptor

    fn[T] Model::table_descriptor(self : Model[T]) -> Table

    The Table descriptor for this model (name + column names), for interop with the plain builder API.

    Order

    pub(all) enum Order {
    Asc
    Desc
    } derive(Eq)

    Sort direction for an ORDER BY term.

    Predicate

    pub(all) enum Predicate {
    Cmp(col~ : String, op~ : String, val~ :
    Value
    )
    InList(col~ : String, vals~ : Array[
    Value
    ], negated~ : Bool)
    Exists(sub~ : Select, negated~ : Bool)
    And(Array[Predicate])
    Or(Array[Predicate])
    Not(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.

    Predicate::build

    fn Predicate::build(self : Predicate) -> (String, Array[
    Value
    ])

    Render a predicate to its SQL fragment and the values it binds, in text order. A multi-child And/Or is wrapped in parentheses so nesting composes correctly.

    Relation

    pub struct Relation[S, T] {
    target : Model[T]
    key_column : String
    source_key : (S) ->
    Value

    to_many : Bool
    }

    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.

    Relation::batch_query

    fn[S, T] Relation::batch_query(self : Relation[S, T], sources : Array[S]) -> Select

    One SELECT covering every source's related rows — key_column IN (…) over the sources' keys — so loading a relation for a list costs one query instead of one per row. source_value is what buckets the results back.

    Relation::query

    fn[S, T] Relation::query(self : Relation[S, T], source : S) -> Select

    The Select that resolves this relationship for a given source record: the target model's projection filtered to key_column = ?, with the source-derived value bound (never spliced), so eager loading is injection-safe like every other query. Session::load runs this and maps the rows.

    Relation::source_value

    fn[S, T] Relation::source_value(self : Relation[S, T], source : S) ->
    Value

    The single Select that resolves this relationship for many sources at once: the target model's projection filtered to key_column IN (k1, k2, …), where the keys are the distinct values extracted from sources. This is the query behind an N+1-avoiding batch load — one round trip fetches the related rows of every source, instead of one query(source) per source. Every key binds as its own placeholder. With no sources the result filters on the empty set (IN (NULL)), matching nothing; Session::load_batch short-circuits that case without a query. The source-side key this relationship extracts from a source record — the value the target's key_column is matched against. Exposed so a batch load can bucket fetched rows back to their sources.

    RowStream

    pub struct RowStream[T] {
    cursor : &
    Cursor

    model : Model[T]
    }

    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).

    RowStream::close

    fn[T] RowStream::close(self : RowStream[T]) -> Unit

    Release the underlying cursor early.

    RowStream::next

    fn[T] RowStream::next(self : RowStream[T]) -> T? raise
    DbError

    The next decoded record, or None once the result is exhausted.

    Savepoint

    pub struct Savepoint {
    sess : Session
    name : String
    depth : Int
    finished : Bool
    }

    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.

    Savepoint::commit

    fn Savepoint::commit(self : Savepoint) -> Unit raise
    DbError

    SQLAlchemy spells "keep the nested work" as commit(); this is the alias for [release].

    Savepoint::depth

    fn Savepoint::depth(self : Savepoint) -> Int

    This savepoint's nesting depth (1 for the outermost begin_nested, 2 for one opened inside it, and so on).

    Savepoint::name

    fn Savepoint::name(self : Savepoint) -> String

    The generated SQL name of this savepoint.

    Savepoint::release

    fn Savepoint::release(self : Savepoint) -> Unit raise
    DbError

    Release this savepoint (RELEASE SAVEPOINT <name>), keeping the work done since it opened and merging it into the enclosing transaction. Terminal and idempotent. The SQLAlchemy nested-transaction commit().

    Savepoint::rollback

    fn Savepoint::rollback(self : Savepoint) -> Unit raise
    DbError

    Roll back and close this savepoint, discarding everything done since it opened while leaving the enclosing transaction intact. It issues ROLLBACK TO SAVEPOINT followed by RELEASE SAVEPOINT, so — like SQLAlchemy's nested rollback() — the savepoint is terminal afterwards and the depth drops. Terminal and idempotent.

    Select

    pub struct Select {
    table : String
    ctes : Array[Cte]
    cols : Array[String]
    joins : Array[JoinClause]
    conds : Array[Cond]
    in_conds : Array[InCond]
    sub_conds : Array[SubCond]
    preds : Array[Predicate]
    groups : Array[String]
    havings : Array[Cond]
    orders : Array[(String, Order)]
    set_ops : Array[(String, Select)]
    distinct_ : Bool
    limit_ : Int?
    offset_ : Int?
    lock_ : LockClause?
    }

    A SELECT statement builder.

    Select::build

    fn Select::build(self : Select) -> (String, Array[
    Value
    ])

    Render to (sql, params), with ? placeholders for every bound value. Bound values appear in the params list in the same left-to-right order they occur in the SQL text: CTE subqueries first (the leading WITH), then the WHERE scalar predicates, then the WHERE IN (values) predicates, then the WHERE subquery predicates, then HAVING.

    Select::column

    fn Select::column(self : Select, col : String) -> Select

    Add a projected column (no columns selects *).

    Select::count

    fn Select::count(self : Select) -> Select

    Project COUNT(*) — the most common aggregate. Sugar for raw("COUNT(*)").

    Select::distinct

    fn Select::distinct(self : Select) -> Select

    Deduplicate the result set: SELECT DISTINCT. Applies to this query's own projection (each operand of a set operation carries its own DISTINCT).

    Select::eq

    fn Select::eq(self : Select, col : String, val :
    Value
    ) -> Select

    Shorthand for where_(col, "=", val).

    Select::except_

    fn Select::except_(self : Select, other : Select) -> Select

    EXCEPT — rows in this query but not in other (SQL's set difference).

    Select::for_update

    fn Select::for_update(self : Select, read? : Bool, of? : Array[String], nowait? : Bool, skip_locked? : Bool) -> Select

    Take a row lock on the selected rows (SQLAlchemy Select.with_for_update): FOR UPDATE by default, or FOR SHARE when read; restrict it to certain tables with of; make it fail immediately on a locked row with nowait, or skip locked rows with skip_locked. The clause is emitted after ORDER BY / LIMIT / OFFSET, the standard SQL position (SQLite, which has no row locks, ignores it).

    Select::group_by

    fn Select::group_by(self : Select, col : String) -> Select

    Add a GROUP BY column (repeatable; columns are emitted in call order).

    Select::having

    fn Select::having(self : Select, col : String, op : String, val :
    Value
    ) -> Select

    Add a col op ? predicate to the HAVING clause (ANDed with the rest). The value is bound as a ? placeholder exactly like where_, so aggregate filters stay injection-safe.

    Select::intersect

    fn Select::intersect(self : Select, other : Select) -> Select

    INTERSECT — rows present in both queries.

    Select::join

    fn Select::join(self : Select, table : String, on : String) -> Select

    Add an inner JOIN table ON <on>. The on predicate is rendered verbatim (it references columns, not bound values), so pass only trusted identifiers.

    Select::left_join

    fn Select::left_join(self : Select, table : String, on : String) -> Select

    Add a LEFT JOIN table ON <on>. See join for the on contract.

    Select::limit

    fn Select::limit(self : Select, n : Int) -> Select

    Cap how many rows come back.

    Select::offset

    fn Select::offset(self : Select, n : Int) -> Select

    Skip the first n rows. Paired with limit this is a page.

    Select::order_by

    fn Select::order_by(self : Select, col : String, ord : Order) -> Select

    Order the result by a column. Repeated calls order by each in turn.

    Select::raw

    fn Select::raw(self : Select, expr : String) -> Select

    Add a raw column expression (e.g. an aggregate like "SUM(price)" or a qualified "users.id"). Identical machinery to column; named for intent so call sites read as "this is an expression, not a plain column name".

    Select::union

    fn Select::union(self : Select, other : Select) -> Select

    UNION this query with other — the combined rows with duplicates removed. A trailing ORDER BY / LIMIT on either query binds to the whole compound (standard SQL); to order or limit a single operand, wrap it as a subquery in its FROM.

    Select::union_all

    fn Select::union_all(self : Select, other : Select) -> Select

    UNION ALL — the combined rows keeping duplicates.

    Select::where_

    fn Select::where_(self : Select, col : String, op : String, val :
    Value
    ) -> Select

    Add a col op ? predicate (ANDed with the rest).

    Select::where_exists

    fn Select::where_exists(self : Select, sub : Select) -> Select

    Add an EXISTS (subquery) predicate (ANDed with the rest) — true when the subquery returns any row (SQLAlchemy's exists()). The subquery's bound values splice in at its position, so it stays injection-safe.

    Select::where_in

    fn Select::where_in(self : Select, col : String, sub : Select) -> Select

    Add a col IN (subquery) predicate (ANDed with the rest). The subquery's bound values splice in at the predicate's position, so a correlated or filtering subquery stays injection-safe exactly like a scalar where_.

    Select::where_in_values

    fn Select::where_in_values(self : Select, col : String, vals : Array[
    Value
    ]) -> Select

    Add a col IN (?, ?, …) predicate over an explicit list of values (ANDed with the rest). Every value binds as its own placeholder, never spliced — this is the set-membership filter a batch load issues to fetch the related rows of many parents in one round trip (WHERE fk IN (key1, key2, …)) instead of one query per parent. An empty vals renders the always-false 1 = 0 — "in the empty set" matches nothing — so a batch load with no keys returns no rows rather than erroring.

    Select::where_not_exists

    fn Select::where_not_exists(self : Select, sub : Select) -> Select

    Add a NOT EXISTS (subquery) predicate (ANDed with the rest). See where_exists.

    Select::where_not_in

    fn Select::where_not_in(self : Select, col : String, sub : Select) -> Select

    Add a col NOT IN (subquery) predicate (ANDed with the rest). See where_in.

    Select::where_not_in_values

    fn Select::where_not_in_values(self : Select, col : String, vals : Array[
    Value
    ]) -> Select

    Add a col NOT IN (?, ?, …) predicate over an explicit list of values (ANDed with the rest). See where_in_values; an empty vals renders the always-true 1 = 1 — "not in the empty set" matches every row.

    Select::where_pred

    fn Select::where_pred(self : Select, pred : Predicate) -> Select

    Add a boolean Predicate tree to the WHERE — the parameter-safe AND/OR/NOT grouping the flat where_/eq cannot express, e.g. where_pred(Or([Cmp(col="a", op="=", val=Int(1)), Cmp(col="b", op="=", val=Int(2))])). Predicates are ANDed with the other clauses, so mixing eq(...) and where_pred(...) composes as one conjunction.

    Select::window

    fn Select::window(self : Select, expr : String, partition_by? : Array[String], order_by? : Array[(String, Order)], as_? : String) -> Select

    Project a window function: <expr> OVER (PARTITION BY … ORDER BY …), optionally aliased. Window functions compute over a frame of rows without collapsing them (ROW_NUMBER(), RANK(), a running SUM(x)), so this adds one more projected column rather than grouping. expr is the function call ("ROW_NUMBER()", "SUM(amount)"), partition_by splits the rows into independent windows, and order_by ranks within each. All three are identifier-level SQL (they name columns and functions, never bound values), so they render verbatim like raw and carry no parameters. An empty partition_by and order_by yields the whole result as one unordered window, <expr> OVER ().

    Select::with_cte

    fn Select::with_cte(self : Select, name : String, sub : Select) -> Select

    Attach a common table expression: WITH name AS (sub). Repeatable — several CTEs render comma-separated in one leading WITH. The subquery is parameterised like any other statement; its bound values lead the final params list because the WITH clause leads the SQL text. name is a trusted identifier declared in code, never a bound value.

    Select::with_recursive

    fn Select::with_recursive(self : Select, name : String, anchor : Select, step : Select, all? : Bool) -> Select

    Attach a recursive common table expression (SQLAlchemy's select(...).cte(recursive=True)): WITH RECURSIVE name AS (anchor UNION [ALL] step). anchor is the base term and step the term that refers back to name in its own FROM — the CTE name is a trusted identifier referenceable in step and in this query's own FROM, so the usual shape is select(name).with_recursive(name, anchor, step). all picks UNION ALL (keep duplicates, the common tree/graph walk) over the default UNION. The two terms compose through the existing union / union_all builders, so their bound values thread through in text order like any CTE.

    Session

    pub struct Session {
    driver : &
    Driver

    sp_depth : Int
    sp_seq : Int
    }

    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.

    Session::add

    Build and run an Insert, returning the affected-row count and new rowid. This renders the SQLite/PostgreSQL form (ON CONFLICT upserts, RETURNING); for a MySQL upsert build the dialect form explicitly — session.execute(stmt.build_for(Mysql)).

    Session::all

    fn[T] Session::all(self : Session, model : Model[T]) -> Array[T] raise
    DbError

    Fetch every row of a model's table, decoded into records (`SELECT FROM

    Session::begin

    fn Session::begin(self : Session) -> Unit raise
    DbError

    Begin an explicit transaction. Delegates to the driver's transaction bracket (@moondb.Driver::begin) rather than emitting BEGIN as text, so a driver that manages transactions out-of-band (or maps them to savepoints) stays in control.

    Session::begin_nested

    fn Session::begin_nested(self : Session) -> Savepoint raise
    DbError

    Open a nested transaction — SAVEPOINT <auto-name> — and return a handle that tracks its name and depth, so callers never spell (or risk mis-spelling) a savepoint name. Nesting begin_nested inside another increases the depth; each handle's release/rollback brings it back down. This is the ergonomic, depth-tracked counterpart of the raw savepoint/rollback_to/release trio, mirroring SQLAlchemy's begin_nested().

    The generated name (moonorm_sp_<n>, n strictly increasing per session) is a bare identifier by construction, so it is injection-safe without a runtime check.

    Session::begin_with

    fn Session::begin_with(self : Session, opts : TxOptions, dialect? : Dialect) -> Unit raise
    DbError

    Begin a transaction with explicit isolation / read-only options. It brackets the driver's begin with the SET TRANSACTION (or PRAGMA) statements opts renders for dialect, ordered so each backend accepts them: before BEGIN for MySQL and SQLite, after BEGIN for PostgreSQL (see TxOptions::to_sql). dialect defaults to Postgres, whose SET TRANSACTION ISOLATION LEVEL spelling is the ANSI-standard one. Commit or roll back with the usual commit/rollback.

    Session::close

    fn Session::close(self : Session) -> Unit

    Close the session's underlying connection.

    Session::commit

    fn Session::commit(self : Session) -> Unit raise
    DbError

    Commit the current transaction.

    Session::create_table

    fn[T] Session::create_table(self : Session, model : Model[T], if_not_exists? : Bool) ->
    ExecResult
    raise
    DbError

    Create the table backing model from its declared columns (see Model::create_table_sql). With if_not_exists=true the DDL is idempotent.

    Session::execute

    Execute a raw statement with bound params.

    Session::fetch

    Build and run a Select, returning the matched rows.

    Session::fetch_as

    fn[T] Session::fetch_as(self : Session, model : Model[T], stmt : Select) -> Array[T] raise
    DbError

    Run a Select and decode every row into a T via model. Use it with a Model::select() (optionally refined with where_/order_by) to get records rather than raw Rows back.

    Session::insert_record

    fn[T] Session::insert_record(self : Session, model : Model[T], record : T) ->
    ExecResult
    raise
    DbError

    Insert a mapped record through its model, binding the model's to_columns pairs. Returns the affected-row count and new rowid.

    Session::load

    fn[S, T] Session::load(self : Session, source : S, rel : Relation[S, T]) -> Array[T] raise
    DbError

    Eagerly load the related records of a 1:N relationship: given a source parent, run the relationship's query and decode every matching child into a record. This is the explicit stand-in for SQLAlchemy's transparent lazy load (parent.children firing a SELECT on attribute access) — MoonBit has no attribute interception, so the load is a call, exactly as Diesel and GORM require. The match value is bound, so eager loading stays injection-safe.

    Session::load_batch

    fn[S, T] Session::load_batch(self : Session, sources : Array[S], rel : Relation[S, T]) -> Array[Array[T]] raise
    DbError

    Eagerly load a 1:N relationship for many sources in a single query, avoiding the N+1 problem. Naively calling load in a loop fires one SELECT per source; this instead issues one SELECT … WHERE key_column IN (all the source keys), then buckets the fetched children back to their parents in memory. The return is index-aligned with sources: result[i] is the list of children whose foreign key matches sources[i]'s key (empty if none). This is the explicit equivalent of SQLAlchemy's selectinload eager strategy.

    Children are matched to a source by comparing the child row's key_column value against the source's extracted key, so a source with no children yields an empty list and children are shared correctly when two sources happen to hold the same key. An empty sources returns an empty array without touching the database.

    Session::load_many

    fn[S, T] Session::load_many(self : Session, source : S, rel : ManyToMany[S, T]) -> Array[T] raise
    DbError

    Eagerly load the related records of a many-to-many relationship: run the junction JOIN for source and decode every matching target row. The N:M counterpart of Session::load, and like it the source key is bound, so eager loading stays injection-safe.

    Session::load_one

    fn[S, T] Session::load_one(self : Session, source : S, rel : Relation[S, T]) -> T? raise
    DbError

    Eagerly load the single related record of a N:1 relationship (e.g. a child's parent): the first matching row decoded into a record, or None if there is no match. Like load, the match value is bound.

    Session::load_one_batch

    fn[S, T] Session::load_one_batch(self : Session, sources : Array[S], rel : Relation[S, T]) -> Array[T?] raise
    DbError

    Eagerly load a N:1 relationship for many sources in a single query — the to-one counterpart of load_batch. One SELECT … WHERE key_column IN (…) fetches every distinct parent; the return is index-aligned with sources, result[i] being sources[i]'s parent (Some) or None when there is no match. Avoids the N+1 that a per-source load_one loop would incur.

    Session::modify

    Build and run an Update, returning the affected-row count.

    Session::modify_versioned

    fn Session::modify_versioned(self : Session, stmt : Update, what? : String) ->
    ExecResult
    raise

    Run a versioned UPDATE under optimistic concurrency control. stmt must carry the version predicate in its WHERE (e.g. where_(version_col, "=",expected)) and bump the version column in its SET. If the update matches no row the version has moved on since it was read — a lost update — and this raises LostUpdate instead of silently doing nothing, the faithful equivalent of SQLAlchemy's version_id_col StaleDataError. The whole statement is parameterised, so both the version guard and the new values stay bound.

    Session::new

    Wrap a connected driver in a session.

    Session::query

    Run a raw query with bound params and return its rows.

    Session::query_stream

    Stream a raw query's rows through a [@moondb.Cursor] instead of materialising them — the bounded-memory counterpart to [query] (SQLAlchemy's stream_results). This seam is synchronous, so only a synchronous driver with incremental fetch streams lazily here: moon-sqlite steps its prepared statement row by row. The async wire drivers cannot stream through this sync seam (their cursor is async) — a Session over Postgres raises (the async-wall façade) and over MySQL falls back to a materialised cursor; for true lazy Postgres/MySQL streaming call PgConn/MysqlConn::query_stream directly under an event loop.

    Session::release

    Release a savepoint (RELEASE SAVEPOINT <name>), merging its work into the enclosing transaction (or savepoint). Rejects a non-identifier name.

    Session::remove

    Build and run a Delete, returning the affected-row count.

    Session::rollback

    fn Session::rollback(self : Session) -> Unit raise
    DbError

    Roll back the current transaction.

    Session::rollback_to

    Roll back to a savepoint (ROLLBACK TO SAVEPOINT <name>), undoing everything done since it was opened while keeping the savepoint (and the outer transaction) active. Rejects a non-identifier name with QueryError.

    Session::savepoint

    Open a nested transaction with SAVEPOINT <name>. Savepoints nest arbitrarily, so this is the faithful equivalent of SQLAlchemy's Session.begin_nested(): work done after the savepoint can be undone with rollback_to(name) without discarding the enclosing transaction, and finalised with release(name).

    name must be a bare identifier (it is interpolated as an identifier, never a bound value); a non-identifier raises QueryError rather than reaching the database. The statement is issued through execute, so a backend that supports SQL savepoints (SQLite, Postgres) runs it verbatim.

    Session::savepoint_depth

    fn Session::savepoint_depth(self : Session) -> Int

    How many nested savepoints opened via begin_nested are currently active.

    Session::stream

    Build and stream a Select, yielding raw rows through a cursor.

    Session::stream_as

    fn[T] Session::stream_as(self : Session, model : Model[T], stmt : Select) -> RowStream[T] raise
    DbError

    Build and stream a Select, decoding each row into a T on demand — the streaming counterpart to [fetch_as].

    Session::with_pool

    fn[D :
    Driver
    , T] Session::with_pool(pool :
    Pool
    [D], f : (Session) -> T raise) -> T raise

    Run f with a Session on a connection borrowed from pool, returning the connection to the pool when f finishes or raises. The unit of work owns its transaction: commit or roll back inside f before it returns, since the connection is reused as-is.

    SubCond

    type SubCond

    A col IN (subquery) / col NOT IN (subquery) predicate: the subquery is a full Select, whose bound parameters splice in at the point the predicate is rendered. Keeping subquery predicates separate from the scalar conds lets the builder concatenate the two params streams in the exact order the SQL text emits them (scalar predicates first, then subqueries), so binding stays index-aligned.

    Table

    pub(all) struct Table {
    name : String
    columns : Array[String]
    }

    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.

    Table::select

    fn Table::select(self : Table) -> Select

    Start a SELECT over this table with all of its declared columns projected (an empty columns yields SELECT *, matching select(name)).

    TxOptions

    pub(all) struct TxOptions {
    isolation : IsolationLevel?
    read_only : Bool
    }

    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.

    TxOptions::default

    fn TxOptions::default() -> TxOptions

    Transaction options with everything defaulted off (backend default isolation, read-write). Set what you need: { ..TxOptions::default(), isolation:Some(Serializable) }.

    TxOptions::to_sql

    fn TxOptions::to_sql(self : TxOptions, dialect : Dialect) -> Array[String]

    Render these options into the statements that impose them for dialect. On PostgreSQL and MySQL that is SET TRANSACTION ISOLATION LEVEL <level> and/or SETTRANSACTION READ ONLY; on SQLite, which has no such statement, it is PRAGMA read_uncommitted = 1 for the one weaker level it supports (every other level is SQLite's default serializable behaviour, so nothing is emitted) and PRAGMA query_only = 1 for read-only. An empty result means the backend's defaults already satisfy the options.

    Update

    pub struct Update {
    table : String
    cols : Array[String]
    vals : Array[
    Value
    ]
    conds : Array[Cond]
    }

    An UPDATE statement builder.

    Update::build

    fn Update::build(self : Update) -> (String, Array[
    Value
    ])

    Render (sql, params). The SET values come first and the WHERE values after, in the order the placeholders appear, which is the order every driver binds in.

    Update::set

    fn Update::set(self : Update, col : String, val :
    Value
    ) -> Update

    Assign one column. Repeated calls set each, in the order given.

    Update::where_

    fn Update::where_(self : Update, col : String, op : String, val :
    Value
    ) -> Update

    Narrow the update with col op ?. Conditions are ANDed together.

    belongs_to

    fn[S, T] belongs_to(target : Model[T], target_key : String, child_key : (S) ->
    Value
    ) -> 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.

    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.

    delete

    fn delete(table : String) -> Delete

    Start a DELETE from table.

    diff_schema

    fn diff_schema(declared : Array[Column], reflected : Array[Column]) -> Array[ColumnDiff]

    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.

    field

    fn[T] field(name : String, col_type : ColumnType, encode : (T) ->
    Value
    , 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 pulls this field's [Value] out of a record. The column knobs (primary_key, nullable, references) mirror [column]; encode is the one-liner that binds the attribute, e.g. field("id", IntType, primary_key=true, h =>Int(h.id)).

    has_many

    fn[S, T] has_many(target : Model[T], foreign_key : String, parent_key : (S) ->
    Value
    ) -> 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. Load it with Session::load, which returns every matching child record.

    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.

    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] many_to_many(target : Model[T], junction~ : String, source_fk~ : String, target_fk~ : String, target_key~ : String, source_key~ : (S) ->
    Value
    ) -> 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.

    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

    fn reflect_columns_mysql(rows : Array[
    Row
    ]) -> Array[Column] raise
    DbError

    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_table

    fn reflect_table(driver : &
    Driver
    , table : String) -> Array[Column] raise
    DbError

    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

    fn reflect_table_for(driver : &
    Driver
    , table : String, dialect : Dialect) -> Array[Column] raise
    DbError

    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.

    select

    fn select(table : String) -> Select

    Start a SELECT over table.

    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.