Guide
Ratify the contract
Implementing Driver is how a backend joins the moondb family. You write seven operations and declare one dialect; the rest are given to you.
§1
Install
moon add moonbitstack/moondb
moondb is a library package. It defines the interface; it opens no connections itself. A driver package depends on it and implements Driver.
§2
Implement the trait
A driver is a struct that owns the connection handle, plus an impl Driver block. You must supply execute, query, begin, commit, rollback, close, and dialect — a query layer reads the last one to know how to spell a placeholder.
pub struct MyConn { dsn : String mut closed : Bool } pub fn open(dsn : String) -> MyConn { { dsn, closed: false } } impl @moondb.Driver for MyConn with fn execute( self, sql, params, ) -> @moondb.ExecResult raise @moondb.DbError { ignore(params) if self.closed { raise @moondb.Closed } raise @moondb.QueryError("execute is not wired to a backend yet: " + sql) } impl @moondb.Driver for MyConn with fn query( self, sql, params, ) -> Array[@moondb.Row] raise @moondb.DbError { ignore(params) if self.closed { raise @moondb.Closed } raise @moondb.QueryError("query is not wired to a backend yet: " + sql) } impl @moondb.Driver for MyConn with fn begin(self) -> Unit raise @moondb.DbError { raise @moondb.QueryError("transactions are not wired yet: " + self.dsn) } impl @moondb.Driver for MyConn with fn commit(self) -> Unit raise @moondb.DbError { raise @moondb.QueryError("transactions are not wired yet: " + self.dsn) } impl @moondb.Driver for MyConn with fn rollback(self) -> Unit raise @moondb.DbError { raise @moondb.QueryError("transactions are not wired yet: " + self.dsn) } impl @moondb.Driver for MyConn with fn close(self) -> Unit { self.closed = true } impl @moondb.Driver for MyConn with fn dialect(_self) -> @moondb.Dialect { @moondb.Dialect::Sqlite } extend MyConn with @moondb.Driver::{ping, close, dialect} pub fn healthy(conn : MyConn) -> Bool { conn.ping() } pub fn shutdown(conn : MyConn) -> Unit { conn.close() }
§3
Values & parameters
Parameters arrive as Array[Value] — one per positional placeholder, in order. Never interpolate them into the SQL string; bind them out-of-band. The placeholder token is the driver's choice (? for SQLite/MySQL, $1 for PostgreSQL).
pub(all) enum Value {
Null | Bool(Bool) | Int(Int32) | Int64(Int64)
| Double(Double) | Text(String) | Blob(Bytes)
| Time(String) | Numeric(String) | Uuid(Bytes)
| Json(String) | Array(Array[Value])
}
Use Value::kind and Value::is_null when classifying an inbound value.
§4
Reading rows
A Row is read positionally or by name, with one typed accessor per Value case. Each raises DbError::TypeError if the column is not the requested shape.
pub fn read(row : @moondb.Row) -> Unit raise @moondb.DbError { let id = row.int64(0) // column 0, as Int64 let email = row.text_by("email") // by column name let unset = row.is_null(2) // SQL NULL check let width = row.width() // column count println("\{id} \{email} \{unset} \{width}") }
§5
Statements & names
prepare returns a Stmt — a handle that runs one SQL text with new values. Override it when the server can parse once; the default keeps the text and re-runs the ordinary path, so every driver hands out a working handle either way.
let stmt = conn.prepare("INSERT INTO hero VALUES (:id, :name)") let args : Map[String, @moondb.Value] = Map([]) args.set("id", @moondb.Value::Int(1)) args.set("name", @moondb.Value::Text("Nova")) stmt.bind_named(args) stmt.execute() stmt.close()
A driver that prepares rewrites :name itself and reports the slot names from params, so bind_named needs no numbering from the caller. Outside a driver, @moondb.rewrite_named(sql, dialect) does the rewrite and Named::bind orders the values; the lexer leaves string literals, quoted identifiers, comments, dollar-quoted bodies, casts and array slices alone.
§6
Nested transactions
Txn brackets a body: the outermost call opens a transaction, a nested one opens a savepoint, and a failure undoes only its own level. Nothing to implement — it speaks begin/commit/rollback and the three savepoint statements, which every backend here accepts.
let tx = @moondb.Txn::new(conn) tx.run(fn(tx) raise @moondb.DbError { tx.driver().execute("INSERT INTO hero VALUES (?, ?)", [Int(1), Text("Nova")]) tx.run(fn(tx) raise @moondb.DbError { // nested: a savepoint, not a second transaction tx.driver().execute("INSERT INTO hero VALUES (?, ?)", [Int(2), Text("Iris")]) }) })
For an async driver, AsyncTxn is the same with every round trip awaited.
§7
Dialects & capabilities
dialect spells a placeholder; capabilities says what the backend can do beyond the base contract. Both are derived from the dialect by default, so a driver declares one thing.
let d = conn.dialect() d.placeholder(1) // "?" or "$1" let caps = conn.capabilities() caps.server_cursors // a cursor the server holds open caps.streaming_copy // COPY in both directions, chunk by chunk caps.unsigned_integers // an unsigned 64-bit column keeps its value
A query layer reads these once and lowers its plan, instead of discovering a missing feature mid-statement. @moondb.Executor::new(conn) is the narrower view: run SQL, but no close and no transaction — the handle a repository method should receive.
§8
Defaults & overrides
Four operations ship with defaults you may keep or override:
ping → Bool
Default issues SELECT 1 and returns whether it round-trips, never raising. Override with a cheaper heartbeat if your backend has one.
query_stream → &Cursor
Default materialises via query and returns an ArrayCursor. A driver with incremental fetch overrides this with a live cursor for bounded memory.
prepare → &Stmt
Default returns a buffered handle that re-runs execute/query. Override when the server can parse once.
capabilities → Capabilities
Default is the preset for the reported dialect. Override only if the driver implements a different subset than the preset claims.
§9
Built on moondb
ORMs such as moonorm write their SQL generation against the Driver trait — not against any one backend. That is the whole point of the contract: one query layer, every ratified driver.