comptime sql bindings for zig
ziglang sql

add struct mapping and modularize codebase

- add validateStruct, columnIndex, fromRow to Query type
- split into modules: Query.zig, parse.zig, root.zig (all <200 lines)
- mark as alpha in README and root.zig
- fix comptime slice issues with inline fn

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+257 -153
+15 -7
README.md
··· 1 1 # zql 2 2 3 - comptime sql ergonomics for zig. 3 + comptime sql ergonomics for zig. **alpha** - api may change. 4 4 5 5 ## features 6 6 7 7 - **named parameters**: `:name` syntax with comptime validation 8 8 - **column extraction**: parse SELECT columns at comptime 9 + - **struct mapping**: map rows to structs with comptime validation 9 10 - **zero runtime overhead**: all parsing happens at compile time 10 11 11 12 ## usage ··· 13 14 ```zig 14 15 const zql = @import("zql"); 15 16 16 - // query metadata extracted at comptime 17 - const Q = zql.parse.Query("SELECT id, name, age FROM users WHERE age > :min_age"); 17 + const Q = zql.Query("SELECT id, name, age FROM users WHERE age > :min_age"); 18 18 19 - // access parsed info 19 + // access parsed metadata 20 20 _ = Q.raw; // original sql 21 21 _ = Q.positional; // "SELECT id, name, age FROM users WHERE age > ?" 22 22 _ = Q.params; // ["min_age"] 23 23 _ = Q.columns; // ["id", "name", "age"] 24 24 25 - // validate args struct has required params (comptime error if missing) 26 - Q.validateArgs(struct { min_age: i64 }); 25 + // comptime validation 26 + Q.validateArgs(struct { min_age: i64 }); // error if param missing 27 + Q.validateStruct(User); // error if field not in columns 28 + 29 + // struct mapping (with any row type that has .get(idx)) 30 + const User = struct { id: i64, name: []const u8 }; 31 + const user = Q.fromRow(User, row_data); 32 + 33 + // column index lookup 34 + const idx = Q.columnIndex("name"); // 1 27 35 ``` 28 36 29 37 ## install ··· 39 47 40 48 ## status 41 49 42 - early development. contributions welcome. 50 + alpha. contributions welcome.
+154
src/Query.zig
··· 1 + //! Query - comptime sql metadata extraction 2 + //! 3 + //! extracts parameter names, column names, and provides 4 + //! struct mapping utilities at compile time. 5 + 6 + const std = @import("std"); 7 + const parser = @import("parse.zig"); 8 + 9 + /// query metadata extracted at comptime 10 + pub fn Query(comptime sql: []const u8) type { 11 + comptime { 12 + const parsed = parser.parse(sql); 13 + return struct { 14 + pub const raw = sql; 15 + pub const positional: []const u8 = parsed.positional[0..parsed.positional_len]; 16 + pub const param_count = parsed.param_count; 17 + pub const params: []const []const u8 = parsed.params[0..parsed.params_len]; 18 + pub const columns: []const []const u8 = parsed.columns[0..parsed.columns_len]; 19 + 20 + /// validate that args struct has all required params 21 + pub fn validateArgs(comptime Args: type) void { 22 + const fields = @typeInfo(Args).@"struct".fields; 23 + inline for (params) |p| { 24 + if (!hasField(fields, p)) { 25 + @compileError("missing param :" ++ p ++ " in args struct"); 26 + } 27 + } 28 + } 29 + 30 + /// validate that struct fields match query columns 31 + pub fn validateStruct(comptime T: type) void { 32 + const fields = @typeInfo(T).@"struct".fields; 33 + inline for (fields) |f| { 34 + if (!hasColumn(f.name)) { 35 + @compileError("struct field '" ++ f.name ++ "' not found in query columns"); 36 + } 37 + } 38 + } 39 + 40 + /// get column index by name at comptime 41 + pub inline fn columnIndex(comptime name: []const u8) comptime_int { 42 + inline for (columns, 0..) |col, i| { 43 + if (comptime std.mem.eql(u8, col, name)) { 44 + return i; 45 + } 46 + } 47 + @compileError("column '" ++ name ++ "' not found in query"); 48 + } 49 + 50 + /// map row data to a struct using column names 51 + pub fn fromRow(comptime T: type, row_data: anytype) T { 52 + comptime validateStruct(T); 53 + var result: T = undefined; 54 + const fields = @typeInfo(T).@"struct".fields; 55 + inline for (fields) |f| { 56 + const idx = comptime columnIndex(f.name); 57 + @field(result, f.name) = row_data.get(idx); 58 + } 59 + return result; 60 + } 61 + 62 + fn hasField(fields: anytype, name: []const u8) bool { 63 + inline for (fields) |f| { 64 + if (std.mem.eql(u8, f.name, name)) return true; 65 + } 66 + return false; 67 + } 68 + 69 + fn hasColumn(comptime name: []const u8) bool { 70 + const cols = @This().columns; 71 + inline for (cols) |col| { 72 + if (std.mem.eql(u8, col, name)) return true; 73 + } 74 + return false; 75 + } 76 + }; 77 + } 78 + } 79 + 80 + test "columns" { 81 + const Q = Query("SELECT id, name, age FROM users"); 82 + try std.testing.expectEqual(3, Q.columns.len); 83 + try std.testing.expectEqualStrings("id", Q.columns[0]); 84 + try std.testing.expectEqualStrings("name", Q.columns[1]); 85 + try std.testing.expectEqualStrings("age", Q.columns[2]); 86 + } 87 + 88 + test "named params" { 89 + const Q = Query("SELECT * FROM users WHERE id = :id AND age > :min_age"); 90 + try std.testing.expectEqual(2, Q.params.len); 91 + try std.testing.expectEqualStrings("id", Q.params[0]); 92 + try std.testing.expectEqualStrings("min_age", Q.params[1]); 93 + } 94 + 95 + test "positional conversion" { 96 + const Q = Query("SELECT * FROM users WHERE id = :id AND age > :min_age"); 97 + try std.testing.expectEqualStrings("SELECT * FROM users WHERE id = ? AND age > ?", Q.positional); 98 + } 99 + 100 + test "columns with alias" { 101 + const Q = Query("SELECT id, first_name AS name FROM users"); 102 + try std.testing.expectEqual(2, Q.columns.len); 103 + try std.testing.expectEqualStrings("id", Q.columns[0]); 104 + try std.testing.expectEqualStrings("name", Q.columns[1]); 105 + } 106 + 107 + test "columns with function" { 108 + const Q = Query("SELECT COUNT(*) AS count, MAX(age) AS max_age FROM users"); 109 + try std.testing.expectEqual(2, Q.columns.len); 110 + try std.testing.expectEqualStrings("count", Q.columns[0]); 111 + try std.testing.expectEqualStrings("max_age", Q.columns[1]); 112 + } 113 + 114 + test "columnIndex" { 115 + const Q = Query("SELECT id, name, age FROM users"); 116 + // first verify columns work directly 117 + try std.testing.expectEqual(3, Q.columns.len); 118 + try std.testing.expectEqualStrings("id", Q.columns[0]); 119 + 120 + // now try columnIndex 121 + try std.testing.expectEqual(0, Q.columnIndex("id")); 122 + try std.testing.expectEqual(1, Q.columnIndex("name")); 123 + try std.testing.expectEqual(2, Q.columnIndex("age")); 124 + } 125 + 126 + test "validateStruct" { 127 + const Q = Query("SELECT id, name, age FROM users"); 128 + // verify columns first 129 + try std.testing.expectEqual(3, Q.columns.len); 130 + 131 + const User = struct { id: i64, name: []const u8, age: i64 }; 132 + comptime Q.validateStruct(User); 133 + 134 + const Partial = struct { id: i64, name: []const u8 }; 135 + comptime Q.validateStruct(Partial); 136 + } 137 + 138 + test "fromRow" { 139 + const Q = Query("SELECT id, name, age FROM users"); 140 + 141 + const MockRow = struct { 142 + values: [3]i64, 143 + pub fn get(self: @This(), idx: usize) i64 { 144 + return self.values[idx]; 145 + } 146 + }; 147 + 148 + const row = MockRow{ .values = .{ 42, 100, 25 } }; 149 + const Result = struct { id: i64, age: i64 }; 150 + const result = Q.fromRow(Result, row); 151 + 152 + try std.testing.expectEqual(42, result.id); 153 + try std.testing.expectEqual(25, result.age); 154 + }
+71 -118
src/parse.zig
··· 1 - //! comptime sql parsing utilities 1 + //! sql parsing utilities 2 2 3 3 const std = @import("std"); 4 4 5 - /// query metadata extracted at comptime 6 - pub fn Query(comptime sql: []const u8) type { 7 - comptime { 8 - const parsed = parse(sql); 9 - return struct { 10 - pub const raw = sql; 11 - pub const positional: []const u8 = parsed.positional[0..parsed.positional_len]; 12 - pub const param_count = parsed.param_count; 13 - pub const params: []const []const u8 = parsed.params[0..parsed.params_len]; 14 - pub const columns: []const []const u8 = parsed.columns[0..parsed.columns_len]; 15 - 16 - /// validate that args struct has all required params 17 - pub fn validateArgs(comptime Args: type) void { 18 - const fields = @typeInfo(Args).@"struct".fields; 19 - inline for (params) |p| { 20 - var found = false; 21 - inline for (fields) |f| { 22 - if (std.mem.eql(u8, f.name, p)) { 23 - found = true; 24 - break; 25 - } 26 - } 27 - if (!found) { 28 - @compileError("missing param :" ++ p ++ " in args struct"); 29 - } 30 - } 31 - } 32 - }; 33 - } 34 - } 35 - 36 - const MAX_PARAMS = 32; 37 - const MAX_COLS = 64; 5 + pub const MAX_PARAMS = 32; 6 + pub const MAX_COLS = 64; 7 + pub const MAX_SQL_LEN = 4096; 38 8 39 - const ParseResult = struct { 40 - positional: [4096]u8, 9 + pub const ParseResult = struct { 10 + positional: [MAX_SQL_LEN]u8, 41 11 positional_len: usize, 42 12 param_count: usize, 43 13 params: [MAX_PARAMS][]const u8, ··· 46 16 columns_len: usize, 47 17 }; 48 18 49 - fn parse(comptime sql: []const u8) ParseResult { 19 + pub fn parse(comptime sql: []const u8) ParseResult { 50 20 var result = ParseResult{ 51 21 .positional = undefined, 52 22 .positional_len = 0, ··· 57 27 .columns_len = 0, 58 28 }; 59 29 60 - // convert :name to ? and extract param names 30 + parseParams(sql, &result); 31 + parseColumns(sql, &result); 32 + 33 + return result; 34 + } 35 + 36 + fn parseParams(comptime sql: []const u8, result: *ParseResult) void { 61 37 var i: usize = 0; 62 38 while (i < sql.len) : (i += 1) { 63 39 if (sql[i] == '?') { ··· 80 56 result.positional_len += 1; 81 57 } 82 58 } 59 + } 83 60 84 - // extract columns from SELECT 85 - const select_start = findSelectStart(sql); 86 - if (select_start) |start| { 87 - const from_pos = findFromPos(sql, start) orelse sql.len; 88 - const cols_str = std.mem.trim(u8, sql[start..from_pos], " \t\n\r"); 61 + fn parseColumns(comptime sql: []const u8, result: *ParseResult) void { 62 + const select_start = findSelectStart(sql) orelse return; 63 + const from_pos = findFromPos(sql, select_start) orelse sql.len; 89 64 90 - if (cols_str.len > 0 and !std.mem.eql(u8, cols_str, "*")) { 91 - var col_i: usize = 0; 92 - var paren_depth: usize = 0; 65 + // work directly with sql and offset, not a sub-slice 66 + const cols_start = select_start + countLeadingWhitespace(sql[select_start..from_pos]); 67 + const cols_end = from_pos - countTrailingWhitespace(sql[select_start..from_pos]); 93 68 94 - while (col_i < cols_str.len) { 95 - while (col_i < cols_str.len and isWhitespace(cols_str[col_i])) : (col_i += 1) {} 96 - if (col_i >= cols_str.len) break; 69 + if (cols_start >= cols_end) return; 70 + if (std.mem.eql(u8, sql[cols_start..cols_end], "*")) return; 97 71 98 - var last_ident_start: ?usize = null; 99 - var last_ident_end: ?usize = null; 72 + var col_i: usize = cols_start; 73 + var paren_depth: usize = 0; 100 74 101 - while (col_i < cols_str.len) : (col_i += 1) { 102 - const c = cols_str[col_i]; 103 - if (c == '(') { 104 - paren_depth += 1; 105 - } else if (c == ')') { 106 - paren_depth -|= 1; 107 - } else if (c == ',' and paren_depth == 0) { 108 - break; 109 - } else if (isIdentStart(c) and paren_depth == 0) { 110 - last_ident_start = col_i; 111 - while (col_i < cols_str.len and isIdentChar(cols_str[col_i])) : (col_i += 1) {} 112 - last_ident_end = col_i; 113 - col_i -= 1; 114 - } 115 - } 75 + while (col_i < cols_end) { 76 + while (col_i < cols_end and isWhitespace(sql[col_i])) : (col_i += 1) {} 77 + if (col_i >= cols_end) break; 116 78 117 - if (last_ident_start) |s| { 118 - result.columns[result.columns_len] = cols_str[s..last_ident_end.?]; 119 - result.columns_len += 1; 120 - } 79 + var last_ident_start: ?usize = null; 80 + var last_ident_end: ?usize = null; 121 81 122 - if (col_i < cols_str.len and cols_str[col_i] == ',') col_i += 1; 82 + while (col_i < cols_end) : (col_i += 1) { 83 + const c = sql[col_i]; 84 + if (c == '(') { 85 + paren_depth += 1; 86 + } else if (c == ')') { 87 + paren_depth -|= 1; 88 + } else if (c == ',' and paren_depth == 0) { 89 + break; 90 + } else if (isIdentStart(c) and paren_depth == 0) { 91 + last_ident_start = col_i; 92 + while (col_i < cols_end and isIdentChar(sql[col_i])) : (col_i += 1) {} 93 + last_ident_end = col_i; 94 + col_i -= 1; 123 95 } 124 96 } 97 + 98 + if (last_ident_start) |s| { 99 + result.columns[result.columns_len] = sql[s..last_ident_end.?]; 100 + result.columns_len += 1; 101 + } 102 + 103 + if (col_i < cols_end and sql[col_i] == ',') col_i += 1; 125 104 } 105 + } 126 106 127 - return result; 107 + fn countLeadingWhitespace(s: []const u8) usize { 108 + var i: usize = 0; 109 + while (i < s.len and isWhitespace(s[i])) : (i += 1) {} 110 + return i; 111 + } 112 + 113 + fn countTrailingWhitespace(s: []const u8) usize { 114 + var i: usize = 0; 115 + while (i < s.len and isWhitespace(s[s.len - 1 - i])) : (i += 1) {} 116 + return i; 128 117 } 129 118 130 119 fn isIdentStart(c: u8) bool { ··· 167 156 return null; 168 157 } 169 158 170 - // tests 171 - 172 - test "Query - named params" { 173 - const Q = Query("SELECT * FROM users WHERE id = :id AND age > :min_age"); 174 - try std.testing.expectEqual(2, Q.params.len); 175 - try std.testing.expectEqualStrings("id", Q.params[0]); 176 - try std.testing.expectEqualStrings("min_age", Q.params[1]); 177 - } 178 - 179 - test "Query - positional conversion" { 180 - const Q = Query("SELECT * FROM users WHERE id = :id AND age > :min_age"); 181 - try std.testing.expectEqualStrings("SELECT * FROM users WHERE id = ? AND age > ?", Q.positional); 182 - } 183 - 184 - test "Query - columns" { 185 - const Q = Query("SELECT id, name, age FROM users"); 186 - try std.testing.expectEqual(3, Q.columns.len); 187 - try std.testing.expectEqualStrings("id", Q.columns[0]); 188 - try std.testing.expectEqualStrings("name", Q.columns[1]); 189 - try std.testing.expectEqualStrings("age", Q.columns[2]); 190 - } 159 + test "parse columns" { 160 + const result = comptime parse("SELECT id, name, age FROM users"); 161 + try std.testing.expectEqual(3, result.columns_len); 162 + try std.testing.expectEqualStrings("id", result.columns[0]); 163 + try std.testing.expectEqualStrings("name", result.columns[1]); 164 + try std.testing.expectEqualStrings("age", result.columns[2]); 191 165 192 - test "Query - columns with alias" { 193 - const Q = Query("SELECT id, first_name AS name FROM users"); 194 - try std.testing.expectEqual(2, Q.columns.len); 195 - try std.testing.expectEqualStrings("id", Q.columns[0]); 196 - try std.testing.expectEqualStrings("name", Q.columns[1]); 197 - } 198 - 199 - test "Query - columns with function" { 200 - const Q = Query("SELECT COUNT(*) AS count, MAX(age) AS max_age FROM users"); 201 - try std.testing.expectEqual(2, Q.columns.len); 202 - try std.testing.expectEqualStrings("count", Q.columns[0]); 203 - try std.testing.expectEqualStrings("max_age", Q.columns[1]); 204 - } 205 - 206 - test "Query - param extraction for INSERT" { 207 - const Q = Query("INSERT INTO users (name, age) VALUES (:name, :age)"); 208 - try std.testing.expectEqual(2, Q.params.len); 209 - try std.testing.expectEqualStrings("name", Q.params[0]); 210 - try std.testing.expectEqualStrings("age", Q.params[1]); 211 - } 212 - 213 - test "Query - no params" { 214 - const Q = Query("SELECT * FROM users"); 215 - try std.testing.expectEqual(0, Q.params.len); 216 - try std.testing.expectEqual(0, Q.param_count); 166 + // test slicing works correctly 167 + const cols: []const []const u8 = result.columns[0..result.columns_len]; 168 + try std.testing.expectEqual(3, cols.len); 169 + try std.testing.expectEqualStrings("id", cols[0]); 217 170 }
+17 -28
src/root.zig
··· 1 - //! zql - comptime sql ergonomics for zig 1 + //! zql - comptime sql ergonomics for zig (alpha) 2 2 //! 3 - //! backend-agnostic sql library with: 4 - //! - comptime parameter validation 5 - //! - comptime column name extraction 6 - //! - zero-overhead named column access 7 - //! - struct mapping 3 + //! status: alpha - api may change 8 4 //! 9 - //! example: 5 + //! usage: 10 6 //! ```zig 11 - //! const db = try zql.open(MyDriver, .{ .url = "..." }); 7 + //! const zql = @import("zql"); 12 8 //! 13 - //! // named params - validated at comptime 14 - //! try db.exec("INSERT INTO users (name, age) VALUES (:name, :age)", .{ 15 - //! .name = "bob", 16 - //! .age = 30, 17 - //! }); 9 + //! const Q = zql.Query("SELECT id, name FROM users WHERE age > :min_age"); 18 10 //! 19 - //! // query with typed rows 20 - //! var rows = try db.query("SELECT id, name, age FROM users WHERE age > :min", .{ .min = 18 }); 21 - //! defer rows.deinit(); 11 + //! // comptime validation 12 + //! Q.validateArgs(struct { min_age: i64 }); 22 13 //! 23 - //! while (rows.next()) |row| { 24 - //! // named column access - validated at comptime 25 - //! const name = row.text(.name); 26 - //! const age = row.int(.age); 27 - //! } 14 + //! // access parsed metadata 15 + //! _ = Q.positional; // "SELECT id, name FROM users WHERE age > ?" 16 + //! _ = Q.params; // ["min_age"] 17 + //! _ = Q.columns; // ["id", "name"] 28 18 //! 29 - //! // or map directly to struct 30 - //! const User = struct { id: i64, name: []const u8, age: ?i64 }; 31 - //! const users = try db.queryAs(User, "SELECT id, name, age FROM users", .{}); 19 + //! // struct mapping 20 + //! const User = struct { id: i64, name: []const u8 }; 21 + //! Q.validateStruct(User); 22 + //! const user = Q.fromRow(User, row_data); 32 23 //! ``` 33 24 34 - const std = @import("std"); 35 - 36 - pub const parse = @import("parse.zig"); 25 + pub const Query = @import("Query.zig").Query; 37 26 38 27 test { 39 - _ = parse; 28 + _ = @import("Query.zig"); 40 29 }