moonmysql

A pure-MoonBit MySQL wire-protocol driver — no C, like PyMySQL. Speaks protocol 41, mysql_native_password, and the text protocol over a raw socket, and implements the @moondb.Driver contract.

CIlicenseGitHub
$moon add moonbitstack/moonmysql
roundtrip.mbt
// A synchronous @moondb.Driver over MySQL — no C, pure MoonBit.
let db = MysqlDriver::new(user="root", password="root", database="test")
db.execute("CREATE TABLE t (id INT, name TEXT)", [])
db.execute("INSERT INTO t VALUES (?, ?)", [Int(1), Text("a")])
let rows = db.query("SELECT id, name FROM t", [])
rows[0].int_by("id")    // => 1
rows[0].text_by("name") // => "a"

@error

The driver's one error type, for the protocol failures the server and the wire can produce.

suberror
suberror MysqlError

A 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
fn MysqlError::to_string(self : MysqlError) -> String

A one-line rendering, e.g. ServerError(1146, 42S02): Table 'test.t' doesn't exist.

item
extend MysqlError with Eq::
item
extend MysqlError with Show::

@packet

The MySQL packet codec: length-encoded and fixed-width little-endian integers, NUL- and lenenc-strings, over an in-memory cursor.

struct
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
fn PacketReader::new(data : Bytes) -> PacketReader

Wrap a decoded packet payload for reading from the start.

fn
fn PacketReader::remaining(self : PacketReader) -> Int

Bytes not yet consumed.

fn
fn PacketReader::at_end(self : PacketReader) -> Bool

Whether the cursor has consumed the whole payload.

fn
fn PacketReader::peek(self : PacketReader) -> Int

Peek the next byte without advancing; -1 at end of payload.

fn
fn PacketReader::u8(self : PacketReader) -> Int raise MysqlError

Read one byte as an Int in 0..=255.

