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 moonbitstack/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.

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

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

struct
struct Select

A SELECT statement builder.

enum
enum LockMode

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

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

fn
fn select(table : String) -> Select

Start a SELECT over table.

fn
fn Select::for_update( self : Select, read? : Bool = false, of? : Array[String] = [], nowait? : Bool = false, skip_locked? : Bool = false) -> 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).

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

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

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

UNION ALL — the combined rows keeping duplicates.

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

INTERSECT — rows present in both queries.

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

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

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

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::with_recursive( self : Select, name : String, anchor : Select, step : Select, all? : Bool = false) -> 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.

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

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

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

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 the always-false 1 = 0 — "in the empty set" matches nothing — so a batch load with no keys returns no rows rather than erroring.

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 the always-true 1 = 1 — "not in the empty set" matches every row.

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

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

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

Cap how many rows come back.

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

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

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

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.

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

Set a column to a value.

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

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

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

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

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

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

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

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

struct
struct Delete

A DELETE statement builder.

fn
fn delete(table : String) -> Delete

Start a DELETE from table.

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

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

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

Render (sql, params).

§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 moonsqlite, 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 **now**, returning the affected-row count and new rowid. This is the statement-level form: it goes to the driver on this call and never touches the unit of work. To queue a record instead and let flush order and emit it, use add_record. 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)).

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::query_stream( self : Session, sql : String, params : Array[@moondb.Value]) -> &@moondb.Cursor raise @moondb.DbError

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: moonsqlite 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.

fn
fn Session::stream( self : Session, stmt : Select) -> &@moondb.Cursor raise @moondb.DbError

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

struct
struct RowStream[T]

A typed streaming cursor: each [next](RowStream::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).

item
fn[T] RowStream::next(self : RowStream[T]) -> T? raise @moondb.DbError

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

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

Release the underlying cursor early.

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

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

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

Prints the row and the version that was expected, which is what a caller needs to decide whether to reload and retry.

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

Flush the unit of work, then commit the current transaction. Queued records reach the database here if nothing has flushed them yet, which is why a caller can add and commit without naming flush at all. With an empty queue this is the bare driver commit.

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

Roll back the current transaction, abandoning whatever is still queued and emptying the identity map. The records held there describe rows the rollback has just undone, so keeping them would hand out state the database no longer has.

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 **now**, binding the model's to_columns pairs. Returns the affected-row count and new rowid. Nothing is queued and the identity map is not consulted; add_record is the unit-of-work counterpart that defers to flush.

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, dropping anything still queued and every record the identity map holds.

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

§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, autoincrement? : Bool = false, nullable? : Bool = true, unique? : Bool = false, default? : String? = None, checks? : Array[String] = [], references? : (String, String)? = None, on_delete? : String? = None, on_update? : 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, inline_pk? : Bool = true) -> 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, autoincrement? : Bool = false, nullable? : Bool = true, unique? : Bool = false, default? : String? = None, checks? : Array[String] = [], references? : (String, String)? = None, on_delete? : String? = None, on_update? : 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::pk_columns(self : Model[T]) -> Array[String]

The declared primary-key column names, in declaration order. Empty when the model declares no key at all — the unit of work needs one to address a row, and Session::get / Session::delete_record say so rather than guessing.

item
fn[T] Model::pk_of(self : Model[T], record : T) -> Array[Value]

A record's primary-key values in pk_columns order, read off the same to_columns projection an INSERT binds — the only route to them, since MoonBit cannot look up an attribute by name. A projection that omits a key column — the usual shape for an autoincrement id the server assigns — stops the scan there, so the result is shorter than pk_columns. That length mismatch is how a session tells a record it can address from one whose key does not exist yet.

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

The distinct tables this model's foreign keys point at, itself excluded. A flush orders inserts so every table named here is written before this one, and deletes the other way round.

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

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.

struct
struct ManyToMany[S, T]

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.

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

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

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

fn
fn Column::ddl_for( self : Column, dialect : Dialect, inline_pk? : Bool = true) -> 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.

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

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

fn
fn reflect_columns( rows : Array[@moondb.Row]) -> Array[Column] raise @moondb.DbError

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.

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

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

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

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

fn
fn index_ddl( name : String, table : String, columns : Array[String], unique? : Bool = false) -> 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.

fn
fn reflect_columns_pg( rows : Array[@moondb.Row]) -> Array[Column] raise @moondb.DbError

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

fn
fn reflect_columns_mysql( rows : Array[@moondb.Row]) -> Array[Column] raise @moondb.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).

fn
fn reflect_table_for( driver : &@moondb.Driver, table : String, dialect : Dialect) -> Array[Column] raise @moondb.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.

§Pooled sessions

Session::with_pool borrows a connection for the duration of one call and returns it with defer, so it comes back on every path including a raise or a cancellation.

