storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user