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.

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

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