moon-mysql

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 Lfan-ke/moon-mysql
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"

@sha1

Self-contained SHA-1 (FIPS 180-4) — the primitive behind the mysql_native_password scramble.

fn
fn sha1(msg : Bytes) -> Bytes

SHA-1 (FIPS 180-4) over msg, returning the 20-byte digest. MoonBit's core ships no SHA-1, but mysql_native_password's challenge-response is built entirely on it (SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))), so the driver carries its own. Pure and allocation-simple; verified against the FIPS/NIST test vectors in the suite.

@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-ubu240411.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 is the tested path on both MySQL and MariaDB. When the server advertises a different default (e.g. a MariaDB configured for client_ed25519, or MySQL 8's caching_sha2_password) but still speaks pluggable auth, the client advertises mysql_native_password and sends a native token: a native-capable account then authenticates directly, and otherwise the server drives an AuthSwitchRequest that [MysqlConn::connect] answers (to mysql_native_password) or rejects with a roadmap error. Only a server that requires a non-native plugin *and* does not offer pluggable auth is rejected here. client_ed25519 and caching_sha2_password full-auth remain on the README roadmap and are not stubbed.

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

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

@binding

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

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.

@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) -> @moon_mysql.ServerKind

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

fn
fn MysqlConn::connect(

Open a connection to host:port and complete a mysql_native_password handshake against user/password, selecting database (empty for none). Works against both MySQL and MariaDB (the reported dialect is on [server_kind]). If the server's default plugin is not native but it offers pluggable auth, the client advertises mysql_native_password and answers the server's AuthSwitchRequest to it — the down-negotiation MariaDB may require. A switch to any other plugin, or a caching_sha2_password full-auth (0x01 AuthMoreData), raises [@moon_mysql.MysqlError::UnsupportedError]: client_ed25519 and caching_sha2_password are on the README roadmap, not stubbed.

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

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.