zql design notes#
current state#
zql provides comptime utilities for SQL:
- parse - extract column names and param names from SQL strings
- bind - convert named struct args to positional tuple
- fromRow - map row data to typed struct with validation
const Q = zql.Query("SELECT id, name FROM users WHERE id = :id");
// bind: struct -> tuple in param order
c.query(Q.positional, Q.bind(.{ .id = user_id }));
// fromRow: row -> typed struct
const user = Q.fromRow(User, row);
what comptime enables#
based on research (see comptime.md), zig's comptime can:
- parse and validate strings at compile time
- generate types from data
- create perfect hash functions for O(1) lookups
- build complex data structures via type functions
potential directions#
1. table definitions (orm-lite)#
define schema once, generate queries:
const User = zql.Table("users", struct {
id: i64,
name: []const u8,
email: []const u8,
});
// generates: INSERT INTO users (id, name, email) VALUES (?, ?, ?)
User.insert(.{ .id = 1, .name = "alice", .email = "a@b.com" });
// generates: SELECT id, name, email FROM users WHERE id = ?
User.select().where(.{ .id = 1 });
// generates: UPDATE users SET name = ? WHERE id = ?
User.update(.{ .name = "bob" }).where(.{ .id = 1 });
pros:
- single source of truth for schema
- no manual SQL writing for CRUD
- compile-time validation
cons:
- scope creep into ORM territory
- complex queries still need raw SQL
- migration story unclear
2. query composition (options struct pattern)#
zig idiom: use options structs, not fluent builders.
// NOT idiomatic zig:
zql.Select("id").from("users").where("x = :x")
// idiomatic zig - options struct:
const Q = zql.Query(.{
.select = "d.uri, d.did, d.title, d.created_at",
.from = "documents d",
.joins = &.{
"LEFT JOIN publications p ON d.publication_uri = p.uri",
},
.where = "d.uri = :uri",
.order_by = "d.created_at DESC",
.limit = 40,
});
for variants, use comptime conditionals:
fn DocQuery(comptime opts: struct {
fts: bool = false,
tag: bool = false,
}) type {
return zql.Query(.{
.select = if (opts.fts) fts_columns else basic_columns,
.from = if (opts.fts) "documents_fts f" else "documents d",
.joins = buildJoins(opts),
.where = buildWhere(opts),
.order_by = if (opts.fts) "rank" else "d.created_at DESC",
});
}
// usage:
const DocsByFts = DocQuery(.{ .fts = true });
const DocsByTag = DocQuery(.{ .tag = true });
const DocsByFtsAndTag = DocQuery(.{ .fts = true, .tag = true });
pros:
- follows zig idioms (options struct pattern)
- explicit, readable
- comptime conditional logic is clear
- type-returning function pattern from std
cons:
- more verbose than current raw SQL strings
- query structure must fit the options model
3. schema validation#
define schema, validate queries reference real columns:
const schema = zql.Schema{
.tables = .{
.users = .{ .id = .int, .name = .text, .email = .text },
.posts = .{ .id = .int, .user_id = .int, .title = .text },
},
};
// compile error if 'users' table doesn't have 'name' column
const Q = schema.Query("SELECT name FROM users");
pros:
- catches typos at compile time
- documents schema in code
cons:
- schema must be maintained in zig (duplication from DB)
- no runtime schema introspection in zig
4. better parsing#
current parser is basic. could add:
- sql syntax validation (not just name extraction)
- join detection
- subquery handling
- aggregate function recognition
5. perfect hash for columns#
use comptime perfect hashing for O(1) column lookups:
const Q = zql.Query("SELECT id, name, age FROM users");
// Q.columnIndex("name") could use perfect hash instead of linear search
probably premature optimization for typical column counts.
zig idioms (from research)#
the zig community prefers:
- options structs over fluent builders
- type-returning functions for generics
- explicit code over clever patterns
- comptime validation via @compileError
from the zig zen:
- "favor reading code over writing code"
- "only one obvious way to do things"
- "communicate intent precisely"
recommendation#
keep the current simple approach. three explicit Query constants is more readable than a factory function with options.
// current - explicit, clear
const DocsByTag = zql.Query("SELECT ... WHERE tag = :tag ...");
const DocsByFts = zql.Query("SELECT ... WHERE MATCH :query ...");
const DocsByFtsAndTag = zql.Query("SELECT ... WHERE MATCH :query AND tag = :tag ...");
this follows zig's preference for explicit over clever. the "duplication" is actually meaningful - these queries have different semantics.
if we do add composition, use the type-returning function pattern:
fn DocQuery(comptime opts: struct { fts: bool = false, tag: bool = false }) type {
// comptime conditionals to build SQL
return zql.Query(sql);
}
but only if the duplication becomes a real maintenance burden.
open questions#
- how to handle dynamic WHERE clauses? (optional filters)
- how to compose queries that return different column sets?
- should zql own the execution layer or just generate SQL?
- how to handle database-specific SQL dialects?