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:
- Calls
mydriver.connect("user:pass@host:port/db")to get the raw connection - Wraps it in a managed connection object that provides:
conn.query(sql, params)— binds named parameters, then callsmydriver.query(&raw, boundSql)conn.exec(sql, params)— binds named parameters, then callsmydriver.exec(&raw, boundSql)conn.close()— callsmydriver.disconnect(&raw)conn.beginTransaction()— callsmydriver.exec(&raw, "BEGIN")conn.commit()— callsmydriver.exec(&raw, "COMMIT")conn.rollback()— callsmydriver.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
- Place your driver file where your application can import it
- 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()- 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
dbmodule calls your functions by name - The
dbmodule owns named parameter binding — drivers receive clean SQL strings, never:nameplaceholders - The
dbmodule owns transaction state — drivers just need to be able to executeBEGIN,COMMIT, andROLLBACKas regular SQL statements throughexec - All result values are strings — the
dbmodule handles type conversion when the application reads values withrow.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/rollbackcalls will still work throughexec— thedbmodule sendsBEGIN,COMMIT, andROLLBACKas literal SQL. If your database needs a different mechanism, this is a current limitation