Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
1507 lines
61 KiB
Zig
1507 lines
61 KiB
Zig
//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
|
|
//! file calls SQLite directly.
|
|
//!
|
|
//! **SQLite's own file I/O 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 exception covers SQLite, not nxdns. The one place this file does its own
|
|
//! filesystem calls — the probes guarding `OpenMode.immutable`, at the open and
|
|
//! again at `Db.verifyImmutable` — follows the ordinary rule and takes an
|
|
//! `io: std.Io`, which is why that mode carries one.
|
|
//!
|
|
//! 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 builtin = @import("builtin");
|
|
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;
|
|
pub extern fn sqlite3_total_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,
|
|
/// Not a SQLite result code. An `OpenMode.immutable` read would have
|
|
/// answered from a stale main file, because `<path>-wal` holds bytes or the
|
|
/// files moved while the read ran. Raised by the open and again by
|
|
/// `Db.verifyImmutable`. See `OpenMode.immutable`.
|
|
WalPending,
|
|
};
|
|
|
|
/// 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 = union(enum) {
|
|
read_write_create,
|
|
read_write_existing,
|
|
read_only,
|
|
memory,
|
|
/// Read a database that this process promises not to change, and that no
|
|
/// writer may be touching: `SQLITE_OPEN_READONLY` plus the `immutable=1` URI
|
|
/// parameter, which makes the pager treat the file like a temp file — no
|
|
/// locking, no rollback journal, no wal-index — so **no `-wal` and no `-shm`
|
|
/// appear beside it**.
|
|
///
|
|
/// This mode exists for `nxdns check` (milestone-13 ruling F-c), which must
|
|
/// validate without writing. `.read_only` alone is not enough, and this is
|
|
/// measured, not assumed: reading a database whose header says WAL makes
|
|
/// SQLite build the wal-index, so `config.db-wal` (0 bytes) and
|
|
/// `config.db-shm` (32 KiB) are created, and a read-only connection cannot
|
|
/// remove them on close. A command that claims to write nothing must not
|
|
/// leave two files behind. Do not "simplify" this back to `.read_only`.
|
|
///
|
|
/// What `immutable=1` costs: SQLite then **ignores any `-wal` file**. The
|
|
/// newest committed rows live there, so an immutable read of a database with
|
|
/// an un-checkpointed WAL would answer from stale data and say nothing — a
|
|
/// worse failure than the two sidecar files it removes. `Db.open` therefore
|
|
/// refuses this mode with `error.WalPending` whenever `<path>-wal` exists and
|
|
/// is not empty; the caller reports that as a failure and names `nxdns run`,
|
|
/// which opens read-write and checkpoints, as the fix.
|
|
///
|
|
/// The guard lives inside `open` rather than in a helper callers are trusted
|
|
/// to call, because the failure it prevents is silent: a caller that forgets
|
|
/// a helper gets a plausible wrong answer, and nothing anywhere reports it.
|
|
///
|
|
/// **The open is half the guard.** `immutable=1` takes no lock, so nothing
|
|
/// keeps a writer out for the duration of the read, and a check made only
|
|
/// before the read can say only that the log was empty *then*.
|
|
/// `Db.verifyImmutable` makes the other half, and a caller that grades what
|
|
/// it read without calling it is back to the stale answer this mode exists
|
|
/// to refuse.
|
|
///
|
|
/// A zero-length `-wal` does not block the open: it holds no frames, so the
|
|
/// main file is complete. That is the exact leftover the pre-F-c `check`
|
|
/// used to create.
|
|
///
|
|
/// The `std.Io` is for that probe — the one filesystem call nxdns itself
|
|
/// makes in this file. Passing it is what makes the guard unskippable.
|
|
immutable: std.Io,
|
|
};
|
|
|
|
pub const OpenOptions = struct {
|
|
mode: OpenMode = .read_write_create,
|
|
busy_timeout_ms: c_int = 5000,
|
|
};
|
|
|
|
/// SQLite's name for the write-ahead log beside `<path>`. Exported so a caller
|
|
/// reporting `error.WalPending` can name the file without hard-coding SQLite's
|
|
/// naming convention, which this file owns.
|
|
pub const wal_suffix = "-wal";
|
|
|
|
/// 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,
|
|
/// Set by `OpenMode.immutable` and null in every other mode: what the
|
|
/// database file and its `-wal` looked like when the read began, for
|
|
/// `verifyImmutable` to compare against when it ends.
|
|
immutable_guard: ?ImmutableGuard = null,
|
|
|
|
/// Every mode carries `FULLMUTEX` (serialized mode). The query logger and
|
|
/// the web 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,
|
|
// Its own function: the URI buffer is 12 KiB, and every other open
|
|
// in the process would carry it in this frame.
|
|
.immutable => |io| return openImmutable(io, path, options.busy_timeout_ms),
|
|
};
|
|
const filename: [:0]const u8 = switch (options.mode) {
|
|
.memory => ":memory:",
|
|
else => path,
|
|
};
|
|
|
|
const h = try openHandle(filename, flags);
|
|
return applyBusyTimeout(h, options.busy_timeout_ms);
|
|
}
|
|
|
|
/// Proves that the files an `OpenMode.immutable` read answered from stood
|
|
/// still while it ran, and fails the read with `error.WalPending` when they
|
|
/// did not. Call it after the last read and before anything read is
|
|
/// reported.
|
|
///
|
|
/// `immutable=1` takes no lock at all — that is what stops the pager
|
|
/// building a wal-index, and the price is that nothing keeps a writer out.
|
|
/// The probe at open time can only say the log was empty at that instant: a
|
|
/// writer that appends one frame the instant after leaves the read answering
|
|
/// from the older pages of the main file, silently, which is the whole
|
|
/// failure `OpenMode.immutable`'s guard exists to prevent.
|
|
///
|
|
/// Two things are compared, because a writer can hide in either:
|
|
///
|
|
/// - `<path>-wal` holding bytes now. A log that appeared, one that grew, and
|
|
/// one written into the empty file the open accepted all land here.
|
|
/// - `<path>` itself moving — size, inode, mtime or ctime. This is the
|
|
/// checkpoint the log cannot show: a writer that checkpointed into the main
|
|
/// file and truncated its log back to nothing leaves both stats saying "no
|
|
/// frames" while the pages the read saw have been replaced.
|
|
///
|
|
/// Best effort, and honestly so: a filesystem with coarse timestamps can
|
|
/// hide a rewrite that lands on the same byte count in the same tick. That
|
|
/// cannot be fixed from outside SQLite's locking, and taking a lock is the
|
|
/// one thing this mode may not do. What it closes is the window a single
|
|
/// stat before the read leaves open for the whole of the read.
|
|
///
|
|
/// Two stats and nothing else: no `-wal` or `-shm` is created, and neither
|
|
/// sidecar is removed or truncated. Calling this on a handle opened in any
|
|
/// other mode is a caller bug.
|
|
pub fn verifyImmutable(self: *Db) Error!void {
|
|
const guard = self.immutable_guard orelse unreachable;
|
|
const now = try markImmutable(guard.io, guard.path);
|
|
if (!std.meta.eql(now, guard.mark)) {
|
|
log.warn(
|
|
"'{s}' changed while it was being read without a lock; the read is not trustworthy",
|
|
.{guard.path},
|
|
);
|
|
return error.WalPending;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// Every row this connection has inserted, updated or deleted since it was
|
|
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
|
|
/// reading it either side and comparing — which is stronger than comparing
|
|
/// content, because an UPDATE that rewrites identical values still moves
|
|
/// this counter.
|
|
pub fn totalChanges(self: *Db) i64 {
|
|
return c.sqlite3_total_changes(self.handle);
|
|
}
|
|
};
|
|
|
|
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
|
|
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);
|
|
}
|
|
return handle orelse error.SqliteError;
|
|
}
|
|
|
|
/// A silently ignored busy timeout is how a contended WAL database turns into
|
|
/// random SQLITE_BUSY failures under load.
|
|
fn applyBusyTimeout(h: *c.Sqlite3, busy_timeout_ms: c_int) Error!Db {
|
|
check(c.sqlite3_busy_timeout(h, busy_timeout_ms)) catch |e| {
|
|
_ = c.sqlite3_close_v2(h);
|
|
return e;
|
|
};
|
|
return .{ .handle = h };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenMode.immutable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const uri_scheme = "file:";
|
|
const uri_immutable_query = "?immutable=1";
|
|
|
|
/// Worst case: every byte of the longest path the platform accepts becomes
|
|
/// `%HH`.
|
|
const immutable_uri_buf_len =
|
|
uri_scheme.len + 3 * std.Io.Dir.max_path_bytes + uri_immutable_query.len + 1;
|
|
|
|
fn openImmutable(io: std.Io, path: [:0]const u8, busy_timeout_ms: c_int) Error!Db {
|
|
const mark = try markImmutable(io, path);
|
|
|
|
var buf: [immutable_uri_buf_len]u8 = undefined;
|
|
const uri = try immutableUri(&buf, path);
|
|
|
|
// `uri` without `open_flag.uri` would be opened as a filename spelled
|
|
// "file:...", creating nothing and finding nothing.
|
|
const flags = open_flag.exrescode | open_flag.fullmutex |
|
|
open_flag.readonly | open_flag.uri;
|
|
const h = try openHandle(uri, flags);
|
|
var database = try applyBusyTimeout(h, busy_timeout_ms);
|
|
database.immutable_guard = .{ .io = io, .path = path, .mark = mark };
|
|
return database;
|
|
}
|
|
|
|
/// What `OpenMode.immutable` recorded at the start of a read so that
|
|
/// `Db.verifyImmutable` can prove nothing moved by the end of it.
|
|
pub const ImmutableGuard = struct {
|
|
io: std.Io,
|
|
/// Borrowed. Must outlive the `Db`, which every caller satisfies by owning
|
|
/// the path for at least as long as the connection it opened with it.
|
|
path: []const u8,
|
|
mark: FileMark,
|
|
};
|
|
|
|
/// The main database file at one instant, in the fields an outside observer can
|
|
/// compare cheaply. `atime` is deliberately absent: reading the file changes it,
|
|
/// so comparing it would report every read as a change.
|
|
///
|
|
/// All zero when the file does not exist, which is itself a state worth
|
|
/// comparing — a database replaced by an unlink is a database that moved.
|
|
const FileMark = struct {
|
|
present: bool,
|
|
size: u64,
|
|
inode: std.Io.File.INode,
|
|
mtime_ns: i96,
|
|
ctime_ns: i96,
|
|
};
|
|
|
|
/// The state an immutable read must find unchanged, or `error.WalPending` when
|
|
/// `<path>-wal` already holds bytes.
|
|
///
|
|
/// The `-wal` rule is deliberately conservative: any non-empty log fails.
|
|
/// Deciding whether it really holds committed frames means running WAL recovery
|
|
/// — checksums, salt, the wal-index — which is the writing that
|
|
/// `OpenMode.immutable` exists to avoid. A live writer, a crash, and a
|
|
/// checkpointed-but-retained log all land here, and refusing to answer is the
|
|
/// right side to err on: the alternative is a stale answer nobody can see is
|
|
/// stale. A zero-length log holds no frames, so the main file is complete and it
|
|
/// passes.
|
|
///
|
|
/// A failed stat is not "no WAL": it means this cannot be known, so it stays a
|
|
/// failure.
|
|
fn markImmutable(io: std.Io, path: []const u8) Error!FileMark {
|
|
var buf: [std.Io.Dir.max_path_bytes + wal_suffix.len]u8 = undefined;
|
|
const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, wal_suffix }) catch
|
|
return error.TooBig;
|
|
|
|
if (try statOrAbsent(io, sidecar)) |wal| {
|
|
if (wal.size > 0) return error.WalPending;
|
|
}
|
|
|
|
const main = try statOrAbsent(io, path) orelse return .{
|
|
.present = false,
|
|
.size = 0,
|
|
.inode = 0,
|
|
.mtime_ns = 0,
|
|
.ctime_ns = 0,
|
|
};
|
|
return .{
|
|
.present = true,
|
|
.size = main.size,
|
|
.inode = main.inode,
|
|
.mtime_ns = main.mtime.nanoseconds,
|
|
.ctime_ns = main.ctime.nanoseconds,
|
|
};
|
|
}
|
|
|
|
fn statOrAbsent(io: std.Io, path: []const u8) Error!?std.Io.Dir.Stat {
|
|
return std.Io.Dir.cwd().statFile(io, path, .{}) catch |e| switch (e) {
|
|
error.FileNotFound => return null,
|
|
else => {
|
|
log.warn("cannot stat '{s}': {t}", .{ path, e });
|
|
return error.IoErr;
|
|
},
|
|
};
|
|
}
|
|
|
|
/// `file:` + percent-encoded `path` + `?immutable=1`.
|
|
///
|
|
/// The encoding is load-bearing, not cosmetic. `?` opens SQLite's query section
|
|
/// and `#` its fragment, so an unencoded data directory named `dns?db` would
|
|
/// silently open a *different* file; `%` must be encoded because SQLite decodes
|
|
/// `%HH` on its way back to a filename. `--data-dir` is operator input, so all
|
|
/// three are reachable.
|
|
///
|
|
/// Everything outside the unreserved set (`A-Z a-z 0-9 - . _ ~ /`) is encoded,
|
|
/// which is always safe: SQLite decodes every escape in the path before handing
|
|
/// the name to its VFS, so the bytes it opens are the bytes passed in.
|
|
///
|
|
/// `/` stays literal to keep diagnostics readable, with one exception. SQLite
|
|
/// reads `file://…` as a URI authority and rejects any authority but the empty
|
|
/// one or `localhost` (`sqlite3ParseUri`), so a path beginning with `//` — legal
|
|
/// POSIX — has its second slash encoded.
|
|
fn immutableUri(buf: []u8, path: []const u8) error{TooBig}![:0]const u8 {
|
|
var out: usize = 0;
|
|
try appendSlice(buf, &out, uri_scheme);
|
|
for (path, 0..) |ch, i| {
|
|
const opens_authority = i == 1 and ch == '/' and path[0] == '/';
|
|
if (isUriUnreserved(ch) and !opens_authority) {
|
|
try appendByte(buf, &out, ch);
|
|
} else {
|
|
const hex = "0123456789ABCDEF";
|
|
try appendByte(buf, &out, '%');
|
|
try appendByte(buf, &out, hex[ch >> 4]);
|
|
try appendByte(buf, &out, hex[ch & 0xf]);
|
|
}
|
|
}
|
|
try appendSlice(buf, &out, uri_immutable_query);
|
|
try appendByte(buf, &out, 0);
|
|
return buf[0 .. out - 1 :0];
|
|
}
|
|
|
|
fn isUriUnreserved(ch: u8) bool {
|
|
return switch (ch) {
|
|
'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_', '~', '/' => true,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn appendByte(buf: []u8, out: *usize, ch: u8) error{TooBig}!void {
|
|
if (out.* == buf.len) return error.TooBig;
|
|
buf[out.*] = ch;
|
|
out.* += 1;
|
|
}
|
|
|
|
fn appendSlice(buf: []u8, out: *usize, bytes: []const u8) error{TooBig}!void {
|
|
for (bytes) |ch| try appendByte(buf, out, ch);
|
|
}
|
|
|
|
/// 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,
|
|
/// Null leaves SQLite's 1000-page default. A caller that sets it owns the
|
|
/// durability consequences, which depend on what the database holds.
|
|
wal_autocheckpoint_pages: ?i32 = null,
|
|
};
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
if (p.wal_autocheckpoint_pages) |pages| {
|
|
var buf: [64]u8 = undefined;
|
|
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA wal_autocheckpoint = {d};", .{pages}) catch unreachable;
|
|
try self.exec(sql);
|
|
const applied = try self.queryInt("PRAGMA wal_autocheckpoint");
|
|
if (applied != pages) {
|
|
log.warn("PRAGMA wal_autocheckpoint = {d} reported {d}", .{ pages, applied });
|
|
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)});
|
|
};
|
|
}
|
|
};
|
|
|
|
/// A read transaction: one consistent view of the file across several
|
|
/// statements.
|
|
///
|
|
/// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write
|
|
/// lock would stall the logger and retention for the length of an HTTP
|
|
/// response; a deferred transaction that only ever reads never upgrades, so it
|
|
/// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it
|
|
/// pins the snapshot at the first read, which is the point: an aggregate and the
|
|
/// coverage watermark beside it describe the same database state even when
|
|
/// retention prunes between them.
|
|
///
|
|
/// Ending it is fallible, and the caller must treat it that way. A connection
|
|
/// left inside a transaction refuses the next `BEGIN`, so a swallowed failure
|
|
/// here does not cost one response — it costs every later one on the same
|
|
/// connection, and they would each be a correct-looking answer over a foreign
|
|
/// snapshot or an outright error the client was never told about.
|
|
///
|
|
/// ```zig
|
|
/// var tx = try ReadTx.begin(db);
|
|
/// errdefer tx.rollback();
|
|
/// ... // reads only
|
|
/// try tx.commit();
|
|
/// ```
|
|
pub const ReadTx = struct {
|
|
db: *Db,
|
|
active: bool,
|
|
|
|
pub fn begin(db: *Db) Error!ReadTx {
|
|
try db.exec("BEGIN DEFERRED;");
|
|
return .{ .db = db, .active = true };
|
|
}
|
|
|
|
/// Ends the transaction, and says so. A read transaction has nothing to
|
|
/// conflict over, so a failed COMMIT means the connection is in a state
|
|
/// this code did not put it in: the ROLLBACK below is the attempt to hand
|
|
/// the next caller a usable connection anyway, and the error is returned so
|
|
/// the response it was serving fails rather than reporting success over a
|
|
/// database whose state nobody can name.
|
|
pub fn commit(self: *ReadTx) Error!void {
|
|
assert(self.active);
|
|
self.active = false;
|
|
self.execCommit() catch |err| {
|
|
self.reportFault("COMMIT");
|
|
self.forceRollback();
|
|
return err;
|
|
};
|
|
}
|
|
|
|
fn execCommit(self: *ReadTx) Error!void {
|
|
if (commitFaultTripped()) return error.Internal;
|
|
return self.db.exec("COMMIT;");
|
|
}
|
|
|
|
/// Safe in `errdefer` and after `commit`. Never returns an error: it runs
|
|
/// on the path where something has already gone wrong, and that error is
|
|
/// the one worth reporting.
|
|
pub fn rollback(self: *ReadTx) void {
|
|
if (!self.active) return;
|
|
self.active = false;
|
|
self.forceRollback();
|
|
}
|
|
|
|
/// A ROLLBACK that fails leaves the connection inside a transaction with no
|
|
/// way left to get it out. Every later `begin` on it fails, which is the
|
|
/// visible symptom this reports the cause of.
|
|
fn forceRollback(self: *ReadTx) void {
|
|
self.db.exec("ROLLBACK;") catch {
|
|
self.reportFault("ROLLBACK");
|
|
};
|
|
}
|
|
|
|
/// The only record that this connection may be unusable, so in a real build
|
|
/// it is `err`. A test that deliberately causes the fault captures it
|
|
/// instead — see `read_tx_faults`.
|
|
fn reportFault(self: *ReadTx, comptime what: []const u8) void {
|
|
var buf: [256]u8 = undefined;
|
|
const message = self.db.lastError(&buf);
|
|
if (faultCaptured()) return;
|
|
log.err("read-transaction " ++ what ++ " failed: {s}", .{message});
|
|
}
|
|
};
|
|
|
|
/// Drives and observes the read-transaction teardown faults, which a unit test
|
|
/// cannot arrange against a healthy SQLite connection. Test builds only; it
|
|
/// reduces to nothing everywhere else — the rotation seam's shape
|
|
/// (logging.zig).
|
|
const read_tx_seam = if (builtin.is_test) struct {
|
|
var fail_next_commit: bool = false;
|
|
var capturing: bool = false;
|
|
var faults: usize = 0;
|
|
} else struct {};
|
|
|
|
fn commitFaultTripped() bool {
|
|
if (!builtin.is_test) return false;
|
|
if (!read_tx_seam.fail_next_commit) return false;
|
|
read_tx_seam.fail_next_commit = false;
|
|
return true;
|
|
}
|
|
|
|
/// True when a test has said it is expecting this fault and will assert on it.
|
|
/// Capture is opt-in for exactly one reason: the test runner fails a test that
|
|
/// logs at `err`, so a blanket silence would turn an unexpected teardown fault
|
|
/// in some unrelated test into a silent pass.
|
|
fn faultCaptured() bool {
|
|
if (!builtin.is_test) return false;
|
|
if (!read_tx_seam.capturing) return false;
|
|
read_tx_seam.faults += 1;
|
|
return true;
|
|
}
|
|
|
|
/// The seam's controls, for tests in this file and in the web layer.
|
|
pub const read_tx_faults = if (builtin.is_test) struct {
|
|
/// Fails the next `ReadTx.commit` before it issues COMMIT, so the
|
|
/// transaction is still open when the recovery path runs — which is the
|
|
/// shape of a real COMMIT failure.
|
|
pub fn failNextCommit() void {
|
|
read_tx_seam.fail_next_commit = true;
|
|
}
|
|
|
|
pub fn beginCapture() void {
|
|
read_tx_seam.capturing = true;
|
|
read_tx_seam.faults = 0;
|
|
}
|
|
|
|
/// Stops capturing and answers how many faults were reported meanwhile.
|
|
pub fn endCapture() usize {
|
|
read_tx_seam.capturing = false;
|
|
return read_tx_seam.faults;
|
|
}
|
|
} else struct {};
|
|
|
|
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 "applyPragmas leaves wal_autocheckpoint at the default unless a page count is given" {
|
|
var db = try openMemory();
|
|
defer db.close();
|
|
try applyPragmas(&db, .{});
|
|
try testing.expectEqual(@as(i64, 1000), try db.queryInt("PRAGMA wal_autocheckpoint"));
|
|
|
|
var configured = try openMemory();
|
|
defer configured.close();
|
|
try applyPragmas(&configured, .{ .wal_autocheckpoint_pages = 8192 });
|
|
try testing.expectEqual(@as(i64, 8192), try configured.queryInt("PRAGMA wal_autocheckpoint"));
|
|
}
|
|
|
|
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 read transaction sees one state and frees the connection when it commits" {
|
|
var db = try openMemory();
|
|
defer db.close();
|
|
try applyPragmas(&db, .{});
|
|
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
|
try db.exec("INSERT INTO t (id) VALUES (1);");
|
|
|
|
var tx = try ReadTx.begin(&db);
|
|
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
|
try tx.commit();
|
|
try testing.expect(!tx.active);
|
|
// Committed, so the connection takes a fresh transaction again.
|
|
var second = try ReadTx.begin(&db);
|
|
second.rollback();
|
|
try testing.expect(!second.active);
|
|
// Idempotent, so an `errdefer` that survives a successful rollback is a
|
|
// no-op rather than a stray ROLLBACK against the next transaction.
|
|
second.rollback();
|
|
|
|
var third = try ReadTx.begin(&db);
|
|
try third.commit();
|
|
}
|
|
|
|
test "a connection left inside a transaction refuses the next begin" {
|
|
var db = try openMemory();
|
|
defer db.close();
|
|
try applyPragmas(&db, .{});
|
|
|
|
// The poisoned connection, reached the only way it can be: a transaction
|
|
// that was opened and never ended. This is what a swallowed COMMIT failure
|
|
// would leave behind, and the point is that it is *loud* — the next reader
|
|
// gets an error it must report, never a silent read outside a snapshot.
|
|
try db.exec("BEGIN DEFERRED;");
|
|
try testing.expectError(error.Unexpected, ReadTx.begin(&db));
|
|
|
|
// And it is recoverable: ending the stray transaction restores the
|
|
// connection, which is what `commit`'s rollback attempt is reaching for.
|
|
try db.exec("ROLLBACK;");
|
|
var tx = try ReadTx.begin(&db);
|
|
try tx.commit();
|
|
}
|
|
|
|
test "a failed commit is reported, rolled back, and leaves the connection usable" {
|
|
var db = try openMemory();
|
|
defer db.close();
|
|
try applyPragmas(&db, .{});
|
|
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
|
try db.exec("INSERT INTO t (id) VALUES (1);");
|
|
|
|
read_tx_faults.beginCapture();
|
|
defer _ = read_tx_faults.endCapture();
|
|
|
|
var tx = try ReadTx.begin(&db);
|
|
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
|
|
|
// The failure lands where a real one does: the transaction is still open
|
|
// when the recovery path runs.
|
|
read_tx_faults.failNextCommit();
|
|
try testing.expectError(error.Internal, tx.commit());
|
|
try testing.expect(!tx.active);
|
|
|
|
// Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is
|
|
// the whole point of attempting it.
|
|
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
|
|
|
|
// The connection is not poisoned — the next reader gets a transaction
|
|
// rather than inheriting the fault.
|
|
read_tx_faults.beginCapture();
|
|
var next = try ReadTx.begin(&db);
|
|
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
|
try next.commit();
|
|
try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture());
|
|
}
|
|
|
|
test "an armed commit fault fires once and no further" {
|
|
var db = try openMemory();
|
|
defer db.close();
|
|
try applyPragmas(&db, .{});
|
|
|
|
read_tx_faults.beginCapture();
|
|
defer _ = read_tx_faults.endCapture();
|
|
read_tx_faults.failNextCommit();
|
|
|
|
var first = try ReadTx.begin(&db);
|
|
try testing.expectError(error.Internal, first.commit());
|
|
|
|
// The seam disarms itself, so it cannot leak into a later test in the same
|
|
// binary and fail a commit nobody asked to fail.
|
|
var second = try ReadTx.begin(&db);
|
|
try second.commit();
|
|
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenMode.immutable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/` relative to
|
|
/// the process working directory, which is also how SQLite's VFS resolves the
|
|
/// filename it is handed (`queries_repo.zig:721`).
|
|
const tmp_prefix = ".zig-cache/tmp/";
|
|
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
|
|
const test_io = testing.io;
|
|
|
|
fn tmpPath(buf: []u8, tmp: *const testing.TmpDir, name: []const u8) ![:0]const u8 {
|
|
return std.fmt.bufPrintZ(buf, "{s}{s}/{s}", .{ tmp_prefix, &tmp.sub_path, name });
|
|
}
|
|
|
|
/// A file database in WAL mode holding one row, `id = marker`. Closing the last
|
|
/// connection checkpoints and unlinks both sidecars, but the header keeps saying
|
|
/// WAL — which is what makes a later `.read_only` open recreate them.
|
|
fn writeWalDatabase(path: [:0]const u8, marker: i64) !void {
|
|
var database = try Db.open(path, .{ .mode = .read_write_create });
|
|
defer database.close();
|
|
try applyPragmas(&database, .{});
|
|
try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
|
var stmt = try database.prepare("INSERT INTO t (id) VALUES (?1)");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, marker);
|
|
try stmt.exec();
|
|
}
|
|
|
|
/// A second writer doing what a running nxdns does: it appends to the
|
|
/// write-ahead log and, being the last connection, checkpoints into the main
|
|
/// file and unlinks both sidecars on close. The blob is what makes the main
|
|
/// file grow by whole pages, so a test that watches for the change does not rest
|
|
/// on the filesystem's timestamp resolution.
|
|
fn checkpointOver(path: [:0]const u8) !void {
|
|
var database = try Db.open(path, .{ .mode = .read_write_existing });
|
|
defer database.close();
|
|
try applyPragmas(&database, .{});
|
|
try database.exec("INSERT INTO t (id) VALUES (8);");
|
|
try database.exec("CREATE TABLE bulk (v TEXT);");
|
|
try database.exec("INSERT INTO bulk (v) VALUES (hex(randomblob(30000)));");
|
|
}
|
|
|
|
fn expectAbsent(dir: std.Io.Dir, name: []const u8) !void {
|
|
dir.access(test_io, name, .{}) catch |e| switch (e) {
|
|
error.FileNotFound => return,
|
|
else => |other| return other,
|
|
};
|
|
std.debug.print("sidecar '{s}' exists and must not\n", .{name});
|
|
return error.SidecarPresent;
|
|
}
|
|
|
|
test "immutableUri encodes what would otherwise change which file is opened" {
|
|
var buf: [256]u8 = undefined;
|
|
|
|
try testing.expectEqualStrings(
|
|
"file:/var/lib/nxdns/config.db?immutable=1",
|
|
try immutableUri(&buf, "/var/lib/nxdns/config.db"),
|
|
);
|
|
// '?' would start SQLite's query section, '#' its fragment, '%' an escape.
|
|
try testing.expectEqualStrings(
|
|
"file:/data%3Fdir/config.db?immutable=1",
|
|
try immutableUri(&buf, "/data?dir/config.db"),
|
|
);
|
|
try testing.expectEqualStrings(
|
|
"file:/data%23dir/config.db?immutable=1",
|
|
try immutableUri(&buf, "/data#dir/config.db"),
|
|
);
|
|
try testing.expectEqualStrings(
|
|
"file:/data%25dir/config.db?immutable=1",
|
|
try immutableUri(&buf, "/data%dir/config.db"),
|
|
);
|
|
try testing.expectEqualStrings(
|
|
"file:/a%20b/c%3Fd%23e%25f.db?immutable=1",
|
|
try immutableUri(&buf, "/a b/c?d#e%f.db"),
|
|
);
|
|
// A relative path stays relative: SQLite's VFS resolves it against the
|
|
// working directory, exactly as a bare filename would be.
|
|
try testing.expectEqualStrings(
|
|
"file:config.db?immutable=1",
|
|
try immutableUri(&buf, "config.db"),
|
|
);
|
|
// A leading "//" would be read as a URI authority and rejected.
|
|
try testing.expectEqualStrings(
|
|
"file:/%2Fnet/share/config.db?immutable=1",
|
|
try immutableUri(&buf, "//net/share/config.db"),
|
|
);
|
|
// Only the authority position is special: "//" further in stays literal.
|
|
try testing.expectEqualStrings(
|
|
"file:/net//share/config.db?immutable=1",
|
|
try immutableUri(&buf, "/net//share/config.db"),
|
|
);
|
|
// Non-ASCII bytes survive the round trip because SQLite decodes them back.
|
|
try testing.expectEqualStrings(
|
|
"file:/caf%C3%A9/config.db?immutable=1",
|
|
try immutableUri(&buf, "/café/config.db"),
|
|
);
|
|
|
|
var small: [16]u8 = undefined;
|
|
try testing.expectError(error.TooBig, immutableUri(&small, "/var/lib/nxdns/config.db"));
|
|
}
|
|
|
|
test "an immutable open of a WAL database creates no -wal and no -shm" {
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
|
const path = try tmpPath(&path_buf, &tmp, "config.db");
|
|
|
|
try writeWalDatabase(path, 7);
|
|
try expectAbsent(tmp.dir, "config.db-wal");
|
|
try expectAbsent(tmp.dir, "config.db-shm");
|
|
|
|
{
|
|
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
|
|
defer database.close();
|
|
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
|
|
// The point of the mode: a `.read_only` open creates both of these here,
|
|
// and cannot delete them on close.
|
|
try expectAbsent(tmp.dir, "config.db-wal");
|
|
try expectAbsent(tmp.dir, "config.db-shm");
|
|
}
|
|
try expectAbsent(tmp.dir, "config.db-wal");
|
|
try expectAbsent(tmp.dir, "config.db-shm");
|
|
}
|
|
|
|
test "an immutable open of a path holding URI metacharacters opens the intended file" {
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var decoy_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
|
var path_buf: [tmp_prefix.len + sub_path_len + 64]u8 = undefined;
|
|
|
|
// Unencoded, SQLite cuts the filename at the '?' and opens "<tmp>/d". That
|
|
// file exists here and holds a different database, so the failure without
|
|
// percent-encoding is a wrong answer, not an error.
|
|
try tmp.dir.createDirPath(test_io, "d?x#y%z");
|
|
try writeWalDatabase(try tmpPath(&decoy_buf, &tmp, "d"), 99);
|
|
const path = try tmpPath(&path_buf, &tmp, "d?x#y%z/config.db");
|
|
try writeWalDatabase(path, 7);
|
|
|
|
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
|
|
defer database.close();
|
|
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
|
|
}
|
|
|
|
test "an immutable open refuses a database whose -wal holds bytes" {
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
|
const path = try tmpPath(&path_buf, &tmp, "config.db");
|
|
try writeWalDatabase(path, 7);
|
|
|
|
// What an unclean shutdown leaves behind, written directly so the case does
|
|
// not depend on when SQLite decides to checkpoint.
|
|
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = &([_]u8{0x37} ** 32) });
|
|
try testing.expectError(error.WalPending, Db.open(path, .{ .mode = .{ .immutable = test_io } }));
|
|
|
|
// A zero-length `-wal` holds no frames, so the main file is complete: the
|
|
// exact leftover the pre-F-c `check` created must not block a check.
|
|
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
|
|
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
|
|
defer database.close();
|
|
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
|
|
}
|
|
|
|
test "an immutable read refuses a -wal that arrives while it is in flight" {
|
|
// The probe at open time can only say the log was empty *then*.
|
|
// `immutable=1` takes no lock, so a writer is free to arrive one instant
|
|
// later, and the read goes on answering from the older pages of the main
|
|
// file with nothing anywhere reporting it.
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
|
const path = try tmpPath(&path_buf, &tmp, "config.db");
|
|
try writeWalDatabase(path, 7);
|
|
|
|
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
|
|
defer database.close();
|
|
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
|
|
// Nothing has moved yet, so the read stands.
|
|
try database.verifyImmutable();
|
|
|
|
// A zero-length log still holds no frames: the rule at the end of the read
|
|
// is the rule at the start of it.
|
|
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
|
|
try database.verifyImmutable();
|
|
|
|
// Frames, now, in the log the open accepted as empty.
|
|
try tmp.dir.writeFile(test_io, .{
|
|
.sub_path = "config.db" ++ wal_suffix,
|
|
.data = &([_]u8{0x37} ** 32),
|
|
});
|
|
try testing.expectError(error.WalPending, database.verifyImmutable());
|
|
// Repeatable: reporting the race is all it does.
|
|
try testing.expectError(error.WalPending, database.verifyImmutable());
|
|
|
|
// And it repairs nothing. The operator's log is byte for byte what was
|
|
// written, and no wal-index appeared beside it.
|
|
const wal = try tmp.dir.statFile(test_io, "config.db" ++ wal_suffix, .{});
|
|
try testing.expectEqual(@as(u64, 32), wal.size);
|
|
try expectAbsent(tmp.dir, "config.db-shm");
|
|
}
|
|
|
|
test "an immutable read refuses a main file checkpointed under it" {
|
|
// The case a `-wal` probe cannot see at either end: a writer checkpointed
|
|
// into the main file and, closing, unlinked its log again. Both stats say
|
|
// "no frames" while the pages the read answered from have been replaced.
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
|
const path = try tmpPath(&path_buf, &tmp, "config.db");
|
|
try writeWalDatabase(path, 7);
|
|
|
|
var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
|
|
defer database.close();
|
|
try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
|
|
try database.verifyImmutable();
|
|
|
|
const before = try tmp.dir.statFile(test_io, "config.db", .{});
|
|
try checkpointOver(path);
|
|
const after = try tmp.dir.statFile(test_io, "config.db", .{});
|
|
|
|
// The premise of the test, proved rather than assumed: the main file really
|
|
// did move, and the log really is gone again.
|
|
try testing.expect(after.size != before.size);
|
|
try expectAbsent(tmp.dir, "config.db" ++ wal_suffix);
|
|
|
|
try testing.expectError(error.WalPending, database.verifyImmutable());
|
|
}
|
|
|
|
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());
|
|
}
|