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.
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.
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 Order
Sort direction for an ORDER BY term.
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 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 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 Select
A SELECT statement builder.
enum LockMode
The kind of row lock a FOR UPDATE / FOR SHARE clause takes.
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 select(table : String) -> Select
Start a SELECT over table.
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 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 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 Select::union_all(self : Select, other : Select) -> Select
UNION ALL — the combined rows keeping duplicates.
fn Select::intersect(self : Select, other : Select) -> Select
INTERSECT — rows present in both queries.
fn Select::except_(self : Select, other : Select) -> Select
EXCEPT — rows in this query but not in other (SQL's set difference).
fn Select::column(self : Select, col : String) -> Select
Add a projected column (no columns selects *).
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 Select::count(self : Select) -> Select
Project COUNT(*) — the most common aggregate. Sugar for raw("COUNT(*)").
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 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 Select::left_join(self : Select, table : String, on : String) -> Select
Add a LEFT JOIN table ON <on>. See join for the on contract.
fn Select::group_by(self : Select, col : String) -> Select
Add a GROUP BY column (repeatable; columns are emitted in call order).
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 Select::where_( self : Select, col : String, op : String, val : Value) -> Select
Add a col op ? predicate (ANDed with the rest).
fn Select::eq(self : Select, col : String, val : Value) -> Select
Shorthand for where_(col, "=", val).
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 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 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 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 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 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 Select::where_not_exists(self : Select, sub : Select) -> Select
Add a NOT EXISTS (subquery) predicate (ANDed with the rest). See where_exists.
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 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 Select::order_by(self : Select, col : String, ord : Order) -> Select
Order the result by a column. Repeated calls order by each in turn.
fn Select::limit(self : Select, n : Int) -> Select
Cap how many rows come back.
fn Select::offset(self : Select, n : Int) -> Select
Skip the first n rows. Paired with limit this is a page.
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 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 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 Insert
An INSERT statement builder, with optional upsert (ON CONFLICT) and RETURNING support.
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 Insert::set(self : Insert, col : String, val : Value) -> Insert
Set a column to a value.
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 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 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 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 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 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 Insert::returning_all(self : Insert) -> Insert
Return every column of each inserted row (RETURNING *). See returning.
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 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 Update
An UPDATE statement builder.
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 Update::set(self : Update, col : String, val : Value) -> Update
Assign one column. Repeated calls set each, in the order given.
fn Update::where_( self : Update, col : String, op : String, val : Value) -> Update
Narrow the update with col op ?. Conditions are ANDed together.
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 Delete
A DELETE statement builder.
fn delete(table : String) -> Delete
Start a DELETE from table.
fn Delete::where_( self : Delete, col : String, op : String, val : Value) -> Delete
Narrow the delete with col op ?. Conditions are ANDed together.
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 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 Session::new(driver : &@moondb.Driver) -> Session
Wrap a connected driver in a session.
fn Session::savepoint_depth(self : Session) -> Int
How many nested savepoints opened via begin_nested are currently active.
fn Session::execute( self : Session, sql : String, params : Array[Value]) -> @moondb.ExecResult raise @moondb.DbError
Execute a raw statement with bound params.
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 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 Session::fetch( self : Session, stmt : Select) -> Array[@moondb.Row] raise @moondb.DbError
Build and run a Select, returning the matched rows.
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 Session::stream( self : Session, stmt : Select) -> &@moondb.Cursor raise @moondb.DbError
Build and stream a Select, yielding raw rows through a cursor.
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).
fn[T] RowStream::next(self : RowStream[T]) -> T? raise @moondb.DbError
The next decoded record, or None once the result is exhausted.
fn[T] RowStream::close(self : RowStream[T]) -> Unit
Release the underlying cursor early.
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 Session::modify( self : Session, stmt : Update) -> @moondb.ExecResult raise @moondb.DbError
Build and run an Update, returning the affected-row count.
fn Session::remove( self : Session, stmt : Delete) -> @moondb.ExecResult raise @moondb.DbError
Build and run a Delete, returning the affected-row count.
suberror LostUpdateA lost update was detected: an optimistic-lock UPDATE matched no row because the row's version had already moved on since it was read. Raised by Session::modify_versioned.
impl Show for LostUpdate 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 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 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 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 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 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 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 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 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 Savepoint::name(self : Savepoint) -> String
The generated SQL name of this savepoint.
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 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 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 Savepoint::commit(self : Savepoint) -> Unit raise @moondb.DbError
SQLAlchemy spells "keep the nested work" as commit(); this is the alias for [release].
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 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 IsolationLevel::keyword(self : IsolationLevel) -> String
The ANSI keyword for an isolation level ("READ COMMITTED", "SERIALIZABLE", …).
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 TxOptions::default() -> TxOptions
Transaction options with everything defaulted off (backend default isolation, read-write). Set what you need: { ..TxOptions::default(), isolation: Some(Serializable) }.
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 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.
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.
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.
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.
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>).
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.
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.
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.
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 Session::close(self : Session) -> Unit
Close the session's underlying connection, dropping anything still queued and every record the identity map holds.
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 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 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 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 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 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]).
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)).
fn[T] Field::name(self : Field[T]) -> String
The name of the column this field maps to.
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.
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) \}.
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) }, ) ``
fn[T] Model::column_names(self : Model[T]) -> Array[String]
The declared column names, in order.
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.
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.
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.
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.
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.
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().
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.
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.
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 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.
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.
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.
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.
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.
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 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.
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.
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 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 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.
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 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 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 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 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 Migrator::applied_versions( self : Migrator, sess : Session) -> Array[Int] raise @moondb.DbError
Every applied version, ascending.
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 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 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 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 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 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 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 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 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 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 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.
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.
pub(open) trait DriverThe 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 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.
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.
fn[D] Pool::idle_count(self : Pool[D]) -> Int
How many connections are currently idle (checked in and reusable).
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.
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.
fn[D] Pool::max_size(self : Pool[D]) -> Int
The pool's ceiling on simultaneously open connections.
fn[D] Pool::max_lifetime_ms(self : Pool[D]) -> Int64
The configured maximum connection lifetime in milliseconds (0 = unlimited).
fn[D] Pool::pre_ping(self : Pool[D]) -> Bool
Whether the pre-ping health probe is enabled.
fn[D] Pool::acquire_timeout_ms(self : Pool[D]) -> Int64
The configured acquire wait budget in milliseconds (0 = fail immediately).
fn[D] Pool::is_closed(self : Pool[D]) -> Bool
Whether [close_all] has been called.
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.
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.
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.
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.
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 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 Row::width(self : Row) -> Int
The number of columns in the row.
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 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 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 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 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 Row::bool(self : Row, idx : Int) -> Bool raise DbError
Read column idx as a Bool. Raises TypeError unless the cell is Bool.
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 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 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 Row::text(self : Row, idx : Int) -> String raise DbError
Read column idx as text. Raises TypeError unless the cell is Text.
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 Row::bool_by(self : Row, name : String) -> Bool raise DbError
Read the column named name as a Bool.
fn Row::int_by(self : Row, name : String) -> Int raise DbError
Read the column named name as an Int.
fn Row::int64_by(self : Row, name : String) -> Int64 raise DbError
Read the column named name as an Int64.
fn Row::double_by(self : Row, name : String) -> Double raise DbError
Read the column named name as a Double.
fn Row::text_by(self : Row, name : String) -> String raise DbError
Read the column named name as text.
fn Row::blob_by(self : Row, name : String) -> Bytes raise DbError
Read the column named name as an opaque blob.
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.
pub(open) trait CursorA 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 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 ArrayCursor::new(rows : Array[Row]) -> ArrayCursor
A cursor positioned before the first of rows.
impl Cursor for ArrayCursor with fn next(self : ArrayCursor) -> Row? raise DbError
Hand back the next row, or None once the array is spent.
impl Cursor for ArrayCursor with fn close(self : ArrayCursor) -> Unit
Nothing to release — the rows are already in memory.
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 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 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 Value::is_null(self : Value) -> Bool
Whether this value is SQL NULL.
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.
suberror DbErrorA 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 DbError::to_string(self : DbError) -> String
A one-line rendering of the error, e.g. QueryError: no such table: hero.
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 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 MockDriver::new() -> MockDriver
A fresh, empty mock connection.
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 MockDriver::is_closed(self : MockDriver) -> Bool
Whether the connection has been closed.
fn MockDriver::in_transaction(self : MockDriver) -> Bool
Whether a transaction is currently open (at least one un-committed begin).
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.
impl Driver for MockDriver with fn query( self : MockDriver, _sql : String, _params : Array[Value]) -> Array[Row] raise DbError
Return the recorded rows.
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.
impl Driver for MockDriver with fn commit(self : MockDriver) -> Unit raise DbError
Close the innermost transaction or release the innermost savepoint.
impl Driver for MockDriver with fn rollback(self : MockDriver) -> Unit raise DbError
Undo back to the innermost savepoint, or abandon the transaction.
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 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 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 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 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.
impl @moondb.Driver for SqliteDriver with fn execute(self, sql, params)
impl @moondb.Driver for SqliteDriver with fn query(self, sql, params)
impl @moondb.Cursor for SqliteCursor with fn next(self : SqliteCursor) -> @moondb.Row? raise @moondb.DbError
impl @moondb.Cursor for SqliteCursor with fn close(self : SqliteCursor) -> Unit
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.
impl @moondb.Driver for SqliteDriver with fn begin(self)
impl @moondb.Driver for SqliteDriver with fn commit(self)
impl @moondb.Driver for SqliteDriver with fn rollback(self)
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 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 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.
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].
impl @moondb.Driver for PgDriver with fn execute( _self : PgDriver, _sql : String, _params : Array[Value]) -> ExecResult raise DbError
impl @moondb.Driver for PgDriver with fn query( _self : PgDriver, _sql : String, _params : Array[Value]) -> Array[Row] raise DbError
impl @moondb.Driver for PgDriver with fn begin(_self : PgDriver) -> Unit raise DbError
impl @moondb.Driver for PgDriver with fn commit(_self : PgDriver) -> Unit raise DbError
impl @moondb.Driver for PgDriver with fn rollback(_self : PgDriver) -> Unit raise DbError
impl @moondb.Driver for PgDriver with fn close(_self : PgDriver) -> Unit
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.
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.
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.
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 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.
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.
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.
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.
async fn PgConn::begin(self : PgConn) -> Unit raise DbError
Begin an explicit transaction (BEGIN).
async fn PgConn::commit(self : PgConn) -> Unit raise DbError
Commit the current transaction (COMMIT).
async fn PgConn::rollback(self : PgConn) -> Unit raise DbError
Roll back the current transaction (ROLLBACK).
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 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 build_password(token : String) -> Bytes
PasswordMessage ('p'): the auth response token (cleartext, or the md5… digest), as a C string.
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 build_sasl_response(client_final : String) -> Bytes
SASLResponse ('p'): the raw client-final SCRAM message, no length prefix.
fn build_query(sql : String) -> Bytes
Simple Query ('Q'): one SQL string, no bound parameters.
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 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 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 build_execute() -> Bytes
Execute ('E') the unnamed portal with no row limit (0 = all rows).
fn build_sync() -> Bytes
Sync ('S'): close the extended-query batch; the server replies ReadyForQuery.
fn build_terminate() -> Bytes
Terminate ('X'): ask the backend to close the connection.
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 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 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 parse_int64(s : String) -> Int64?
Parse a base-10 Int64, or None on any non-digit (after an optional sign).
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 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 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 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 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 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 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 Row = @moondb.Row
One decoded result row (column names + values), aliased from moondb.
type ExecResult = @moondb.ExecResult
The outcome of a non-row statement, aliased from moondb.
type DbError = @moondb.DbError
The single database error type every fallible operation raises, aliased from moondb so driver signatures read raise DbError.
fn sha256(msg : Bytes) -> Bytes
SHA-256 (FIPS 180-4).
fn hmac_sha256(key : Bytes, msg : Bytes) -> Bytes
HMAC-SHA256 (RFC 2104).
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 base64_encode(data : Bytes) -> String
Standard base64 encoding (RFC 4648) with = padding.
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 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 base64_decode(s : String) -> Bytes
Standard base64 decode (RFC 4648); = padding is ignored.
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 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 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 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 client_long_password : Int = 0x00000001
let client_long_flag : Int = 0x00000004
let client_connect_with_db : Int = 0x00000008
let client_protocol_41 : Int = 0x00000200
let client_transactions : Int = 0x00002000
let client_secure_connection : Int = 0x00008000
let client_plugin_auth : Int = 0x00080000
let mariadb_client_progress : Int = 0x00000001
let mariadb_client_com_multi : Int = 0x00000002
let mariadb_client_stmt_bulk_operations : Int = 0x00000004
let mariadb_client_extended_metadata : Int = 0x00000008
let mariadb_client_cache_metadata : Int = 0x00000010
enum ServerKind
Which server dialect answered the handshake. Both speak the MySQL wire protocol; they diverge in the version string and the extended capabilities.
impl Show for ServerKind with fn output(self : ServerKind, logger : &Logger) -> Unit
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-ubu2404 → 11.4.2-MariaDB-ubu2404 as [ServerKind::MariaDB]; a plain 8.0.35 is returned unchanged as [ServerKind::MySQL].
fn bytes_to_string(b : Bytes) -> String
Bytes → String, lossily (server version, error text, text-protocol cells).
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 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 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 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 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 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 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 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 PacketReader::new(data : Bytes) -> PacketReader
Wrap a decoded packet payload for reading from the start.
fn PacketReader::remaining(self : PacketReader) -> Int
Bytes not yet consumed.
fn PacketReader::at_end(self : PacketReader) -> Bool
Whether the cursor has consumed the whole payload.
fn PacketReader::peek(self : PacketReader) -> Int
Peek the next byte without advancing; -1 at end of payload.
fn PacketReader::u8(self : PacketReader) -> Int raise MysqlError
Read one byte as an Int in 0..=255.
fn PacketReader::uint_le( self : PacketReader, n : Int) -> Int64 raise MysqlError
Read an n-byte little-endian unsigned integer.
fn PacketReader::bytes( self : PacketReader, n : Int) -> Bytes raise MysqlError
Read n raw bytes.
fn PacketReader::skip( self : PacketReader, n : Int) -> Unit raise MysqlError
Skip n bytes.
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 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 PacketReader::string_nul(self : PacketReader) -> Bytes raise MysqlError
Read a NUL-terminated string, consuming the terminator.
fn PacketReader::rest(self : PacketReader) -> Bytes
Read everything left in the payload (an EOF-terminated string field).
fn put_uint_le(buf : Buffer, v : Int64, n : Int) -> Unit
Append an n-byte little-endian unsigned integer.
fn put_lenenc_uint(buf : Buffer, v : Int64) -> Unit
Append a length-encoded unsigned integer.
fn put_string_nul(buf : Buffer, s : Bytes) -> Unit
Append raw bytes followed by a NUL terminator.
fn put_lenenc_bytes(buf : Buffer, s : Bytes) -> Unit
Append a length-encoded string (its lenenc length prefix, then the bytes).
fn concat_bytes(a : Bytes, b : Bytes) -> Bytes
Concatenate two byte strings.
struct OkPacket
An OK packet: a statement that returned no result set (INSERT/UPDATE/DELETE/DDL) or the terminator of a successful command.
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 is_err_packet(payload : Bytes) -> Bool
Whether payload is an ERR packet (first byte 0xFF).
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 is_ok_packet(payload : Bytes) -> Bool
Whether payload is an OK packet (first byte 0x00, at least 7 bytes).
fn parse_err(payload : Bytes) -> MysqlError raise MysqlError
Decode a full ERR packet (including its 0xFF marker) into the [MysqlError::ServerError] it represents.
fn parse_ok(payload : Bytes) -> OkPacket raise MysqlError
Decode an OK packet (including its 0x00 marker).
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 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 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 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 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.
suberror MysqlErrorA 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 MysqlError::to_string(self : MysqlError) -> String
A one-line rendering, e.g. ServerError(1146, 42S02): Table 'test.t' doesn't exist.
impl Show for MysqlError with fn output(self : MysqlError, logger : &Logger) -> Unit
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 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 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 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 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 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 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 sha256(msg : Bytes) -> Bytes
SHA-256 (FIPS 180-4).
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.