moon-postgres
A pure-MoonBit PostgreSQL wire-protocol driver (v3) — zero C, like asyncpg. Speaks StartupMessage, MD5/trust auth, and simple + extended queries over async TCP, and implements the @moondb.Driver seam.
moon add Lfan-ke/moon-postgres✦ The async wall
PostgreSQL is spoken over async TCP, so the real driver is the async PgConn (asyncpg-shaped: connect → execute/query/begin/commit/rollback/close), used inside an event loop. @moondb.Driver's methods are synchronous; MoonBit exposes no sync→async bridge, so PgDriver conforms to the seam but points synchronous callers to PgConn. The CI suite drives a live PostgreSQL through PgConn.
§driver
PgDriver and its @moondb.Driver conformance — a synchronous façade over the async core (see README §The async wall).
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 moon-sqlite 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
§connection
PgConn — the async connection: startup handshake, authentication, and simple + extended query execution.
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, or MD5), 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).
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.
§protocol
PostgreSQL v3 frontend message builders (StartupMessage, Query, Parse/Bind/Describe/Execute/Sync) and framing.
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_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.
§values
Text-format bind-parameter encoding and result-cell decoding, dispatched by PostgreSQL type OID.
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.
§placeholders
Translation of moondb's dialect-neutral `?` placeholders into PostgreSQL's numbered `$n`, comment/literal aware.
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.
§md5
A pure-MoonBit MD5 and the AuthenticationMD5Password token PostgreSQL's MD5 handshake needs.
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.
§bytes
Byte helpers: concatenation, a cursor reader for message payloads, and a lenient UTF-8 decoder.
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.