moondb

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.

Two things a driver offers on top of the trait are its own calls, because they change how a value crosses the wire rather than what the contract can express. moonpostgres takes binary = true on a query and answers each column in PostgreSQL's binary format, chosen per column from its type; text stays the default, and a prepared handle describes the columns once. moonmysql streams a parameter of a mebibyte or more with COM_STMT_SEND_LONG_DATA, so a value larger than max_allowed_packet still binds — no flag, no call-site change. Each driver's page has the details.

§8

Defaults & overrides

Four operations ship with defaults you may keep or override:

i

ping → Bool

Default issues SELECT 1 and returns whether it round-trips, never raising. Override with a cheaper heartbeat if your backend has one.

ii

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.

iii

prepare → &Stmt

Default returns a buffered handle that re-runs execute/query. Override when the server can parse once.

iv

capabilities → Capabilities

Default is the preset for the reported dialect. Override only if the driver implements a different subset than the preset claims.

§10

A blocking driver, seen as an async one

An application usually has one backend and one shape of code. A checkpoint store, a retrieval index, a server running a single event loop for every dependency — all written against AsyncDriver, with a database underneath that may answer straight out of the page cache. @blocking.Blocking is that seam: every async method calls its blocking twin, and nothing else changes.

let db = @blocking.Blocking::new(@moonsqlite.SqliteDriver::open("app.db"))
let conn : &@moondb.AsyncDriver = db
conn.execute("INSERT INTO hero VALUES (?, ?)", [Int(1), Text("Nova")])
let rows = conn.query("SELECT id FROM hero", [])

Three details make it more than a cast. prepare hands back the driver's own handle — a sqlite3_stmt compiled once — instead of the async default, which re-runs the SQL text on every call. query_stream keeps the driver's live cursor, so the memory bound survives. And dialect/capabilities come from the wrapped driver, so a query layer above sees the real backend rather than the seam's SQLite default.

The call runs on the task's own thread, which is right for a backend answering in microseconds and wrong for one answering over a socket: a networked backend implements AsyncDriver itself. The bridge does not serialise either — one connection per task, or an @apool.AsyncPool handing one connection to one caller at a time.

§11

Holding a driver to the contract

A backend is easy to write and hard to trust. @conformance is the contract as a case matrix: connect, bind and read back, prepare once and re-run, bracket a transaction, nest one through a savepoint, carry every Value case the dialect has a column for, and classify a duplicate key and a malformed statement the way the error model says. Every backend here answers the same cases.

let report = @conformance.check_blocking(@moonsqlite.SqliteDriver::open(":memory:"))
report.summary()      // "12 passed, 0 skipped, 0 failed"
report.failures()     // one line per case the backend did not honour
report.is_clean()     // no failures (a skip is not a pass)

A case that raises becomes a line in the report instead of ending the run, so a broken backend produces a list of what is wrong rather than a stack trace at the first problem. A feature the driver reports as absent — savepoints, say — is recorded as skipped with its reason, never counted as a pass. The same matrix runs in CI against PostgreSQL, MySQL, MariaDB, CockroachDB and TiDB, and the two wire drivers carry it in their own suites.

§12

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.