item
fn[D : @moondb.Driver, T] Session::with_pool( pool : @moondb.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.

§Driver seam

The @moondb contract every backend implements - execute, query, ping, close, and the transaction verbs. moonorm reaches nothing past this seam, which is what lets one Session drive SQLite, Postgres, MySQL or MockDriver.

item
pub(open) trait Driver

The contract every database backend implements and every query layer is written against — the seam moondb exists to define. It is the MoonBit transliteration of Go's database/sql/driver.Conn/Execer/Queryer and Python's DB-API 2.0 Connection/Cursor, reduced to the smallest set of operations a relational backend must offer. ## The binding contract execute and query both take (sql, params). sql carries **positional placeholders** and params supplies one [Value] per placeholder, in order. A driver is expected to bind values out-of-band rather than interpolate them into the SQL text. A driver that cannot — the MySQL text protocol has no out-of-band binding until prepared statements land — has to escape for the server's actual sql_mode, and must say so in its own documentation. The placeholder token itself is dialect-specific and chosen by the driver (SQLite/MySQL use ?, PostgreSQL uses $1, $2, …); a query layer that emits SQL for a given backend uses that backend's token. moondb fixes the *calling convention* (ordered params array), not the spelling. * execute runs a statement that returns no rows (INSERT/UPDATE/DELETE/DDL) and reports an [ExecResult]. * query runs a statement that returns rows and materialises every [Row]. * begin / commit / rollback bracket an explicit transaction. Nesting (savepoints), isolation levels, and read-only hints are driver concerns layered on top; the base contract is the three flat operations. * close releases the connection. It does not raise: closing is best-effort and idempotent, matching Go's io.Closer discipline for connections. This trait is pub(open) so out-of-tree drivers (moonsqlite, moonpostgres, moonmysql, …) can implement it. A future prepared-Stmt handle and a streaming Rows cursor are noted in the README roadmap; v0.1 fixes exactly the operations below so the contract everything pins to stays small and stable.

§Connection pool

A pool over any Driver: max_size ceiling, max_lifetime retirement, pre_ping health probing, and an acquire budget. close_all is the graceful half of a shutdown - nothing in flight is cut off, and in_use_count is what a drain waits on.

struct
struct Pool[D]

A fixed-ceiling connection pool over any [Driver]. Opening a database connection is expensive — a TCP handshake and auth round trip for a networked backend — so a server hands out a small set of connections and reuses them instead of opening one per request. This is the moondb counterpart of Go's sql.DB connection pool and SQLAlchemy's QueuePool. The pool is generic in the concrete driver D rather than holding &Driver trait objects, so acquire gives back the real driver type and a caller keeps access to backend-specific methods (SQLite's exec_script, a driver's prepared-statement handle). new takes a make factory that opens one fresh connection; the pool calls it only when no idle connection is available and the open count is still below max_size. Beyond plain reuse, the pool keeps a connection healthy over its lifetime, matching the knobs both database/sql and SQLAlchemy's QueuePool expose: * **pre-ping** (pre_ping) — before an idle connection is handed back it is probed with [Driver::ping]; a connection that fails the probe is closed and skipped, so a caller never receives a connection the backend has already dropped. This is SQLAlchemy's pool_pre_ping. * **max lifetime** (max_lifetime_ms) — a connection older than this is retired on acquire and replaced with a fresh one, so long-lived pools recycle connections a load balancer or the server may have aged out. This is Go's SetConnMaxLifetime. Age is measured with an injected clock, exactly as database/sql swaps nowFunc in tests; the default clock disables the check. * **acquire timeout** (acquire_timeout_ms) — the wait budget an exhausted acquire is allowed. See the note on synchrony below. It is synchronous and single-threaded by design — moondb's base contract is sync, and MoonBit's pure backends have no shared-memory threads — so acquire never truly blocks: nothing else can return a connection while one call waits. A request that finds the pool exhausted therefore fails immediately rather than sleeping; acquire_timeout_ms is carried into that failure's message and is the budget a driver running on an async runtime honours when it layers real waiting on top. Use [try_acquire] for the non-raising "give me one only if free" path. The reuse, ceiling, health, and lifecycle bookkeeping all live here.

item
fn[D] Pool::new( make : () -> D raise DbError, max_size? : Int = 10, max_lifetime_ms? : Int64 = 0, pre_ping? : Bool = false, acquire_timeout_ms? : Int64 = 0, clock? : () -> Int64 = fn()

Build a pool whose connections come from make. * max_size caps how many connections may be open at once (in use plus idle); it defaults to 10, the ceiling Go's database/sql uses out of the box, and must be positive. * max_lifetime_ms retires a connection older than this many milliseconds on acquire (0, the default, means no age limit). Enforcing it requires a real clock; with the default clock every connection reads as age 0. * pre_ping turns on the [Driver::ping] health probe before an idle connection is reused (off by default). * acquire_timeout_ms records the wait budget for an exhausted acquire; 0 (the default) means "fail immediately". In this synchronous pool the wait is always degenerate (see the type doc), so the value serves the error message and an async layer above. * clock returns a monotonically non-decreasing millisecond reading and exists so max_lifetime is testable and portable; it defaults to a constant 0, which disables age-based retirement.

item
fn[D] Pool::idle_count(self : Pool[D]) -> Int

How many connections are currently idle (checked in and reusable).

item
fn[D] Pool::in_use_count(self : Pool[D]) -> Int

How many connections are checked out right now. A shutdown that wants to wait for borrowed connections to come home polls this after close_all: the close empties the idle set, so what is left is exactly what is still in use.

item
fn[D] Pool::open_count(self : Pool[D]) -> Int

How many connections the pool has open in total: those checked out plus those sitting idle. Never exceeds max_size.

item
fn[D] Pool::max_size(self : Pool[D]) -> Int

The pool's ceiling on simultaneously open connections.

item
fn[D] Pool::max_lifetime_ms(self : Pool[D]) -> Int64

The configured maximum connection lifetime in milliseconds (0 = unlimited).

item
fn[D] Pool::pre_ping(self : Pool[D]) -> Bool

Whether the pre-ping health probe is enabled.

item
fn[D] Pool::acquire_timeout_ms(self : Pool[D]) -> Int64

The configured acquire wait budget in milliseconds (0 = fail immediately).

item
fn[D] Pool::is_closed(self : Pool[D]) -> Bool

Whether [close_all] has been called.

item
fn[D : Driver] Pool::acquire(self : Pool[D]) -> D raise DbError

Take a connection: reuse an idle one if the pool has a healthy, unexpired one (the common path, avoiding a fresh handshake), otherwise open a new one through make as long as the open count is below max_size. An idle connection past max_lifetime_ms, or one that fails the [Driver::ping] probe when pre_ping is on, is closed and dropped rather than handed back; the pool then tries the next idle connection or opens a fresh one. Raises QueryError if the pool is exhausted (all max_size connections are checked out) or [Closed] if it has been closed. The caller must return the connection with [release] — or use [with_conn], which does so even on error.

item
fn[D : Driver] Pool::try_acquire(self : Pool[D]) -> D? raise DbError

Take a connection only if one is immediately available — an idle one or fresh headroom under max_size — returning None instead of raising when the pool is exhausted. This is the non-blocking counterpart of [acquire]: it never reports the exhaustion case as an error, so a caller can choose to shed load or retry. Health and lifetime eviction apply exactly as in acquire; a genuine failure while opening a fresh connection (the make factory raising) still propagates.

item
fn[D : Driver] Pool::release(self : Pool[D], conn : D) -> Unit

Return a connection to the idle set so a later [acquire] can reuse it, preserving the birth time it was opened with so max_lifetime keeps counting from creation rather than resetting on every checkout. A connection released back into a closed pool is closed immediately rather than pooled, so no handle outlives close_all.

item
fn[D : Driver, R] Pool::with_conn( self : Pool[D], f : (D) -> R raise DbError) -> R raise DbError

Run f with a pooled connection, returning it afterwards whether f returns or raises. This is the leak-proof way to use the pool: the release happens on every path, so a raising query never strands a connection checked out.

item
fn[D : Driver] Pool::close_all(self : Pool[D]) -> Unit

Close every idle connection and mark the pool closed. Connections still checked out are not touched — each is closed as it is [release]d back. After this, acquire raises Closed. Idempotent. This is the graceful half of a shutdown: nothing in flight is cut off. To wait for the rest, poll [in_use_count] until it reaches zero — this pool is synchronous, so the waiting belongs to whatever async layer owns it.

§Rows and cursors

The typed row a query returns and the cursor that streams them, plus the Value union every driver marshals to and from.

struct
struct Row

One row of a result set: the column names in projection order alongside the decoded [Value] for each. columns and values are the same length and are index-aligned — columns[i] names values[i]. This is the shape drivers hand back from [Driver::query] and the shape a query layer decodes. Read cells either positionally ([get]/[int]/…) or by column name ([by_name]/[int_by]/…). The typed accessors *raise* on a type or lookup mismatch rather than returning a sentinel, so a decode bug surfaces as a DbError at the call site instead of silently reading a zero value.

fn
fn Row::width(self : Row) -> Int

The number of columns in the row.

fn
fn Row::get(self : Row, idx : Int) -> Value raise DbError

The raw [Value] at column idx (0-based, projection order). Raises QueryError if idx is out of range.

fn
fn Row::by_name(self : Row, name : String) -> Value?

The [Value] of the column named name, or None if the row has no such column. Names match exactly as the driver reported them (case-sensitive).

fn
fn Row::index_of(self : Row, name : String) -> Int raise DbError

Resolve a column name to its index, raising QueryError if absent. The positional accessors are the primitives; the _by-name accessors resolve through this.

fn
fn Row::is_null(self : Row, idx : Int) -> Bool raise DbError

Whether column idx is SQL NULL. Raises QueryError if idx is out of range. Call this before a typed accessor when a column is nullable, since the typed accessors treat NULL as a type mismatch.

fn
fn Row::is_null_by(self : Row, name : String) -> Bool raise DbError

Whether the column named name is SQL NULL. Raises QueryError if the column is absent.

fn
fn Row::bool(self : Row, idx : Int) -> Bool raise DbError

Read column idx as a Bool. Raises TypeError unless the cell is Bool.

fn
fn Row::int(self : Row, idx : Int) -> Int raise DbError

Read column idx as an Int, narrowing an Int64 to its low 32 bits. Raises TypeError unless the cell is Int or Int64.

fn
fn Row::int64(self : Row, idx : Int) -> Int64 raise DbError

Read column idx as an Int64, widening an Int. Raises TypeError unless the cell is Int or Int64.

fn
fn Row::double(self : Row, idx : Int) -> Double raise DbError

Read column idx as a Double, widening an integer. Raises TypeError unless the cell is Double, Int, or Int64.

fn
fn Row::text(self : Row, idx : Int) -> String raise DbError

Read column idx as text. Raises TypeError unless the cell is Text.

fn
fn Row::blob(self : Row, idx : Int) -> Bytes raise DbError

Read column idx as an opaque blob. Raises TypeError unless the cell is Blob.

fn
fn Row::bool_by(self : Row, name : String) -> Bool raise DbError

Read the column named name as a Bool.

fn
fn Row::int_by(self : Row, name : String) -> Int raise DbError

Read the column named name as an Int.

fn
fn Row::int64_by(self : Row, name : String) -> Int64 raise DbError

Read the column named name as an Int64.

fn
fn Row::double_by(self : Row, name : String) -> Double raise DbError

Read the column named name as a Double.

fn
fn Row::text_by(self : Row, name : String) -> String raise DbError

Read the column named name as text.

fn
fn Row::blob_by(self : Row, name : String) -> Bytes raise DbError

Read the column named name as an opaque blob.

struct
struct ExecResult

The outcome of a non-query statement (INSERT/UPDATE/DELETE/DDL): how many rows it changed and the id of the last inserted row. rows_affected is 64-bit to hold bulk DML counts; last_insert_id is the backend's auto-increment / rowid value and is backend-defined (0 when the statement produced none). This mirrors Go's sql.Result (RowsAffected / LastInsertId) and DB-API's cursor.rowcount / lastrowid.

item
pub(open) trait Cursor

A forward-only cursor over a query's rows — the streaming counterpart to [Driver::query]. Where query materialises the whole result, a cursor yields one [Row] at a time, so a large result set is consumed in bounded memory (SQLAlchemy's yield_per / server-side cursors, Go's sql.Rows, Python DB-API's fetchone). next advances and returns the next row, or None once the result is exhausted; close releases the cursor early (a driver holding a server-side cursor or prepared statement frees it here). A driver with real incremental fetch — moonsqlite steps its prepared statement, an async driver reads rows off the wire on demand — returns a live cursor; the default [Driver::query_stream] falls back to an [ArrayCursor] over a materialised result, which honours the same interface without the memory bound. A live cursor holds a resource (a prepared statement, a busy connection). MoonBit has no finalizer to reclaim it, so a driver-backed cursor MUST be fully drained (next until None) or closed — abandoning it leaks the statement or leaves the connection mid-result. The materialised [ArrayCursor] holds nothing and is safe to drop. pub(open) so out-of-tree drivers implement it.

struct
struct ArrayCursor

A [Cursor] over an already-materialised Array[Row]. The fallback the default [Driver::query_stream] hands back for any driver that has not overridden it: it preserves the streaming *interface* (callers pull rows one at a time) even though the rows were fetched up front. The mock driver and the reference query layer test against it.

fn
fn ArrayCursor::new(rows : Array[Row]) -> ArrayCursor

A cursor positioned before the first of rows.

item
impl Cursor for ArrayCursor with fn next(self : ArrayCursor) -> Row? raise DbError

Hand back the next row, or None once the array is spent.

item
impl Cursor for ArrayCursor with fn close(self : ArrayCursor) -> Unit

Nothing to release — the rows are already in memory.

fn
fn drain(cursor : &Cursor) -> Array[Row] raise DbError

Drain a cursor into an array — the inverse of streaming, for callers that do want every row (and for asserting a cursor yields exactly what query would).

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

fn
fn Value::kind(self : Value) -> String

The name of a value's variant ("Null", "Int", "Blob", …). Used to build legible [DbError::TypeError] messages when a typed accessor is asked for the wrong shape.

fn
fn Value::is_null(self : Value) -> Bool

Whether this value is SQL NULL.

item
impl Show for Value with fn output(self : Value, logger : &Logger) -> Unit

Render a Value for logs and test output. Scalars print their payload; Text is quoted; Blob shows its length rather than raw bytes.

§Errors

DbError, the one error type the seam raises, so a caller distinguishes a closed pool from a query failure without knowing which backend it is on.

item
suberror DbError

A database-layer failure. Every fallible operation on the interface raises this one error type, so a query layer catches a single kind. The cases mirror the coarse failure classes both Go's database/sql and Python's DB-API 2.0 draw a line around, kept deliberately few so drivers can classify without a taxonomy: * ConnectError — opening, attaching, or handshaking a connection failed, or the connection dropped mid-flight. * QueryError — the backend rejected a statement: a prepare/step/bind error, a constraint violation, or a protocol error. Carries the backend's message. * TypeError — a [Row] typed accessor was asked to read a column as a type it does not hold (e.g. int on a Text), or a column index/name that does not exist in the row. * Closed — the connection or statement was used after close. Declared pub(all) so out-of-tree drivers (moonpostgres, moonmysql, …) that live in their own packages can *construct and raise* these cases — a plain pub suberror would let them catch a DbError but not build one.

fn
fn DbError::to_string(self : DbError) -> String

A one-line rendering of the error, e.g. QueryError: no such table: hero.

item
impl Show for DbError with fn output(self : DbError, logger : &Logger) -> Unit

Prints the failure with the detail the driver gave, so a log line says which statement failed rather than only that one did.

§MockDriver

An in-memory Driver that records what it was asked to run, so the ORM's own behaviour is testable on every backend without a database.

struct
struct MockDriver

A dependency-free, in-memory reference [Driver]. It exists for two reasons: to *prove the interface is implementable* end to end, and to give query layers built on moondb (moonorm and friends) a real test double they can run against with no database and no native backend — it compiles on every target. It is a deliberately naive echo store, **not** a SQL engine: it does not parse sql. Each execute appends one [Row] built from the bound params (columns named c0, c1, …) and hands back an [ExecResult] with a monotonically increasing last_insert_id; each query returns every stored row. What it *does* model faithfully is the transaction bracket: begin snapshots the store, rollback restores it, commit keeps the changes — so a test can assert real rollback semantics against it.

fn
fn MockDriver::new() -> MockDriver

A fresh, empty mock connection.

fn
fn MockDriver::row_count(self : MockDriver) -> Int

How many rows the store currently holds. A test-facing helper, not part of the [Driver] contract.

fn
fn MockDriver::is_closed(self : MockDriver) -> Bool

Whether the connection has been closed.

fn
fn MockDriver::in_transaction(self : MockDriver) -> Bool

Whether a transaction is currently open (at least one un-committed begin).

item
impl Driver for MockDriver with fn execute( self : MockDriver, _sql : String, params : Array[Value]) -> ExecResult raise DbError

Record the statement's parameters as a row and report one row affected, with an incrementing last-insert id. The SQL text is ignored on purpose: this driver exists to prove the ORM issued a statement, not to interpret it.

item
impl Driver for MockDriver with fn query( self : MockDriver, _sql : String, _params : Array[Value]) -> Array[Row] raise DbError

Return the recorded rows.

item
impl Driver for MockDriver with fn begin(self : MockDriver) -> Unit raise DbError

Open a transaction, or a SAVEPOINT when one is already open, so nesting depth is observable to a test.

item
impl Driver for MockDriver with fn commit(self : MockDriver) -> Unit raise DbError

Close the innermost transaction or release the innermost savepoint.

item
impl Driver for MockDriver with fn rollback(self : MockDriver) -> Unit raise DbError

Undo back to the innermost savepoint, or abandon the transaction.

item
impl Driver for MockDriver with fn close(self : MockDriver) -> Unit

Mark the connection closed; every later call raises Closed.

§SQLite driver

The Driver over the vendored SQLite amalgamation — the only C in the repository, kept behind the same seam every other backend meets.

struct
struct SqliteDriver

A live connection to a SQLite database. Open it with SqliteDriver::open; it implements @moondb.Driver, so a moondb query layer can drive it directly. The opaque sqlite3* is carried as its pointer bits in handle.

fn
fn SqliteDriver::open(path : String) -> SqliteDriver raise @moondb.DbError

Open (or create) the database at path. Use ":memory:" for a private in-memory database. Raises @moondb.ConnectError if the file cannot be opened.

fn
fn SqliteDriver::is_closed(self : SqliteDriver) -> Bool

Whether close has been called. Once closed, every statement raises @moondb.Closed rather than touching the freed sqlite3*.

fn
fn SqliteDriver::exec_script( self : SqliteDriver, script : String) -> Unit raise @moondb.DbError

Run a semicolon-separated script (e.g. a schema DDL block) in one call. Unlike execute, this uses SQLite's multi-statement exec path and binds no parameters. Raises @moondb.QueryError with SQLite's message on failure.

item
impl @moondb.Driver for SqliteDriver with fn execute(self, sql, params)
item
impl @moondb.Driver for SqliteDriver with fn query(self, sql, params)
item
impl @moondb.Cursor for SqliteCursor with fn next(self : SqliteCursor) -> @moondb.Row? raise @moondb.DbError
item
impl @moondb.Cursor for SqliteCursor with fn close(self : SqliteCursor) -> Unit
item
impl @moondb.Driver for SqliteDriver with fn query_stream(self, sql, params)

Stream a query's rows through a live [@moondb.Cursor] backed by a prepared statement — the incremental override of the materialising default.

item
impl @moondb.Driver for SqliteDriver with fn begin(self)
item
impl @moondb.Driver for SqliteDriver with fn commit(self)
item
impl @moondb.Driver for SqliteDriver with fn rollback(self)
item
impl @moondb.Driver for SqliteDriver with fn close(self)

§Postgres driver

The Postgres wire protocol in MoonBit: the startup and authentication handshakes (SCRAM-SHA-256 and md5), the simple and extended query paths, $n placeholders, and the type mapping to @moondb.Value.

struct
struct PgDriver

A connection descriptor that carries the parameters needed to reach a PostgreSQL backend and conforms to the synchronous [@moondb.Driver] seam. ## The async wall (why this is a façade) @moondb.Driver's methods are **synchronous** (fn execute(...) raise DbError), matching an FFI-backed driver like moonsqlite whose C calls block. PostgreSQL, however, is spoken over TCP, and MoonBit's only socket stack (moonbitlang/async) is **async-only**: an async fn cannot be called from a synchronous one, and the runtime exposes no public "run-this-async-thunk-to-completion" bridge (with_event_loop lives in an import-blocked internal package). A synchronous method therefore cannot perform a PostgreSQL round trip. The faithful, working driver is the **async [PgConn]** (see conn.mbt), which mirrors asyncpg: PgConn::connect then .execute / .query / .begin / .commit / .rollback / .close, all async, used inside an event loop (async test / async fn main). The CI integration suite drives a real PostgreSQL through exactly that API. PgDriver exists to *demonstrate the seam* and to give moondb-based code a stable target: it implements every @moondb.Driver method. The row-touching methods raise a precise ConnectError explaining that the round trip must run through PgConn under an event loop; close is a no-op. When moondb grows an async Driver variant (or MoonBit ships a blocking socket / a public event-loop entry), this façade becomes a thin adapter over PgConn with no behavioural change to callers.

fn
fn PgDriver::new( host~ : String, port? : Int = 5432, user~ : String, password? : String = "", database~ : String) -> PgDriver

Build a driver descriptor. Does not connect — the async wall means the actual connection is opened by [PgConn::connect] inside an event loop.

item
async fn PgDriver::connect(self : PgDriver) -> PgConn raise DbError

Open the async connection this descriptor points at. This is the intended entry point: call it inside an event loop and use the returned [PgConn].

item
impl @moondb.Driver for PgDriver with fn execute( _self : PgDriver, _sql : String, _params : Array[Value]) -> ExecResult raise DbError
item
impl @moondb.Driver for PgDriver with fn query( _self : PgDriver, _sql : String, _params : Array[Value]) -> Array[Row] raise DbError
item
impl @moondb.Driver for PgDriver with fn begin(_self : PgDriver) -> Unit raise DbError
item
impl @moondb.Driver for PgDriver with fn commit(_self : PgDriver) -> Unit raise DbError
item
impl @moondb.Driver for PgDriver with fn rollback(_self : PgDriver) -> Unit raise DbError
item
impl @moondb.Driver for PgDriver with fn close(_self : PgDriver) -> Unit
struct
struct PgConn

A live connection to a PostgreSQL backend, already past the startup handshake and sitting at ReadyForQuery. Runs the v3 frontend/backend protocol over a single @socket.Tcp. Not safe for concurrent use by multiple tasks: one statement must complete (drain to ReadyForQuery) before the next begins, as the wire protocol is a strict request/response pipeline per connection.

item
async fn PgConn::connect( host : String, port : Int, user : String, password : String, database : String) -> PgConn raise DbError

Open a connection and complete the startup handshake: send StartupMessage, satisfy the authentication request (trust/AuthenticationOk, cleartext, MD5, or SASL/SCRAM-SHA-256), and drain server parameters up to the first ReadyForQuery. host may be a hostname or a literal IP; port is typically 5432. Raises ConnectError on any transport or handshake failure, including an unsupported auth method.

item
async fn PgConn::query( self : PgConn, sql : String, params : Array[Value]) -> Array[Row] raise DbError

Run a row-returning statement and materialise every row. Uses the simple Query protocol when params is empty, and the extended Parse/Bind/Execute protocol (with ?$n translation and out-of-band text-format binding) when parameters are supplied.

item
async fn PgConn::execute( self : PgConn, sql : String, params : Array[Value]) -> ExecResult raise DbError

Run a non-row statement (INSERT/UPDATE/DELETE/DDL) and report rows changed. last_insert_id is 0: the simple/extended protocols do not surface a generated key without a RETURNING clause (roadmap).

struct
struct PgRowStream

A forward-only cursor that reads a query's rows off the wire on demand instead of buffering them — the streaming counterpart to [query], for results too large to materialise (asyncpg's cursor, SQLAlchemy's stream_results). It is bound to its PgConn, which is a single-statement pipeline: the stream must be drained (next returns None) or [closed](PgRowStream::close) before another statement runs on that connection.

item
async fn PgConn::query_stream( self : PgConn, sql : String, params : Array[Value]) -> PgRowStream raise DbError

Send sql (with bound params) and return a streaming cursor over its rows.

item
async fn PgRowStream::next(self : PgRowStream) -> Row? raise DbError

The next row, or None once the result is exhausted. Reads messages until a DataRow (updating the column metadata from any RowDescription first), and on ReadyForQuery marks the stream done. An ErrorResponse is drained to ReadyForQuery before raising, so the connection stays usable.

item
async fn PgRowStream::close(self : PgRowStream) -> Unit raise DbError

Abandon the stream early, draining any unread response to ReadyForQuery so the connection can run the next statement. Idempotent once the stream is done.

item
async fn PgConn::begin(self : PgConn) -> Unit raise DbError

Begin an explicit transaction (BEGIN).

item
async fn PgConn::commit(self : PgConn) -> Unit raise DbError

Commit the current transaction (COMMIT).

item
async fn PgConn::rollback(self : PgConn) -> Unit raise DbError

Roll back the current transaction (ROLLBACK).

item
async fn PgConn::close(self : PgConn) -> Unit

Close the connection: best-effort Terminate, then close the socket. Idempotent — a second call is a no-op.

fn
fn build_startup(user : String, database : String) -> Bytes

StartupMessage: protocol version 3.0 (196608) plus the user / database parameters and a client_encoding=UTF8 request, terminated by an empty key.

fn
fn build_password(token : String) -> Bytes

PasswordMessage ('p'): the auth response token (cleartext, or the md5… digest), as a C string.

fn
fn build_sasl_initial(mechanism : String, client_first : String) -> Bytes

SASLInitialResponse ('p'): the chosen mechanism name, then the length-prefixed client-first SCRAM message (PG protocol §55.2.1).

fn
fn build_sasl_response(client_final : String) -> Bytes

SASLResponse ('p'): the raw client-final SCRAM message, no length prefix.

fn
fn build_query(sql : String) -> Bytes

Simple Query ('Q'): one SQL string, no bound parameters.

fn
fn build_parse(sql : String) -> Bytes

Parse ('P'): prepare the unnamed statement from sql (with $n placeholders). Zero declared parameter types — the server infers them.

fn
fn build_bind(params : Array[Value]) -> Bytes

Bind ('B'): bind params (all text format) to the unnamed statement, producing the unnamed portal, and request all result columns in text format.

fn
fn build_describe_portal() -> Bytes

Describe ('D') the unnamed portal, so the server sends a RowDescription before the DataRows (giving column names + type OIDs for decoding).

fn
fn build_execute() -> Bytes

Execute ('E') the unnamed portal with no row limit (0 = all rows).

fn
fn build_sync() -> Bytes

Sync ('S'): close the extended-query batch; the server replies ReadyForQuery.

fn
fn build_terminate() -> Bytes

Terminate ('X'): ask the backend to close the connection.

item
const OID_BOOL : Int = 16 ///| pub const OID_BYTEA : Int = 17 ///| pub const OID_INT8 : Int = 20 ///| pub const OID_INT2 : Int = 21 ///| pub const OID_INT4 : Int = 23 ///| pub const OID_FLOAT4 : Int = 700 ///| pub const OID_FLOAT8 : Int = 701 ///| /// Encode a bound parameter to its text-format bytes, or `None` for SQL `NULL` /// (which the Bind message sends as a length of `-1`). Binding is out-of-band — /// the value never touches the SQL string — so this is the injection-safe path. /// /// * integers / doubles / bools render to their canonical PostgreSQL text /// literals (`t`/`f` for booleans); /// * `Text` passes through UTF-8; /// * `Blob` uses the `bytea` hex format (`\x` + lowercase hex), which the server /// accepts for a text-format `bytea` parameter. pub fn encode_param(v : Value) -> Bytes?
fn
fn decode_value(oid : Int, is_null : Bool, raw : String) -> Value

Decode a text-format result cell (raw, already UTF-8) tagged with its column oid into a [Value]. is_null marks a wire NULL (length -1), which decodes to Null regardless of type. Numeric and boolean OIDs decode to their typed cases; everything else — including numeric, dates, and unknown OIDs — rides back as Text, exactly the dialect-neutral contract moondb documents (temporal/numeric typing is a roadmap item).

fn
fn parse_int(s : String) -> Int?

Parse a base-10 Int, or None if s is not a well-formed integer. Used to decode int2/int4 result text; a parse failure falls back to Text so a surprising server rendering never silently becomes a wrong number.

fn
fn parse_int64(s : String) -> Int64?

Parse a base-10 Int64, or None on any non-digit (after an optional sign).

fn
fn parse_double(s : String) -> Double?

Parse a floating-point literal from PostgreSQL text (123.45, -1e10, Infinity, NaN), or None if malformed. Handles sign, fraction, and a base-10 exponent; the special IEEE tokens PostgreSQL emits are recognised.

fn
fn decode_bytea(s : String) -> Bytes

Decode PostgreSQL bytea hex text (\xDEADBEEF) back to raw bytes. A value not in hex form (legacy escape format) rides back as its UTF-8 bytes.

fn
fn concat_bytes(a : Bytes, b : Bytes) -> Bytes

Concatenate two byte strings. A small helper the MD5 auth path and message framing lean on; MoonBit's Bytes is immutable so this allocates once.

fn
fn utf8_decode(data : Bytes) -> String

Decode UTF-8 bytes to a String. PostgreSQL text values, column names, and error fields all arrive UTF-8 (the startup message negotiates client_encoding). Decodes the ASCII fast path directly and multi-byte sequences by code point; malformed input yields U+FFFD rather than raising, matching a lenient text codec.

fn
fn translate_placeholders(sql : String) -> String

Rewrite moondb's dialect-neutral ? positional placeholders into PostgreSQL's numbered $1, $2, … form. moondb fixes the *calling convention* (an ordered params array) but leaves the placeholder spelling to the driver; PostgreSQL's extended-query protocol requires $n, so a query layer that emits ? for portability is translated here before Parse. A ? is only a placeholder in SQL text — never inside a single-quoted string literal, a dollar-quoted string, a "-quoted identifier, or a -- / /* */ comment. Those spans are scanned through verbatim so a literal ? in data or a comment is left untouched and does not shift the parameter numbering. A literal ? an application genuinely needs in output can be written ??, which collapses to a single ? (mirroring JDBC-style escaping).

fn
fn count_placeholders(sql : String) -> Int

The number of ? placeholders [translate_placeholders] would consume — the count of parameters a statement expects. Shares the same scanner discipline so ?? escapes and quoted/comment spans do not count.

type
type Value = @moondb.Value

The dialect-neutral value type this driver binds and decodes. Aliased so the wire code reads Value rather than @moondb.Value throughout; the enum constructors (Int, Text, …) are still spelled @moondb.Int where a value is built.

type
type Row = @moondb.Row

One decoded result row (column names + values), aliased from moondb.

type
type ExecResult = @moondb.ExecResult

The outcome of a non-row statement, aliased from moondb.

type
type DbError = @moondb.DbError

The single database error type every fallible operation raises, aliased from moondb so driver signatures read raise DbError.

fn
fn sha256(msg : Bytes) -> Bytes

SHA-256 (FIPS 180-4).

fn
fn hmac_sha256(key : Bytes, msg : Bytes) -> Bytes

HMAC-SHA256 (RFC 2104).

fn
fn pbkdf2_sha256(password : Bytes, salt : Bytes, iterations : Int) -> Bytes

PBKDF2-HMAC-SHA256 producing a 32-byte key (SCRAM's Hi, dkLen = hLen so only the first block is computed): T = U1 ^ U2 ^ … ^ Uc, U1 = HMAC(pw, salt‖INT32(1)).

fn
fn base64_encode(data : Bytes) -> String

Standard base64 encoding (RFC 4648) with = padding.

fn
fn scram_client_proof(salted : Bytes, auth_message : Bytes) -> Bytes

The SCRAM ClientProof for the AuthMessage: ClientKey XOR HMAC(StoredKey, AuthMessage), where ClientKey = HMAC(SaltedPassword, "Client Key") and StoredKey = SHA256(ClientKey) (RFC 5802 §3).

fn
fn scram_server_signature(salted : Bytes, auth_message : Bytes) -> Bytes

The SCRAM ServerSignature: HMAC(ServerKey, AuthMessage), ServerKey = HMAC(SaltedPassword, "Server Key"). The client verifies the server's v= against this to authenticate the server (RFC 5802 §3).

fn
fn base64_decode(s : String) -> Bytes

Standard base64 decode (RFC 4648); = padding is ignored.

fn
fn scram_client_final( password : Bytes, client_first_bare : String, server_first : String) -> (String, Bytes) raise DbError

Build the SCRAM client-final message and the expected server signature from the server's first message (RFC 5802). client_first_bare is n=…,r=clientnonce; server_first is r=nonce,s=salt,i=iters. Returns (client_final_message, server_signature) — the client sends the first and verifies the server's v= against the base64 of the second.

fn
fn md5(msg : Bytes) -> Bytes

The MD5 digest of msg as 16 raw bytes (RFC 1321). Used only for PostgreSQL's AuthenticationMD5Password handshake; not a general-purpose hashing API. Runs the standard little-endian padding + four-round compression over 64-byte blocks.

fn
fn hex_lower(data : Bytes) -> String

Lowercase hex of data, e.g. the 16-byte MD5 digest rendered as its 32-char hex string — the form PostgreSQL's MD5 auth concatenates and re-hashes.

fn
fn pg_md5_password( user : String, password : String, salt : Bytes) -> String

The AuthenticationMD5Password response token: "md5" ++ hex(md5(hex(md5(password ++ user)) ++ salt)), exactly per the PostgreSQL frontend/backend protocol. salt is the four bytes the server sent.

§MySQL and MariaDB driver

The MySQL wire protocol in MoonBit: the handshake and every authentication plugin a modern server offers — native password, caching_sha2 including the full RSA path on a cold cache, and MariaDB's client_ed25519 — plus packet framing, prepared-statement binding and the error mapping.

let
let client_long_password : Int = 0x00000001
let
let client_long_flag : Int = 0x00000004
let
let client_connect_with_db : Int = 0x00000008
let
let client_protocol_41 : Int = 0x00000200
let
let client_transactions : Int = 0x00002000
let
let client_secure_connection : Int = 0x00008000
let
let client_plugin_auth : Int = 0x00080000
let
let mariadb_client_progress : Int = 0x00000001
let
let mariadb_client_com_multi : Int = 0x00000002
let
let mariadb_client_stmt_bulk_operations : Int = 0x00000004
let
let mariadb_client_extended_metadata : Int = 0x00000008
let
let mariadb_client_cache_metadata : Int = 0x00000010
enum
enum ServerKind

Which server dialect answered the handshake. Both speak the MySQL wire protocol; they diverge in the version string and the extended capabilities.

item
impl Show for ServerKind with fn output(self : ServerKind, logger : &Logger) -> Unit
fn
fn parse_server_version(raw : String) -> (ServerKind, String)

Classify a raw handshake version string and recover the real version. MariaDB 10+ prefixes its version with the fake 5.5.5- sentinel (MySQL 5.5.5 was never released), so pre-10 clients that gate on the leading 5. still connect. Strip it to get the true version — e.g. 5.5.5-11.4.2-MariaDB-ubu240411.4.2-MariaDB-ubu2404 as [ServerKind::MariaDB]; a plain 8.0.35 is returned unchanged as [ServerKind::MySQL].

fn
fn bytes_to_string(b : Bytes) -> String

Bytes → String, lossily (server version, error text, text-protocol cells).

fn
fn native_password_scramble(password : Bytes, salt : Bytes) -> Bytes

The mysql_native_password challenge response: SHA1(password) XOR SHA1(salt ++ SHA1(SHA1(password))), 20 bytes. An empty password produces an empty response (the server accepts a zero-length token).

struct
struct Handshake

The server's initial Handshake packet (protocol version 10), decoded down to the fields the client needs: the 20-byte auth salt, the negotiated capabilities, and the auth-plugin name that selects the scramble.

fn
fn parse_handshake(payload : Bytes) -> Handshake raise MysqlError

Parse a protocol-10 initial Handshake payload. An ERR packet in its place (0xFF, e.g. "Host is blocked" / "Too many connections") is surfaced as a [MysqlError::ServerError].

fn
fn build_handshake_response( handshake : Handshake, user : String, password : Bytes, database : String) -> Bytes raise MysqlError

Build the client's HandshakeResponse41 payload for user/password/database. mysql_native_password and caching_sha2_password (MySQL 8's default, fast-path scramble here and full auth driven in [MysqlConn::connect]) are both handled directly. For any other plugin the client advertises mysql_native_password and sends a native token, so a native-capable account authenticates and otherwise the server drives an AuthSwitchRequest the connection layer answers (native or caching_sha2) or rejects. Only a server that requires a non-native plugin *and* does not offer pluggable auth is rejected here. client_ed25519 remains on the README roadmap.

fn
fn is_auth_switch_request(payload : Bytes) -> Bool

Whether payload is an AuthSwitchRequest (0xFE lead byte with a body — the length is what tells it apart from a 5-byte EOF, exactly as [is_eof_packet] keys off < 9).

fn
fn parse_ed25519_challenge(payload : Bytes) -> Bytes raise MysqlError

The 32-byte nonce from a client_ed25519 AuthSwitchRequest. MariaDB sends exactly NONCE_BYTES (= 32) with no NUL terminator and the client signs all of them, so — unlike the native scramble — the full 32 bytes are read raw rather than through the NUL-stripping [parse_auth_switch_request].

fn
fn parse_auth_switch_request( payload : Bytes) -> (String, Bytes) raise MysqlError

Decode an AuthSwitchRequest into the plugin name the server wants and the fresh 20-byte auth salt for it. The trailing NUL that follows the scramble is dropped so the salt feeds [native_password_scramble] directly.

struct
struct PacketReader

A cursor over one decoded MySQL packet payload. Every field type the protocol uses — fixed-width little-endian integers, length-encoded integers and strings, NUL-terminated strings, and raw byte runs — is read through this, advancing a position and raising [MysqlError::ProtocolError] on any short read. It holds no socket: the transport reads a full payload into Bytes first, then parses it here, which is what lets the whole codec be tested off any backend.

fn
fn PacketReader::new(data : Bytes) -> PacketReader

Wrap a decoded packet payload for reading from the start.

fn
fn PacketReader::remaining(self : PacketReader) -> Int

Bytes not yet consumed.

fn
fn PacketReader::at_end(self : PacketReader) -> Bool

Whether the cursor has consumed the whole payload.

fn
fn PacketReader::peek(self : PacketReader) -> Int

Peek the next byte without advancing; -1 at end of payload.

fn
fn PacketReader::u8(self : PacketReader) -> Int raise MysqlError

Read one byte as an Int in 0..=255.

fn
fn PacketReader::uint_le( self : PacketReader, n : Int) -> Int64 raise MysqlError

Read an n-byte little-endian unsigned integer.

fn
fn PacketReader::bytes( self : PacketReader, n : Int) -> Bytes raise MysqlError

Read n raw bytes.

fn
fn PacketReader::skip( self : PacketReader, n : Int) -> Unit raise MysqlError

Skip n bytes.

fn
fn PacketReader::lenenc_uint(self : PacketReader) -> Int64 raise MysqlError

Read a length-encoded unsigned integer (the int<lenenc> type). The 0xFB NULL sentinel and 0xFF are rejected here — NULL only has meaning inside a row, which [lenenc_bytes] handles.

fn
fn PacketReader::lenenc_bytes( self : PacketReader) -> Bytes? raise MysqlError

Read a length-encoded string (the string<lenenc> type), returning None for the 0xFB NULL sentinel that appears in text-protocol rows.

fn
fn PacketReader::string_nul(self : PacketReader) -> Bytes raise MysqlError

Read a NUL-terminated string, consuming the terminator.

fn
fn PacketReader::rest(self : PacketReader) -> Bytes

Read everything left in the payload (an EOF-terminated string field).

fn
fn put_uint_le(buf : Buffer, v : Int64, n : Int) -> Unit

Append an n-byte little-endian unsigned integer.

fn
fn put_lenenc_uint(buf : Buffer, v : Int64) -> Unit

Append a length-encoded unsigned integer.

fn
fn put_string_nul(buf : Buffer, s : Bytes) -> Unit

Append raw bytes followed by a NUL terminator.

fn
fn put_lenenc_bytes(buf : Buffer, s : Bytes) -> Unit

Append a length-encoded string (its lenenc length prefix, then the bytes).

fn
fn concat_bytes(a : Bytes, b : Bytes) -> Bytes

Concatenate two byte strings.

struct
struct OkPacket

An OK packet: a statement that returned no result set (INSERT/UPDATE/DELETE/DDL) or the terminator of a successful command.

struct
struct ColumnDef

One result-set column's definition, reduced to what text decoding needs: the projected name, the MySQL type code, the collation (to tell text from binary), and the column flags.

fn
fn is_err_packet(payload : Bytes) -> Bool

Whether payload is an ERR packet (first byte 0xFF).

fn
fn is_eof_packet(payload : Bytes) -> Bool

Whether payload is an EOF packet (first byte 0xFE, fewer than 9 bytes — which is what tells it apart from a row whose first cell is an 8-byte length-encoded value).

fn
fn is_ok_packet(payload : Bytes) -> Bool

Whether payload is an OK packet (first byte 0x00, at least 7 bytes).

fn
fn parse_err(payload : Bytes) -> MysqlError raise MysqlError

Decode a full ERR packet (including its 0xFF marker) into the [MysqlError::ServerError] it represents.

fn
fn parse_ok(payload : Bytes) -> OkPacket raise MysqlError

Decode an OK packet (including its 0x00 marker).

fn
fn parse_column_def(payload : Bytes) -> ColumnDef raise MysqlError

Decode a column-definition packet (protocol 41). Only the fields that steer text decoding are kept; catalog/schema/table names and the length/decimals fields are read past.

fn
fn decode_text_value( raw : Bytes?, col : ColumnDef) -> @moondb.Value raise MysqlError

Map one text-protocol cell (None = the 0xFB NULL sentinel) onto a [@moondb.Value] using the column's declared type. Integers narrow to Int except BIGINT, which keeps 64 bits; FLOAT/DOUBLE become Double; binary-collation strings become Blob; everything else (VARCHAR, TEXT, DECIMAL, temporal types as ISO text, JSON) becomes Text.

fn
fn build_text_rows( columns : Array[ColumnDef], row_payloads : Array[Bytes]) -> Array[@moondb.Row] raise MysqlError

Build the materialised rows of a text-protocol result set from its column definitions and the raw row packets. This is the pure heart of query: the socket transport collects columns and row_payloads, and every cell decode happens here, off any backend.

enum
enum QuoteMode

How the server reads a string literal, which decides how a value has to be escaped. NO_BACKSLASH_ESCAPES is part of ANSI and of several stock sql_mode combinations: there, a backslash is an ordinary character and the only escape is a doubled quote — so a backslash-escaping writer leaves the value's own quote live and the statement is injectable.

fn
fn bind_params( sql : String, params : Array[@moondb.Value], mode? : QuoteMode = Backslash) -> Bytes raise MysqlError

Substitute the ordered params for the ? placeholders in sql, producing the UTF-8 query bytes a COM_QUERY carries. The text protocol has no out-of-band parameter binding — that arrives with prepared statements (COM_STMT_PREPARE, binary protocol) in a later round — so values are rendered as escaped literals here. The scanner tracks single-, double-, and backtick-quoted spans (honouring backslash escapes) so a literal ? inside a string or identifier is never mistaken for a placeholder, and it raises when the placeholder and parameter counts disagree.

item
suberror MysqlError

A failure raised while speaking the MySQL wire protocol. It is deliberately distinct from @moondb.DbError: the pure codec and the socket transport raise MysqlError (which carries wire-level specifics — a server error code, an unsupported auth plugin, a malformed packet), and the @moondb.Driver adapter maps it onto the coarse DbError cases at the public boundary. * ProtocolError — a packet did not parse: short read, bad prefix byte, an out-of-place packet in the result-set state machine. * ServerError — the server sent an ERR packet. Carries the numeric error code, the 5-char SQLSTATE, and the human message verbatim. * UnsupportedError — a code path this round does not implement (e.g. the caching_sha2_password full-auth exchange, a LOCAL INFILE request).

fn
fn MysqlError::to_string(self : MysqlError) -> String

A one-line rendering, e.g. ServerError(1146, 42S02): Table 'test.t' doesn't exist.

item
impl Show for MysqlError with fn output(self : MysqlError, logger : &Logger) -> Unit
fn
fn caching_sha2_scramble(password : Bytes, nonce : Bytes) -> Bytes

The caching_sha2_password fast-auth scramble: SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) ‖ nonce ), 32 bytes. An empty password sends an empty token.

