Writing a Driver

Writing a Driver

Luma’s database layer is designed so that anyone can write a driver. A driver is a regular .luma module that exports a set of functions. There are no compiler plugins, no special interfaces to register, and no build-time dependencies. If your module exports the right functions, db.open() will use it.

This page explains the driver contract, walks through each required function, and provides a skeleton you can use as a starting point.

The Driver Contract

A database driver is a Luma module that exports four public functions:

Function Signature Purpose
connect pub fn connect(dsn: str) -> YourConn Parse the DSN, open a connection, return a connection value
query pub fn query(conn: YourOps, sql: str) -> [{str: str}] Execute a SQL query, return rows as a list of string maps
exec pub fn exec(conn: YourOps, sql: str) -> [int] Execute a SQL statement, return [affected_rows, last_insert_id]
disconnect pub fn disconnect(conn: YourOps) Close the connection and release resources

An optional fifth function provides string escaping:

Function Signature Purpose
escape pub fn escape(value: str) -> str Escape a string for safe interpolation in SQL

The db module calls these functions internally. Your application code never calls them directly — it uses conn.query(), conn.exec(), etc., and the db module translates those into calls to your driver.

Connection Struct and Trait

Your driver needs a struct to hold the connection state and a trait so the public functions can accept it:

struct MyConn {
    sock: tcp
    seq: int
}

trait MyOps {
    fn read_packet(self) -> [byte]
    fn send_packet(self, data: [byte])
}

impl MyOps for MyConn {
    fn read_packet(self) -> [byte] {
        // read from self.sock
    }

    fn send_packet(self, data: [byte]) {
        // write to self.sock
    }
}

The trait is not strictly required — you can use the struct directly as the parameter type. But a trait makes it easier to test and to separate the wire protocol from the public API.

The db module passes the connection value to your functions by reference (&conn). Your functions receive it through the trait parameter.

The connect Function

pub fn connect(dsn: str) -> MyConn {
    // 1. Parse the DSN string
    // 2. Open a TCP connection
    // 3. Perform the protocol handshake
    // 4. Authenticate
    // 5. Return the connection struct
}

This is where all connection setup happens. The DSN format is up to you — the db module passes the string through unchanged.

Example: Parse a DSN like user:pass@host:port/database:

pub fn connect(dsn: str) -> MyConn {
    parts = parse_dsn(dsn)

    sock: tcp = tcp.connect(parts.host, parts.port)

    // Protocol-specific handshake
    greeting = read_greeting(sock)
    send_auth(sock, greeting, parts.user, parts.password)

    return MyConn { sock: sock, seq: 0 }
}

If the connection fails at any point, panic with a descriptive error message. The db module does not catch errors — failures propagate to the application.

The query Function

pub fn query(conn: MyOps, sql: str) -> [{str: str}] {
    // 1. Send the SQL to the server
    // 2. Read column definitions
    // 3. Read row data
    // 4. Return rows as a list of string-keyed maps
}

The return type must be [{str: str}] — a list of maps where keys are column names and values are the string representation of each cell.

Example:

pub fn query(conn: MyOps, sql: str) -> [{str: str}] {
    send_query(conn, sql)
    columns = read_column_names(conn)
    rows: [{str: str}] = []

    // Read rows until end-of-data marker
    row_data = read_row(conn)
    while row_data != nil {
        row: {str: str} = {}
        i: int = 0
        columns.walk(col) -> {
            row[col] = row_data[i]
            i = i + 1
        }
        rows = rows.add(row)
        row_data = read_row(conn)
    }

    return rows
}

The db module takes this raw list and wraps it into its managed rows object, which provides .walk(), .count(), .first(), .all(), and typed .get() access.

Important: All values must be strings. Type conversion (string to int, float, bool) is handled by the db module when the application calls row.get().

The exec Function

pub fn exec(conn: MyOps, sql: str) -> [int] {
    // 1. Send the SQL to the server
    // 2. Read the response
    // 3. Return [affected_rows, last_insert_id]
}

Returns a two-element list [affected_rows, last_insert_id]. The db module uses the first element as the return value of conn.exec() and stores the second element for conn.lastInsertId(). For statements like CREATE TABLE or DROP TABLE that don’t have meaningful values, return [0, 0].

If the statement unexpectedly returns a result set (e.g., the user ran a SELECT through exec), drain and discard the results.

Example:

pub fn exec(conn: MyOps, sql: str) -> [int] {
    send_query(conn, sql)
    response = read_response(conn)

    if response.is_result_set {
        drain_results(conn)
        return [0, 0]
    }

    return [response.affected_rows, response.last_insert_id]
}

The disconnect Function

pub fn disconnect(conn: MyOps) {
    // 1. Send a disconnect/quit command (protocol-specific)
    // 2. Close the TCP socket
}

