moonorm

    An ORM and SQL toolkit for MoonBit.

    sqlalchemy
    orm
    sql
    query-builder
    database
    moonbit
    Download zip
    Version
    0.11.3
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    178

    #moonorm

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

    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, ManyToManyquery, moondb
    moonorm/sessionSession, row streams, transactions and savepoints, the unit of work, pooled sessionsschema, query, moondb
    moonorm/migrateMigration, Migrator, ColumnDiff, schema reflection and diffingsession, schema, query
    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

    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.

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

    #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= 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.3 -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.

    #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
    }

    #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
    }

    #Migrations

    Versioned migrations, tracked in a schema_migrations table. 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.

    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
    }

    To go the other way — from what a live database has to what your model declares — reflect the table and diff it:

    pub fn plan_for(db : &@moondb.Driver, declared : Array[@moonorm.Column]) -> Array[@moonorm.ColumnDiff] raise @moondb.DbError {
    let reflected = @moonorm.reflect_table(db, "accounts")
    @moonorm.diff_schema(declared, reflected, dialect=Postgres)
    // Added(name~, col_type~) for a column only the model declares,
    // Removed(name) for one only the database has,
    // TypeChanged(name~, from~, to~) when the two disagree on the type.
    }

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

    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.

    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.

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

    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.

    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 schema_migrations-style bookkeeping table (its name is configurable for coexistence with other tools). 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.

    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.

    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.

    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.

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

    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.

    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 are expected to normalise their column types down to these seven 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); dates and times ride here as ISO-8601 text until a dedicated temporal case lands (see the README roadmap).
    • Blob — an opaque byte string (BLOB / BYTEA).

    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.

    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.

    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.

    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.

    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.

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

    select

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

    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.

    Source Files

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io