fn
fn parse_rsa_public_key(pem : String) -> (BigInt, BigInt) raise MysqlError

Parse a PEM SubjectPublicKeyInfo (-----BEGIN PUBLIC KEY-----) into the RSA modulus and public exponent. Walks SEQUENCE { AlgorithmIdentifier, BIT STRING { RSAPublicKey { INTEGER n, INTEGER e } } }.

fn
fn rsa_oaep_sha1_encrypt( msg : Bytes, n : BigInt, e : BigInt, seed : Bytes) -> Bytes raise MysqlError

EME-OAEP encode msg (SHA-1, empty label) to k octets with the given seed, then RSA-encrypt: EM^e mod n. seed must be 20 random bytes (RFC 8017 §7.1.1).

fn
fn caching_sha2_full_auth_token( password : Bytes, nonce : Bytes, pem : String, seed : Bytes) -> Bytes raise MysqlError

The caching_sha2_password full-auth token: the NUL-terminated password XORed with the nonce (cycled), then RSA-OAEP encrypted under the server's public key.

fn
fn ed25519_sign(secret : Bytes, msg : Bytes) -> Bytes

Ed25519 signing (RFC 8032 §5.1.6), deterministic, keyed by secret (expanded via [ed_expand]). Returns the 64-byte R || S: r = SHA-512(prefix || M) mod l, R = [r]B, k = SHA-512(R || A || M) mod l, S = (r + k·s) mod l.

fn
fn mariadb_ed25519_response(password : Bytes, scramble : Bytes) -> Bytes

The MariaDB client_ed25519 response: the 64-byte Ed25519 signature of the server's 32-byte challenge, keyed by the password. Unlike mysql_native_password, the ed25519 plugin signs unconditionally — an empty password expands to SHA-512("") and still yields a valid signature.

fn
fn sha1(msg : Bytes) -> Bytes

SHA-1 (FIPS 180-4) over msg, returning the 20-byte digest. MoonBit's core ships no SHA-1, but mysql_native_password's challenge-response is built entirely on it (SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))), so the driver carries its own. Pure and allocation-simple; verified against the FIPS/NIST test vectors in the suite.

fn
fn sha256(msg : Bytes) -> Bytes

SHA-256 (FIPS 180-4).

fn
fn sha512(msg : Bytes) -> Bytes

SHA-512 (FIPS 180-4), 64-bit words over 80 rounds. Messages here are far under 2^64 bits, so the 128-bit length field's high half is always zero.

§Integration suite

The checks that run against published packages rather than the working tree: what is on mooncakes.io actually works together.