Example:

pub fn disconnect(conn: MyOps) {
    send_quit(conn)
    conn.sock.close()
}

The escape Function (Optional)

pub fn escape(value: str) -> str {
    // Escape special characters for safe SQL interpolation
}

This function is not called by the db module — it is provided as a utility for users who need to build SQL strings manually when using the raw driver API.

Example:

pub fn escape(value: str) -> str {
    result: str = value
    result = result.replace("\\", "\\\\")
    result = result.replace("'", "\\'")
    result = result.replace("\"", "\\\"")
    result = result.replace("\n", "\\n")
    return result
}

How db.open() Uses Your Driver

When the application calls:

mydriver = import "mydriver"
conn = db.open(mydriver, "user:pass@host:port/db")

The db module does the following:

  1. Calls mydriver.connect("user:pass@host:port/db") to get the raw connection
  2. Wraps it in a managed connection object that provides:
    • conn.query(sql, params) — binds named parameters, then calls mydriver.query(&raw, boundSql)
    • conn.exec(sql, params) — binds named parameters, then calls mydriver.exec(&raw, boundSql)
    • conn.close() — calls mydriver.disconnect(&raw)
    • conn.beginTransaction() — calls mydriver.exec(&raw, "BEGIN")
    • conn.commit() — calls mydriver.exec(&raw, "COMMIT")
    • conn.rollback() — calls mydriver.exec(&raw, "ROLLBACK")

Named parameter binding (:name → escaped value) is handled by the db module, not by your driver. Your query and exec functions receive fully-bound SQL strings.

Transaction state tracking (inTransaction flag, double-begin protection) is also handled by the db module.

Driver Skeleton

Here is a minimal skeleton for a new database driver:

// mydriver.luma — Skeleton database driver

struct MyConn {
    sock: tcp
}

trait MyOps {
    fn read_packet(self) -> [byte]
    fn send_packet(self, data: [byte])
}

impl MyOps for MyConn {
    fn read_packet(self) -> [byte] {
        // TODO: implement protocol-specific packet reading
    }

    fn send_packet(self, data: [byte]) {
        // TODO: implement protocol-specific packet writing
    }
}

// --- Public API (required by db.open) ---

pub fn connect(dsn: str) -> MyConn {
    // Parse DSN, open TCP, handshake, authenticate
    // Return connection struct
}

pub fn query(conn: MyOps, sql: str) -> [{str: str}] {
    // Send query, read columns, read rows
    // Return list of {column_name: value_string} maps
}

pub fn exec(conn: MyOps, sql: str) -> [int] {
    // Send statement, read response
    // Return [affected_rows, last_insert_id]
}

pub fn disconnect(conn: MyOps) {
    // Send quit command, close socket
}

// --- Optional utility ---

pub fn escape(value: str) -> str {
    // Escape special characters for SQL safety
}

Building and Testing

  1. Place your driver file where your application can import it
  2. Import it and pass it to db.open():
mydriver = import "mydriver"
conn = db.open(mydriver, "user:pass@localhost:5432/testdb")

rows = conn.query("SELECT 1 AS test")
rows.walk(row) -> {
    val: str = row.get("test")
    print("Got: ${val}")
}

conn.close()
  1. Build with luma build your_app.luma — the compiler will include your driver module and verify that the generated Go compiles cleanly

Primitives Available to Drivers

Drivers are regular Luma code with access to all of Luma’s core modules:

Module Use in drivers
tcp Network connections, reading/writing bytes
buffer Building and parsing binary protocol packets
crypto Hashing for authentication (SHA1, SHA256, HMAC, etc.)
bit Bitwise operations for protocol flags and capabilities

The built-in MySQL driver (mysql.luma) uses all four. It implements the full MySQL wire protocol — connection handshake, two authentication methods, query execution, and result parsing — in roughly 800 lines of Luma. Reading it is the best way to understand how a real driver works.

Design Notes

  • Drivers are just modules — no registration, no interfaces to satisfy at the type system level, no compiler awareness. The db module calls your functions by name
  • The db module owns named parameter binding — drivers receive clean SQL strings, never :name placeholders
  • The db module owns transaction state — drivers just need to be able to execute BEGIN, COMMIT, and ROLLBACK as regular SQL statements through exec
  • All result values are strings — the db module handles type conversion when the application reads values with row.get()
  • The connection is passed by reference — your functions modify the same connection state (sequence numbers, buffers, etc.) across calls
  • If your database doesn’t support transactions via SQL statements, the beginTransaction / commit / rollback calls will still work through exec — the db module sends BEGIN, COMMIT, and ROLLBACK as literal SQL. If your database needs a different mechanism, this is a current limitation
Last updated on