storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+748
View File
@@ -0,0 +1,748 @@
//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
//! file calls SQLite directly.
//!
//! **This file takes no `std.Io`.** It is the one deliberate exception to
//! Decision E. SQLite performs its own file I/O through its VFS; routing it
//! through `std.Io` would mean writing a custom SQLite VFS — a large,
//! security-sensitive component bought for nothing at household scale. Every
//! other storage file that touches the filesystem takes `io: std.Io`.
//!
//! The C API is declared by hand below. No `@cImport` — the handles stay
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
const std = @import("std");
const assert = std.debug.assert;
const log = std.log.scoped(.db);
pub const c = struct {
pub const Sqlite3 = opaque {};
pub const Stmt = opaque {};
/// The C prototype is a function pointer, but the only value nxdns passes
/// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function
/// address — a Zig fn-pointer type would reject it on targets with aligned
/// function pointers (aarch64). `?*anyopaque` is ABI-identical.
pub const Destructor = ?*anyopaque;
/// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately.
pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));
pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int;
pub extern fn sqlite3_step(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int;
pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int;
pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int;
pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int;
pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
pub const result = struct {
pub const ok: c_int = 0;
pub const err: c_int = 1;
pub const internal: c_int = 2;
pub const perm: c_int = 3;
pub const abort: c_int = 4;
pub const busy: c_int = 5;
pub const locked: c_int = 6;
pub const nomem: c_int = 7;
pub const readonly: c_int = 8;
pub const interrupt: c_int = 9;
pub const ioerr: c_int = 10;
pub const corrupt: c_int = 11;
pub const notfound: c_int = 12;
pub const full: c_int = 13;
pub const cantopen: c_int = 14;
pub const protocol: c_int = 15;
pub const empty: c_int = 16;
pub const schema: c_int = 17;
pub const toobig: c_int = 18;
pub const constraint: c_int = 19;
pub const mismatch: c_int = 20;
pub const misuse: c_int = 21;
pub const nolfs: c_int = 22;
pub const auth: c_int = 23;
pub const format: c_int = 24;
pub const range: c_int = 25;
pub const notadb: c_int = 26;
pub const row: c_int = 100;
pub const done: c_int = 101;
};
/// Open flags, from the vendored `sqlite3.h` (3.53.4).
pub const open_flag = struct {
pub const readonly: c_int = 0x1;
pub const readwrite: c_int = 0x2;
pub const create: c_int = 0x4;
pub const uri: c_int = 0x40;
pub const nomutex: c_int = 0x8000;
pub const fullmutex: c_int = 0x10000;
pub const exrescode: c_int = 0x2000000;
};
/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
pub const integer: c_int = 1;
pub const float: c_int = 2;
pub const text: c_int = 3;
pub const blob: c_int = 4;
pub const null_value: c_int = 5;
};
pub const Error = error{
Abort,
Auth,
Busy,
CantOpen,
Constraint,
Corrupt,
Empty,
Format,
Full,
Internal,
Interrupt,
IoErr,
Locked,
Mismatch,
Misuse,
NoLfs,
NotADb,
NotFound,
Perm,
Protocol,
Range,
ReadOnly,
Schema,
TooBig,
SqliteError,
OutOfMemory,
Unexpected,
};
/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes
/// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of
/// memory is never the data's fault.
///
/// The switch runs on the primary code (`code & 0xff`), so every extended code
/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on
/// its family. The extended code stays visible to humans through `Db.lastError`.
///
/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to
/// `error.SqliteError`. `SqliteError` is reserved for a primary code this
/// function does not know, so an unmapped future code stays distinguishable
/// from an ordinary SQL error.
pub fn mapCode(code: c_int) Error {
const primary = code & 0xff;
assert(primary != result.ok);
assert(primary != result.row);
assert(primary != result.done);
return switch (primary) {
result.err => error.Unexpected,
result.internal => error.Internal,
result.perm => error.Perm,
result.abort => error.Abort,
result.busy => error.Busy,
result.locked => error.Locked,
result.nomem => error.OutOfMemory,
result.readonly => error.ReadOnly,
result.interrupt => error.Interrupt,
result.ioerr => error.IoErr,
result.corrupt => error.Corrupt,
result.notfound => error.NotFound,
result.full => error.Full,
result.cantopen => error.CantOpen,
result.protocol => error.Protocol,
result.empty => error.Empty,
result.schema => error.Schema,
result.toobig => error.TooBig,
result.constraint => error.Constraint,
result.mismatch => error.Mismatch,
result.misuse => error.Misuse,
result.nolfs => error.NoLfs,
result.auth => error.Auth,
result.format => error.Format,
result.range => error.Range,
result.notadb => error.NotADb,
else => error.SqliteError,
};
}
fn check(code: c_int) Error!void {
if (code == result.ok) return;
return mapCode(code);
}
pub const OpenMode = enum { read_write_create, read_write_existing, read_only, memory };
pub const OpenOptions = struct {
mode: OpenMode = .read_write_create,
busy_timeout_ms: c_int = 5000,
};
/// One SQLite connection.
///
/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt`
/// holds a `*Db`.
pub const Db = struct {
handle: *c.Sqlite3,
/// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger
/// and Phase 8's API handlers share one handle across `std.Io` tasks, and a
/// per-handle mutex inside SQLite is cheaper to be correct about than a
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
/// makes `sqlite3_extended_errcode` meaningful from the first call.
///
/// `open` deliberately applies no pragmas — see `applyPragmas`, which the
/// migration runner must call before it opens a transaction.
///
/// For `.memory`, `path` is ignored and `":memory:"` is used.
pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db {
const base = open_flag.exrescode | open_flag.fullmutex;
const flags: c_int = switch (options.mode) {
.read_write_create, .memory => base | open_flag.readwrite | open_flag.create,
.read_write_existing => base | open_flag.readwrite,
.read_only => base | open_flag.readonly,
};
const filename: [:0]const u8 = switch (options.mode) {
.memory => ":memory:",
else => path,
};
var handle: ?*c.Sqlite3 = null;
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
if (rc != result.ok) {
// sqlite3_open_v2 allocates a handle even on failure. Read the
// message from it, then close it; dropping it leaks on every
// failed open.
// Logged at `warn`, not `err`: the failure itself reaches the
// caller as a typed error, and this line only carries the message
// that would otherwise die with the handle.
if (handle) |h| {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{
filename,
std.mem.span(c.sqlite3_errmsg(h)),
rc & 0xff,
c.sqlite3_extended_errcode(h),
});
_ = c.sqlite3_close_v2(h);
} else {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{
filename,
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
return mapCode(rc);
}
const h = handle orelse return error.SqliteError;
// A silently ignored busy timeout is how a contended WAL database turns
// into random SQLITE_BUSY failures under load.
check(c.sqlite3_busy_timeout(h, options.busy_timeout_ms)) catch |e| {
_ = c.sqlite3_close_v2(h);
return e;
};
return .{ .handle = h };
}
pub fn close(self: *Db) void {
const rc = c.sqlite3_close_v2(self.handle);
if (rc != result.ok) {
log.err("sqlite3_close_v2 returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
/// Borrowed; valid until the next SQLite call on this handle. Formats as
/// "<message> (code <primary>/<extended>)".
pub fn lastError(self: *Db, buf: []u8) []const u8 {
const extended = c.sqlite3_extended_errcode(self.handle);
const message = std.mem.span(c.sqlite3_errmsg(self.handle));
return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{
message,
extended & 0xff,
extended,
}) catch "sqlite error (message did not fit the buffer)";
}
/// For DDL and multi-statement scripts. `errmsg` is passed as null and the
/// message is read back through `sqlite3_errmsg`, so there is no
/// `sqlite3_free` obligation.
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
}
/// `sql` must hold exactly one statement; text with a second statement in it
/// is `error.Misuse` and belongs in `exec`.
pub fn prepare(self: *Db, sql: []const u8) Error!Stmt {
if (sql.len > std.math.maxInt(c_int)) return error.TooBig;
var handle: ?*c.Stmt = null;
var tail: ?[*]const u8 = null;
try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail));
const h = handle orelse return error.Misuse;
const tail_ptr = tail orelse sql.ptr + sql.len;
const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr);
const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n");
if (remaining.len != 0) {
_ = c.sqlite3_finalize(h);
return error.Misuse;
}
return .{ .handle = h, .db = self };
}
/// Runs `sql` (which must yield exactly one row with one integer column) and
/// returns it. A statement that produces no row is `error.SqliteError`.
///
/// The row shape is verified, not assumed: a result with a column count
/// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text,
/// float and blob all count), or a second row is `error.Misuse`. That is the
/// same member `prepare` returns for a caller that hands it the wrong SQL,
/// because these are the same class of fault — a caller bug or schema drift,
/// never a runtime condition. Without the checks a `SELECT` of the wrong
/// column silently returns 0.
pub fn queryInt(self: *Db, sql: []const u8) Error!i64 {
var stmt = try self.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) {
log.warn("queryInt produced no row for '{s}'", .{sql});
return error.SqliteError;
}
const columns = c.sqlite3_column_count(stmt.handle);
if (columns != 1) {
log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql });
return error.Misuse;
}
const kind = c.sqlite3_column_type(stmt.handle, 0);
if (kind != column_type.integer) {
log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql });
return error.Misuse;
}
const value = stmt.columnInt(0);
if (try stmt.step()) {
log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql});
return error.Misuse;
}
return value;
}
pub fn lastInsertRowid(self: *Db) i64 {
return c.sqlite3_last_insert_rowid(self.handle);
}
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
};
/// One prepared statement.
///
/// There is deliberately **no prepared-statement cache in this milestone**.
/// `config.db` is written a handful of times per process lifetime, so a cache is
/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot
/// path and it owns its own long-lived statements. This is a decision, not an
/// oversight against PLAN §3.4.
pub const Stmt = struct {
handle: *c.Stmt,
db: *Db,
/// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and
/// `sqlite3_finalize` both re-report that code; without this the caller
/// would see one failure logged as a second, unrelated one.
pending_error: c_int = result.ok,
pub fn deinit(self: *Stmt) void {
const rc = c.sqlite3_finalize(self.handle);
if (rc != result.ok and rc != self.pending_error) {
log.err("sqlite3_finalize returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
pub fn reset(self: *Stmt) Error!void {
const rc = c.sqlite3_reset(self.handle);
self.pending_error = result.ok;
try check(rc);
try check(c.sqlite3_clear_bindings(self.handle));
}
/// 1-based, matching SQLite.
pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void {
return check(c.sqlite3_bind_int64(self.handle, idx, value));
}
pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void {
return self.bindInt(idx, if (value) 1 else 0);
}
/// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller
/// never has to keep `value` alive. The copy costs an allocation per bind;
/// at config.db volumes that is invisible, and it removes a whole class of
/// use-after-free from every caller.
pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void {
if (value.len > std.math.maxInt(c_int)) return error.TooBig;
return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient));
}
pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void {
if (value) |v| return self.bindText(idx, v);
return self.bindNull(idx);
}
pub fn bindNull(self: *Stmt, idx: c_int) Error!void {
return check(c.sqlite3_bind_null(self.handle, idx));
}
/// true = a row is available, false = the statement finished.
pub fn step(self: *Stmt) Error!bool {
const rc = c.sqlite3_step(self.handle);
if (rc == result.row) return true;
if (rc == result.done) return false;
self.pending_error = rc;
return mapCode(rc);
}
/// Runs to completion; asserts no rows were produced.
pub fn exec(self: *Stmt) Error!void {
const has_row = try self.step();
assert(!has_row);
}
pub fn columnInt(self: *Stmt, col: c_int) i64 {
return c.sqlite3_column_int64(self.handle, col);
}
pub fn columnBool(self: *Stmt, col: c_int) bool {
return self.columnInt(col) != 0;
}
pub fn isNull(self: *Stmt, col: c_int) bool {
return c.sqlite3_column_type(self.handle, col) == column_type.null_value;
}
/// Borrowed: valid only until the next `step`, `reset` or `deinit` on this
/// statement. Every caller that keeps the value must copy it.
///
/// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable
/// in practice, but it must not be undefined behaviour.
pub fn columnText(self: *Stmt, col: c_int) []const u8 {
const ptr = c.sqlite3_column_text(self.handle, col) orelse return "";
const len = c.sqlite3_column_bytes(self.handle, col);
if (len <= 0) return "";
return ptr[0..@intCast(len)];
}
/// Borrowed under the same rules as `columnText`; NULL reads as `null`.
pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 {
if (self.isNull(col)) return null;
return self.columnText(col);
}
/// Copies into `gpa`. Caller owns the result.
pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 {
return gpa.dupe(u8, self.columnText(col));
}
/// Copies into `gpa`. Caller owns the result. NULL reads as `null`.
pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 {
const value = self.columnTextOrNull(col) orelse return null;
return try gpa.dupe(u8, value);
}
};
pub const Pragmas = struct {
journal_wal: bool = true,
synchronous_normal: bool = true,
foreign_keys: bool = true,
};
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
/// no-op inside a transaction, so applying it later silently leaves referential
/// integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
if (p.journal_wal) {
// The pragma returns a row holding the mode it actually reached. `exec`
// would discard that answer, and an in-memory database — which cannot do
// WAL — would look fine.
var stmt = try self.prepare("PRAGMA journal_mode = WAL");
defer stmt.deinit();
if (!try stmt.step()) return error.SqliteError;
const mode = stmt.columnText(0);
const wal = std.ascii.eqlIgnoreCase(mode, "wal");
const memory = std.ascii.eqlIgnoreCase(mode, "memory");
if (!wal and !memory) {
log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode});
return error.SqliteError;
}
}
if (p.synchronous_normal) {
try self.exec("PRAGMA synchronous = NORMAL;");
}
if (p.foreign_keys) {
try self.exec("PRAGMA foreign_keys = ON;");
if (try self.queryInt("PRAGMA foreign_keys") != 1) {
log.warn("PRAGMA foreign_keys did not take", .{});
return error.SqliteError;
}
}
}
/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
///
/// ```zig
/// var tx = try Tx.begin(db);
/// errdefer tx.rollback();
/// ... // all writes
/// try tx.commit();
/// ```
///
/// `commit` and `rollback` both clear `active`, so the `errdefer` after a
/// successful commit is a no-op.
pub const Tx = struct {
db: *Db,
active: bool,
/// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction
/// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work;
/// immediate cannot.
pub fn begin(db: *Db) Error!Tx {
try db.exec("BEGIN IMMEDIATE;");
return .{ .db = db, .active = true };
}
pub fn commit(self: *Tx) Error!void {
assert(self.active);
try self.db.exec("COMMIT;");
self.active = false;
}
/// Safe in `errdefer` and after `commit`. Never returns an error; a failed
/// ROLLBACK is logged at `err` level with the SQLite message, because a
/// database that will not roll back is an operational event, not a detail.
pub fn rollback(self: *Tx) void {
if (!self.active) return;
self.active = false;
self.db.exec("ROLLBACK;") catch {
var buf: [256]u8 = undefined;
log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)});
};
}
};
const testing = std.testing;
fn openMemory() Error!Db {
return Db.open(":memory:", .{ .mode = .memory });
}
test "mapCode maps every primary result code to a distinct error" {
var seen: [26]Error = undefined;
var code: c_int = 1;
while (code <= 26) : (code += 1) {
seen[@intCast(code - 1)] = mapCode(code);
}
for (seen, 0..) |a, i| {
for (seen[i + 1 ..]) |b| {
try testing.expect(a != b);
}
}
}
test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" {
try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem));
// SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067.
try testing.expectEqual(Error.IoErr, mapCode(266));
try testing.expectEqual(Error.Constraint, mapCode(2067));
// A primary code this build does not know stays visible as SqliteError.
try testing.expectEqual(Error.SqliteError, mapCode(99));
}
test "open and close an in-memory database" {
var db = try openMemory();
defer db.close();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1"));
}
test "queryInt rejects a result that is not exactly one row of one integer" {
var db = try openMemory();
defer db.close();
// No row keeps the documented error.SqliteError.
try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0"));
// Wrong column count.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2"));
// Wrong column type: NULL, text, float and blob are all rejected.
try testing.expectError(error.Misuse, db.queryInt("SELECT NULL"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5"));
try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'"));
// A second row.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2"));
}
test "queryInt accepts a single integer row after the shape checks" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');");
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t"));
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3"));
// sum() over an empty table is NULL, not an integer: a caller that wants a
// total from a possibly-empty table must write total(), or COALESCE.
try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0"));
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t"));
}
test "applyPragmas succeeds and foreign_keys reads back as 1" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}
test "open on a directory path returns error.CantOpen and leaks no handle" {
var i: usize = 0;
while (i < 1000) : (i += 1) {
try testing.expectError(error.CantOpen, Db.open(".", .{}));
}
}
test "prepare rejects text holding more than one statement" {
var db = try openMemory();
defer db.close();
try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2"));
var stmt = try db.prepare("SELECT 1;");
stmt.deinit();
}
test "bind, step and column round-trip including a NULL text column" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);");
var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)");
defer insert.deinit();
try insert.bindText(1, "kitchen");
try insert.bindTextOrNull(2, null);
try insert.bindBool(3, true);
try insert.exec();
try testing.expectEqual(@as(i64, 1), db.changes());
try testing.expectEqual(@as(i64, 1), db.lastInsertRowid());
var select = try db.prepare("SELECT id, name, note, flag FROM t");
defer select.deinit();
try testing.expect(try select.step());
try testing.expectEqual(@as(i64, 1), select.columnInt(0));
try testing.expectEqualStrings("kitchen", select.columnText(1));
try testing.expect(select.isNull(2));
try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2));
try testing.expectEqualStrings("", select.columnText(2));
try testing.expect(select.columnBool(3));
try testing.expect(!try select.step());
}
test "columnTextAlloc returns an owned copy that survives a subsequent step" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');");
var stmt = try db.prepare("SELECT name FROM t ORDER BY id");
defer stmt.deinit();
try testing.expect(try stmt.step());
const owned = try stmt.columnTextAlloc(testing.allocator, 0);
defer testing.allocator.free(owned);
const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0);
defer if (owned_or_null) |v| testing.allocator.free(v);
try testing.expect(try stmt.step());
try testing.expectEqualStrings("second", stmt.columnText(0));
try testing.expectEqualStrings("first", owned);
try testing.expectEqualStrings("first", owned_or_null.?);
}
test "transaction commit persists and rollback discards" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
{
var tx = try Tx.begin(&db);
errdefer tx.rollback();
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
{
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (2);");
tx.rollback();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "rollback after commit is a no-op" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
try testing.expect(!tx.active);
tx.rollback();
tx.rollback();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "a row-producing statement reports its row through step" {
var db = try openMemory();
defer db.close();
// Stmt.exec asserts on this shape; the test observes it through `step`
// instead, so the assertion path stays out of the test binary.
var stmt = try db.prepare("SELECT 1");
defer stmt.deinit();
try testing.expect(try stmt.step());
}
test "a duplicate insert into a UNIQUE column returns error.Constraint" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);");
try db.exec("INSERT INTO t (name) VALUES ('only');");
var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)");
defer stmt.deinit();
try stmt.bindText(1, "only");
try testing.expectError(error.Constraint, stmt.step());
}