moonorm

An ORM / SQL toolkit for MoonBit — a parameterized, injection-safe query builder, the way SQLAlchemy Core is for Python. Bound values become ? placeholders, never spliced into the SQL string.

CItestsGitHublicense
$moon add Lfan-ke/moonorm

The contract at a glance

let (sql, params) = select("users")
  .column("id").eq("age", Int(18)).order_by("name", Asc).limit(10).build()
// "SELECT id FROM users WHERE age = ? ORDER BY name ASC LIMIT 10", [Int(18)]

// bound values are ? placeholders + a params list => injection-safe by construction.

§Query builder

select / insert / update / delete builders with WHERE / ORDER BY / LIMIT, JOIN, GROUP BY / HAVING, WITH common table expressions, and IN (subquery) predicates. Every builder renders (sql, params) - values are always ? placeholders, never spliced in, and subquery/CTE params keep SQL text order.

item
using @moondb

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.

enum
enum Order

Sort direction for an ORDER BY term.

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

struct
struct Select

A SELECT statement builder.

fn
fn select(table : String) -> Select

Start a SELECT over table.

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

Add a projected column (no columns selects *).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

fn
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 col IN (NULL), which matches nothing (the SQL-correct reading of "in the empty set"); callers with no keys should skip the query outright.

fn
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 col NOT IN (NULL).

fn
fn Select::order_by(self : Select, col : String, ord : Order) -> Select
fn
fn Select::limit(self : Select, n : Int) -> Select
fn
fn Select::offset(self : Select, n : Int) -> Select
fn
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.

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

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

struct
struct Insert

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

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

Set a column to a value.

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

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

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

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

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

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

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

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

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

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

struct
struct Update

An UPDATE statement builder.

fn
fn update(table : String) -> Update
fn
fn Update::set(self : Update, col : String, val : Value) -> Update
fn
fn Update::where_( self : Update, col : String, op : String, val : Value) -> Update
fn
fn Update::build(self : Update) -> (String, Array[Value])
struct
struct Delete

A DELETE statement builder.

fn
fn delete(table : String) -> Delete
fn
fn Delete::where_( self : Delete, col : String, op : String, val : Value) -> Delete
fn
fn Delete::build(self : Delete) -> (String, Array[Value])

§Execution layer

An explicit Session over any @moondb.Driver: it runs built statements and returns typed @moondb.Row values, with depth-tracked nested transactions (begin_nested returns a named SAVEPOINT handle), isolation-level / read-only transactions (begin_with renders SET TRANSACTION per dialect), optimistic-locked updates (modify_versioned raises LostUpdate on a stale version), and eager relationship loading including N+1-avoiding batch loads (load_batch / load_one_batch). moonorm owns no driver contract - the seam is @moondb, so a Session drives moon-sqlite, a Postgres backend, or MockDriver.

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

fn
fn Session::new(driver : &@moondb.Driver) -> Session

Wrap a connected driver in a session.

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

How many nested savepoints opened via begin_nested are currently active.

fn
fn Session::execute( self : Session, sql : String, params : Array[Value]) -> @moondb.ExecResult raise @moondb.DbError

Execute a raw statement with bound params.

fn
fn Session::query( self : Session, sql : String, params : Array[Value]) -> Array[@moondb.Row] raise @moondb.DbError

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

fn
fn Session::add( self : Session, stmt : Insert) -> @moondb.ExecResult raise @moondb.DbError

Build and run an Insert, returning the affected-row count and new rowid.

fn
fn Session::fetch( self : Session, stmt : Select) -> Array[@moondb.Row] raise @moondb.DbError

Build and run a Select, returning the matched rows.

fn
fn Session::modify( self : Session, stmt : Update) -> @moondb.ExecResult raise @moondb.DbError

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

fn
fn Session::remove( self : Session, stmt : Delete) -> @moondb.ExecResult raise @moondb.DbError

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

item
suberror 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.

item
impl Show for LostUpdate with fn output(self : LostUpdate, logger : &Logger) -> Unit
fn
fn Session::modify_versioned( self : Session, stmt : Update, what? : String = "row") -> @moondb.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.

fn
fn Session::begin(self : Session) -> Unit raise @moondb.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.

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