fn
fn PacketReader::uint_le(

Read an n-byte little-endian unsigned integer.

fn
fn PacketReader::bytes(

Read n raw bytes.

fn
fn PacketReader::skip(

Skip n bytes.

fn
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
fn PacketReader::lenenc_bytes(

Read a length-encoded string (the string<lenenc> type), returning None for the 0xFB NULL sentinel that appears in text-protocol rows.

fn
fn PacketReader::string_nul(self : PacketReader) -> Bytes raise MysqlError

Read a NUL-terminated string, consuming the terminator.

fn
fn PacketReader::rest(self : PacketReader) -> Bytes

Read everything left in the payload (an EOF-terminated string field).

fn
fn put_uint_le(buf : Buffer, v : Int64, n : Int) -> Unit

Append an n-byte little-endian unsigned integer.

fn
fn put_lenenc_uint(buf : Buffer, v : Int64) -> Unit

Append a length-encoded unsigned integer.

fn
fn put_string_nul(buf : Buffer, s : Bytes) -> Unit

Append raw bytes followed by a NUL terminator.

fn
fn put_lenenc_bytes(buf : Buffer, s : Bytes) -> Unit

Append a length-encoded string (its lenenc length prefix, then the bytes).

fn
fn concat_bytes(a : Bytes, b : Bytes) -> Bytes

Concatenate two byte strings.

@handshake

Protocol-10 handshake parsing, capability flags, the mysql_native_password scramble, and the HandshakeResponse41 builder.

let
let client_long_password : Int = 0x00000001
let
let client_long_flag : Int = 0x00000004
let
let client_connect_with_db : Int = 0x00000008
let
let client_protocol_41 : Int = 0x00000200
let
let client_transactions : Int = 0x00002000
let
let client_secure_connection : Int = 0x00008000
let
let client_plugin_auth : Int = 0x00080000
let
let mariadb_client_progress : Int = 0x00000001
let
let mariadb_client_com_multi : Int = 0x00000002
let
let mariadb_client_stmt_bulk_operations : Int = 0x00000004
let
let mariadb_client_extended_metadata : Int = 0x00000008
let
let mariadb_client_cache_metadata : Int = 0x00000010
enum
enum ServerKind

Which server dialect answered the handshake. Both speak the MySQL wire protocol; they diverge in the version string and the extended capabilities.

fn
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
fn bytes_to_string(b : Bytes) -> String

Bytes → String, lossily (server version, error text, text-protocol cells).

fn
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
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
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
fn build_handshake_response(

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
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
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
fn parse_auth_switch_request(

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.

item
extend ServerKind with Debug::
item
extend ServerKind with Eq::
item
extend ServerKind with Show::

@caching_sha2

MySQL 8's default authentication: the fast-auth scramble, and the full-auth token RSA-OAEP encrypted to the server's public key.

fn
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
fn parse_rsa_public_key(pem : String) -> @rsa.PublicKey raise MysqlError

The RSA public key inside the PEM SubjectPublicKeyInfo the server sends.

fn
fn caching_sha2_full_auth_token(

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. seed is the 20 random octets OAEP masks with. MySQL's own client draws them from OpenSSL; here they are a parameter, because this package has no entropy source and a caller that reuses a seed should have to write that down.

@ed25519

MariaDB's client_ed25519 authentication response; the signature itself is mooncrypt's.

fn
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. MariaDB keys the signature with the password itself, where RFC 8032 would expand a 32-byte seed — the derivation PrivateKey::of_secret exists for. The plugin signs unconditionally, so an empty password expands to SHA-512("") and still yields a valid signature.

@response

OK / ERR / EOF classification, column-definition parsing, and the text-protocol result-set decoder into @moondb rows.

struct
struct OkPacket

An OK packet: a statement that returned no result set (INSERT/UPDATE/DELETE/DDL) or the terminator of a successful command.

struct
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
fn is_err_packet(payload : Bytes) -> Bool

Whether payload is an ERR packet (first byte 0xFF).

fn
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
fn is_ok_packet(payload : Bytes) -> Bool

Whether payload is an OK packet (first byte 0x00, at least 7 bytes).

fn
fn parse_err(payload : Bytes) -> MysqlError raise MysqlError

Decode a full ERR packet (including its 0xFF marker) into the [MysqlError::ServerError] it represents.

fn
fn parse_ok(payload : Bytes) -> OkPacket raise MysqlError

Decode an OK packet (including its 0x00 marker).

fn
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
fn decode_text_value(

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
fn build_text_rows(

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.

item
extend ColumnDef with Eq::
item
extend OkPacket with Eq::

@binding

Quote-aware parameter binding: `?` placeholders substituted with escaped SQL literals (out-of-band binding lands with prepared statements).

enum
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
fn bind_params(

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.

item
extend QuoteMode with Eq::

@client · MysqlConn

The asynchronous socket transport: connect + mysql_native_password auth + COM_QUERY over @socket.Tcp, with real transactions.

struct
struct MysqlConn

A live MySQL connection over a raw TCP socket, speaking the wire protocol in pure MoonBit. This is the asynchronous, full-fidelity driver: it owns the socket for its whole lifetime, so begin/commit/rollback bracket a real server-side transaction. The synchronous @moondb.Driver adapter ([MysqlDriver]) is layered on top for the moondb contract. Framing is the classic 3-byte little-endian length + 1-byte sequence id header; the transport transparently reassembles the 0xFFFFFF continuation frames a large payload is split into.

fn
fn MysqlConn::server_kind(self : MysqlConn) -> @moonmysql.ServerKind

The dialect (MySQL or MariaDB) reported by the server's handshake.

fn
fn MysqlConn::connect(

Open a connection to host:port and complete the auth handshake against user/password, selecting database (empty for none). Works against both MySQL and MariaDB (the reported dialect is on [server_kind]). mysql_native_password and caching_sha2_password (MySQL 8's default, including the RSA full-auth path on a cache miss) are handled directly, whether the server drives them from its default or via an AuthSwitchRequest. A switch to any other plugin (e.g. client_ed25519, on the README roadmap) raises [@moonmysql.MysqlError::UnsupportedError].

fn
fn MysqlConn::execute(

Run a non-row statement (params bound as escaped literals) and report the rows affected and last insert id.

fn
fn MysqlConn::query(

Run a row-returning statement and materialise every [@moondb.Row].

struct
struct MysqlRowStream

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 at once (mysql_use_result, SQLAlchemy's stream_results). Bound to its MysqlConn, a single-command pipeline: the stream must be drained (next returns None) or [closed](MysqlRowStream::close) before another statement runs on that connection.

fn
fn MysqlConn::query_stream(

Send a row-returning statement and return a streaming cursor over its rows. The column definitions and their terminating EOF are consumed up front; the rows themselves are read one at a time by [next](MysqlRowStream::next).

fn
fn MysqlRowStream::next(self : MysqlRowStream) -> @moondb.Row?

The next row, or None at the EOF that ends the result set. An ErrorResponse mid-stream is raised.

fn
fn MysqlRowStream::close(self : MysqlRowStream) -> Unit

Abandon the stream early, draining any unread rows to the terminating EOF so the connection can run the next statement. Idempotent once the stream is done.

fn
fn MysqlConn::begin(self : MysqlConn) -> Unit

Begin a server-side transaction.

fn
fn MysqlConn::commit(self : MysqlConn) -> Unit

Commit the current transaction.

fn
fn MysqlConn::rollback(self : MysqlConn) -> Unit

Roll back the current transaction.

fn
fn MysqlConn::close(self : MysqlConn) -> Unit

Close the socket (best-effort, idempotent).

@client · MysqlDriver

The synchronous @moondb.Driver adapter that bridges each call through the async runtime (autocommit; reconnect-per-call).

struct
struct MysqlDriver

A synchronous @moondb.Driver over MySQL — the adapter that lets a moondb query layer (moonorm) talk to MySQL through the same contract as the SQLite and Postgres drivers. ## The async wall, and what it means here moonbitlang/async sockets are asynchronous, but @moondb.Driver's methods are synchronous, and MoonBit forbids calling an async function from a non-async one. The bridge is @async.run_async_main, which spins an event loop, runs an async body to completion, and returns — the same hook the toolchain uses to enter async fn main. A socket does **not** survive across two such event loops (each tears its file descriptors down; verified empirically), so every execute/query opens a fresh connection, authenticates, runs its one statement, and closes. That is correct and efficient enough for autocommit work — which is exactly what the moondb roundtrip is — but it means a transaction cannot be held open across separate Driver calls. begin/commit/rollback therefore raise a clear error directing callers to the asynchronous [MysqlConn] API, which owns its socket for its whole lifetime and brackets real transactions. This is a language constraint, not a shortcut, and it is documented in the README.

fn
fn MysqlDriver::new(

Configure a driver. host defaults to loopback, port to 3306; password and database may be empty.

item
extend MysqlDriver with @moondb.Driver::