Commit the current transaction.

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

Roll back the current transaction.

fn
fn Session::savepoint( self : Session, name : String) -> @moondb.ExecResult raise @moondb.DbError

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.

fn
fn Session::rollback_to( self : Session, name : String) -> @moondb.ExecResult raise @moondb.DbError

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.

fn
fn Session::release( self : Session, name : String) -> @moondb.ExecResult raise @moondb.DbError

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

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

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

The generated SQL name of this savepoint.

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

fn
fn Session::begin_nested(self : Session) -> Savepoint raise @moondb.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.

fn
fn Savepoint::release(self : Savepoint) -> Unit raise @moondb.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().

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

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

fn
fn Savepoint::rollback(self : Savepoint) -> Unit raise @moondb.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.

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

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

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

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

fn
fn TxOptions::default() -> TxOptions

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

fn
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 SET TRANSACTION 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.

fn
fn Session::begin_with( self : Session, opts : TxOptions, dialect? : Dialect = Postgres) -> Unit raise @moondb.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.

item
fn[T] Session::create_table( self : Session, model : Model[T], if_not_exists? : Bool = false) -> @moondb.ExecResult raise @moondb.DbError

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

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

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

item
fn[T] Session::fetch_as( self : Session, model : Model[T], stmt : Select) -> Array[T] raise @moondb.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.

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

Fetch every row of a model's table, decoded into records (SELECT <cols> FROM <table>).

item
fn[S, T] Session::load( self : Session, source : S, rel : Relation[S, T]) -> Array[T] raise @moondb.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.

item
fn[S, T] Session::load_one( self : Session, source : S, rel : Relation[S, T]) -> T? raise @moondb.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.

item
fn[S, T] Session::load_batch( self : Session, sources : Array[S], rel : Relation[S, T]) -> Array[Array[T]] raise @moondb.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.

item
fn[S, T] Session::load_one_batch( self : Session, sources : Array[S], rel : Relation[S, T]) -> Array[T?] raise @moondb.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.

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

Close the session's underlying connection.

§Models & relationships

The declarative model layer: typed columns and Fields, a Model[T] Row<->record mapper (built by hand or via from_fields from a field list, the shape moonctl generates), CREATE TABLE DDL, and foreign-key 1:N / N:1 relationships loaded explicitly via session.load / session.load_one and batched via load_batch (the faithful attribute-interception equivalent, as Diesel and GORM also make explicit).

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

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

fn
fn column( name : String, col_type : ColumnType, primary_key? : Bool = false, nullable? : Bool = true, references? : (String, String)? = None) -> 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.

fn
fn Column::ddl(self : Column) -> 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.

struct
struct Field[T]

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

item
fn[T] field( name : String, col_type : ColumnType, encode : (T) -> Value, primary_key? : Bool = false, nullable? : Bool = true, references? : (String, String)? = None) -> 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)).

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

The name of the column this field maps to.

struct
struct Model[T]

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.

item
fn[T] Model::new( table : String, columns : Array[Column], from_row : (@moondb.Row) -> T raise @moondb.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) \}.

item
fn[T] Model::from_fields( table : String, fields : Array[Field[T]], from_row : (@moondb.Row) -> T raise @moondb.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) }, ) ``

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

The declared column names, in order.

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

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

item
fn[T] Model::create_table_sql( self : Model[T], if_not_exists? : Bool = false) -> 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().

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

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

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

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

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

struct
struct Relation[S, T]

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.

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

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

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

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

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

§Schema migrations

Versioned up/down migrations tracked in a schema_migrations table: a Migrator applies pending versions ascending, is idempotent on re-run, rolls back to a target version descending, and reports the current version. The Alembic / diesel-migrations counterpart, run over any @moondb.Driver.

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

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

fn
fn Migrator::new( table? : String = "schema_migrations") -> Migrator raise @moondb.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.

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

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

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

Every applied version, ascending.

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

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

fn
fn Migrator::up( self : Migrator, sess : Session, migrations : Array[Migration]) -> Int raise @moondb.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).

fn
fn Migrator::down_to( self : Migrator, sess : Session, migrations : Array[Migration], target : Int) -> Int raise @moondb.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.