Files
cobalt/src/query.zig
T
2026-09-09 17:55:42 +02:00

3869 lines
168 KiB
Zig

const std = @import("std");
const dbmod = @import("db.zig");
const rowmod = @import("row.zig");
const idx = @import("index.zig");
const exprmod = @import("expr.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
const Value = rowmod.Value;
const Row = rowmod.Row;
const Field = rowmod.Field;
const IndexSet = idx.IndexSet;
const CompositeIndexSet = idx.CompositeIndexSet;
const CompositeIndexSpec = idx.CompositeIndexSpec;
const UniqueSet = idx.UniqueSet;
const ForeignKeySet = idx.ForeignKeySet;
const CheckConstraintSet = idx.CheckConstraintSet;
const ReferencedBySet = idx.ReferencedBySet;
/// Reserved field name a Table stores the primary key under, so a decoded
/// Row is self-sufficient for callers that only have the Row (e.g. results
/// of a full table scan) without needing to separately decode it from the
/// row key's escaped ordered bytes.
pub const pkField = "__pk";
pub const Condition = struct {
field: []const u8,
value: Value,
};
/// Paging window for `Table.selectPage`: skip the first `offset` matches,
/// return at most `limit` of them (0 = unlimited).
pub const Page = struct {
offset: usize = 0,
limit: usize = 0,
};
/// One column of a `Table.selectPageOrdered` sort key: order matched rows
/// by `field` (via `rowmod.compare`'s total order across value types). A
/// row missing `field` entirely sorts as `Value.none`, which `compare`
/// places before every other tag - so rows lacking the sort column
/// consistently group at the front (ascending) or back (descending)
/// instead of erroring. `selectPageOrdered` takes a `[]const OrderBy`
/// (empty = unsorted): columns after the first only break ties left by
/// the ones before them, the same lexicographic rule SQL's `ORDER BY a, b`
/// follows - the sort is stable, so a `[]const OrderBy` with a single
/// entry that leaves ties unresolved behaves exactly as before this type
/// gained multi-column support (rows keep their relative scan order:
/// ascending pk for a full scan, index order for an indexed lookup).
pub const OrderBy = struct {
field: []const u8,
descending: bool = false,
};
/// Which access strategy `Table.selectPage` would take for a given
/// predicate, as reported by `Table.explain`. Mirrors the three real
/// branches of `selectPage`'s decision ladder: an unindexed full scan, a
/// single-field index (`eq` narrows to one key, `lt`/`lte`/`gt`/`gte`
/// narrow to a range), or a composite index matched on a leftmost prefix
/// of its columns (`_eq` when the prefix is the whole match, `_range` when
/// the column right after the prefix also had a range bound).
pub const PlanKind = enum(u8) {
full_scan,
index_eq,
index_range,
composite_eq,
composite_range,
};
/// `indexName` is an owned copy the caller must free with `Plan.deinit` -
/// null (and `matchedFields`/`totalFields` both 0) for `.full_scan`, which
/// names no index. For `.index_eq`/`.index_range` it is the single indexed
/// field name and `matchedFields == totalFields == 1`. For
/// `.composite_eq`/`.composite_range` it is the composite index's name and
/// `matchedFields` counts how many of the index's `totalFields` leading
/// columns the query actually pinned down (a strict prefix match still
/// narrows the scan, same leftmost-prefix rule a real multi-column B-tree
/// index gives you).
pub const Plan = struct {
kind: PlanKind,
indexName: ?[]const u8 = null,
matchedFields: usize = 0,
totalFields: usize = 0,
pub fn deinit(self: *Plan, allocator: Allocator) void {
if (self.indexName) |n| allocator.free(n);
self.* = undefined;
}
};
/// Predicate AST evaluated against a decoded Row. Leaves compare a named
/// field to a literal Value; And/Or/Not compose leaves and other predicates.
/// Predicate does not own the memory of any Value or field-name slice it
/// references - callers must keep those alive for as long as the predicate
/// tree is used.
pub const Predicate = union(enum) {
eq: Condition,
ne: Condition,
lt: Condition,
lte: Condition,
gt: Condition,
gte: Condition,
like: Condition,
/// True when `field` is absent from the row entirely, or present but
/// holding an explicit `Value.none` - a sparse row's "never set" and a
/// row that set the field to none are indistinguishable to a caller
/// asking "does this row have a real value here", same as SQL NULL.
is_null: []const u8,
is_not_null: []const u8,
and_: []const Predicate,
or_: []const Predicate,
not_: *const Predicate,
fn isNullish(v: ?Value) bool {
const val = v orelse return true;
return val == .none;
}
pub fn evaluate(self: Predicate, row: *const Row) bool {
return switch (self) {
.eq => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .eq else false,
.ne => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .eq else true,
.lt => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .lt else false,
.lte => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .gt else false,
.gt => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .gt else false,
.gte => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .lt else false,
.like => |c| blk: {
const rowVal = row.get(c.field) orelse break :blk false;
if (rowVal != .text or c.value != .text) break :blk false;
break :blk matchLike(c.value.text, rowVal.text);
},
.is_null => |field| isNullish(row.get(field)),
.is_not_null => |field| !isNullish(row.get(field)),
.and_ => |preds| blk: {
for (preds) |p| {
if (!p.evaluate(row)) break :blk false;
}
break :blk true;
},
.or_ => |preds| blk: {
for (preds) |p| {
if (p.evaluate(row)) break :blk true;
}
break :blk false;
},
.not_ => |p| !p.evaluate(row),
};
}
};
/// SQL-style LIKE matching: `%` matches any run of characters (including
/// none), `_` matches exactly one character, every other byte in `pattern`
/// must match `text` literally. No escape character - a pattern that needs a
/// literal `%`/`_` cannot express it, same simplification `expr.zig`'s other
/// operators already make (no escaping in the predicate language at all).
/// Iterative two-pointer wildcard matcher (not recursive backtracking) so a
/// pattern like "a%a%a%a%a%a%b" against a long non-matching text stays
/// linear-ish instead of exponential: on mismatch after a `%`, only the
/// text pointer rewinds-and-retries, the pattern never re-walks from
/// scratch.
fn matchLike(pattern: []const u8, text: []const u8) bool {
var pIdx: usize = 0;
var tIdx: usize = 0;
var starIdx: ?usize = null;
var matchIdx: usize = 0;
while (tIdx < text.len) {
if (pIdx < pattern.len and (pattern[pIdx] == '_' or pattern[pIdx] == text[tIdx])) {
pIdx += 1;
tIdx += 1;
} else if (pIdx < pattern.len and pattern[pIdx] == '%') {
starIdx = pIdx;
matchIdx = tIdx;
pIdx += 1;
} else if (starIdx) |si| {
pIdx = si + 1;
matchIdx += 1;
tIdx = matchIdx;
} else {
return false;
}
}
while (pIdx < pattern.len and pattern[pIdx] == '%') : (pIdx += 1) {}
return pIdx == pattern.len;
}
test "matchLike handles literal, wildcard, and single-char patterns" {
try std.testing.expect(matchLike("warsaw", "warsaw"));
try std.testing.expect(!matchLike("warsaw", "krakow"));
try std.testing.expect(matchLike("", ""));
try std.testing.expect(!matchLike("a", ""));
try std.testing.expect(matchLike("%", "anything"));
try std.testing.expect(matchLike("%", ""));
try std.testing.expect(matchLike("A%", "Ada"));
try std.testing.expect(!matchLike("A%", "Boris"));
try std.testing.expect(matchLike("%son", "Johnson"));
try std.testing.expect(!matchLike("%son", "Sonata"));
try std.testing.expect(matchLike("%ada%", "the ada language"));
try std.testing.expect(matchLike("_da", "ada"));
try std.testing.expect(!matchLike("_da", "aada"));
try std.testing.expect(matchLike("a_a_a", "ababa"));
try std.testing.expect(matchLike("%a%b%c%", "xaxxbxxxcx"));
try std.testing.expect(!matchLike("%a%b%c%", "xxbxxxax"));
try std.testing.expect(matchLike("a%%a", "aXYZa"));
}
/// A table is a named partition of a single Db's keyspace (see index.zig for
/// the exact key layout) plus a set of secondary indexes maintained on
/// write. Rows are whole-row replace semantics: `put` fully overwrites
/// whatever was stored at `pk`, syncing every registered index against the
/// old and new field values in the same call.
/// Composite indexes are capped at this many columns: generous for any
/// realistic multi-column index while keeping the planner's per-spec match
/// buffer (see `findCompositeMatch`) a fixed-size stack array.
pub const maxCompositeFields = 8;
pub const Table = struct {
db: *Db,
allocator: Allocator,
name: []u8,
indexes: IndexSet,
composite: CompositeIndexSet,
unique: UniqueSet,
foreignKeys: ForeignKeySet,
checks: CheckConstraintSet,
pub fn open(db: *Db, allocator: Allocator, name: []const u8) !Table {
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
return .{
.db = db,
.allocator = allocator,
.name = try allocator.dupe(u8, name),
.indexes = try IndexSet.load(allocator, db, name),
.composite = try CompositeIndexSet.load(allocator, db, name),
.unique = try UniqueSet.load(allocator, db, name),
.foreignKeys = try ForeignKeySet.load(allocator, db, name),
.checks = try CheckConstraintSet.load(allocator, db, name),
};
}
pub fn deinit(self: *Table) void {
self.allocator.free(self.name);
self.indexes.deinit();
self.composite.deinit();
self.unique.deinit();
self.foreignKeys.deinit();
self.checks.deinit();
}
pub fn createIndex(self: *Table, field: []const u8) !void {
if (!idx.validIdentifier(field)) return error.InvalidIdentifier;
if (self.indexes.has(field)) return;
// Backfill: every existing row's current value for `field` becomes
// an index entry before the index is advertised as usable.
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
for (rows) |*r| {
const pk = r.get(pkField) orelse continue;
const value = r.get(field) orelse continue;
try idx.addEntry(self.db, self.allocator, self.name, field, value, pk);
}
try self.indexes.add(field);
try self.indexes.save(self.db, self.name);
}
/// Dropping an index also drops any UNIQUE constraint riding on it -
/// `Table.put`'s constraint check relies on the field's index entries
/// existing (see `createUniqueIndex`), so a unique field with no
/// index left behind would silently stop being enforced instead of
/// erroring, which is worse than making the drop explicit here.
pub fn dropIndex(self: *Table, field: []const u8) !void {
if (!self.indexes.remove(field)) return;
try idx.dropAllEntries(self.db, self.allocator, self.name, field);
try self.indexes.save(self.db, self.name);
if (self.unique.remove(field)) try self.unique.save(self.db, self.name);
}
pub fn hasIndex(self: *const Table, field: []const u8) bool {
return self.indexes.has(field);
}
pub fn hasUniqueIndex(self: *const Table, field: []const u8) bool {
return self.unique.has(field);
}
/// Installs a UNIQUE constraint on `field` - SQL's `CREATE UNIQUE
/// INDEX`. Refuses outright if the table already holds two rows with
/// the same (non-null) value for `field`: existing data always wins
/// over a constraint that could never have held for it, exactly like
/// a real database refusing to add a unique index over dirty data.
/// Backfills the plain secondary index too when it is not already
/// present, since `put`'s duplicate check below is a `scanEq` against
/// those very index entries rather than a full table scan - a unique
/// field with no backing index would make every write pay for a full
/// scan just to enforce the constraint.
pub fn createUniqueIndex(self: *Table, field: []const u8) !void {
if (!idx.validIdentifier(field)) return error.InvalidIdentifier;
if (self.unique.has(field)) return;
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
{
var seen = std.StringHashMap(void).init(self.allocator);
defer {
var it = seen.keyIterator();
while (it.next()) |k| self.allocator.free(k.*);
seen.deinit();
}
for (rows) |*r| {
const value = r.get(field) orelse continue;
var encoded = std.ArrayList(u8).init(self.allocator);
defer encoded.deinit();
try rowmod.appendOrdered(&encoded, value);
if (seen.contains(encoded.items)) return error.UniqueViolation;
const owned = try self.allocator.dupe(u8, encoded.items);
errdefer self.allocator.free(owned);
try seen.put(owned, {});
}
}
if (!self.indexes.has(field)) {
for (rows) |*r| {
const pk = r.get(pkField) orelse continue;
const value = r.get(field) orelse continue;
try idx.addEntry(self.db, self.allocator, self.name, field, value, pk);
}
try self.indexes.add(field);
try self.indexes.save(self.db, self.name);
}
try self.unique.add(field);
try self.unique.save(self.db, self.name);
}
pub fn hasForeignKey(self: *const Table, field: []const u8) bool {
return self.foreignKeys.has(field);
}
/// Whether `value` names an existing row of `spec.refTable` under
/// `spec.refField` - `query.pkField` is checked via a plain `get`
/// (the primary key is inherently unique, no secondary index needed),
/// anything else via `idx.scanEq` against `spec.refTable`'s own index
/// entries (required to carry a UNIQUE constraint by
/// `Table.createForeignKey`, so at most one owner is expected here -
/// this only asks "does at least one exist", not "is it exactly one",
/// since a constraint installed correctly can only ever produce zero or
/// one).
fn foreignKeyTargetExists(self: *Table, refTableName: []const u8, refField: []const u8, value: Value) !bool {
if (std.mem.eql(u8, refField, pkField)) {
var refTable = try Table.open(self.db, self.allocator, refTableName);
defer refTable.deinit();
var found = try refTable.get(value) orelse return false;
found.deinit();
return true;
}
const owners = try idx.scanEq(self.db, self.allocator, refTableName, refField, value);
defer {
for (owners) |v| v.free(self.allocator);
self.allocator.free(owners);
}
return owners.len > 0;
}
/// Installs a FOREIGN KEY constraint: every row's (non-null) `field`
/// value must, from this point on, name a value that exists as
/// `refField` on `refTable` - SQL's `FOREIGN KEY (field) REFERENCES
/// refTable(refField)`. `refField` must be either `pkField` or a field
/// already carrying `refTable`'s own UNIQUE constraint (see
/// `foreignKeyTargetExists`'s doc comment for why); anything else is
/// refused with `error.ForeignKeyRequiresUniqueTarget` before touching
/// any row, the same "refuse before mutating" shape `createUniqueIndex`
/// uses for dirty data. Existing rows are checked against the target
/// before the constraint is recorded - a row whose current `field`
/// value has no matching parent aborts the whole install with
/// `error.ForeignKeyViolation`, leaving nothing installed.
///
/// Registers the reverse relationship in `refTable`'s own
/// `ReferencedBySet` (see its doc comment) so `Table.delete` can later
/// enforce `onDelete` against it (SQL's `ON DELETE RESTRICT` or `ON
/// DELETE CASCADE`, see `idx.OnDelete`'s doc comment) - this used to be
/// a documented, unfixed gap ("no registry of dependent tables exists,
/// since tables are opened ad hoc by name and never catalogued
/// anywhere") before `ReferencedBySet` closed it.
///
/// `onDelete` only governs `Table.delete`/`Table.deleteWhere`'s plain
/// (non-transactional) path - `txn_table.zig`'s `TableTxn.delete` reads
/// the same `ReferencedBySet` but does not look at this field at all,
/// so it continues to unconditionally restrict any delete that would
/// orphan a reference even for a `cascade` constraint. That is a
/// deliberately conservative gap, not an oversight: refusing is always
/// a safe fallback for a feature that has not been built yet, whereas
/// silently NOT cascading inside a transaction would leave a dangling
/// reference behind. Only `Table.put`'s `updateRestrictedByForeignKey`
/// check is unaffected by `onDelete` in EITHER path - there is no `ON
/// UPDATE CASCADE` here, so a value change under a live reference is
/// always restricted regardless of the FK's delete mode.
///
/// Known gap that registry does NOT close, same documented-rather-
/// than-fixed shape as `TableTxn`'s missing composite/unique
/// enforcement: nothing here stops a later `refTable.dropIndex
/// (refField)` from removing the UNIQUE constraint this foreign key
/// depends on - doing so leaves the constraint silently checking
/// against an unconstrained (possibly duplicate-holding) column via
/// `scanEq`, which still answers "does at least one owner exist"
/// correctly but no longer guarantees at most one.
pub fn createForeignKey(self: *Table, field: []const u8, refTable: []const u8, refField: []const u8, onDelete: idx.OnDelete) !void {
if (!idx.validIdentifier(field)) return error.InvalidIdentifier;
if (!idx.validIdentifier(refTable)) return error.InvalidIdentifier;
if (!idx.validIdentifier(refField)) return error.InvalidIdentifier;
if (self.foreignKeys.has(field)) return;
if (!std.mem.eql(u8, refField, pkField)) {
var ref = try Table.open(self.db, self.allocator, refTable);
defer ref.deinit();
if (!ref.hasUniqueIndex(refField)) return error.ForeignKeyRequiresUniqueTarget;
}
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
for (rows) |*r| {
const value = r.get(field) orelse continue;
if (value == .none) continue;
if (!try self.foreignKeyTargetExists(refTable, refField, value)) return error.ForeignKeyViolation;
}
try self.foreignKeys.add(field, refTable, refField, onDelete);
try self.foreignKeys.save(self.db, self.name);
// Records the reverse relationship on `refTable` so a later delete
// against it (see `Table.delete`'s RESTRICT check) can discover
// this constraint without a global table catalog - see
// `ReferencedBySet`'s doc comment. Loaded, mutated, and saved
// transiently rather than cached on a `Table` field: unlike
// `foreignKeys`/`unique`/`checks` (always mutated through the same
// handle that holds them), `referencedBy` is mutated as a side
// effect of some OTHER table's `createForeignKey`/`dropForeignKey`
// call - a `refTable` handle the CALLER already has open (the
// ordinary "open customers, open orders, wire up the FK between
// them" shape) would otherwise go stale the moment this runs,
// silently under-enforcing every delete against it thereafter.
// Works unchanged for a self-referencing FK (`refTable ==
// self.name`) too, since it never touches `self`'s own cached
// sets at all - it only ever reads/writes `refTable`'s on-disk
// entry by name.
var revSet = try ReferencedBySet.load(self.allocator, self.db, refTable);
defer revSet.deinit();
try revSet.add(self.name, field, refField, onDelete);
try revSet.save(self.db, refTable);
}
/// Lifts a FOREIGN KEY and its reverse `ReferencedBySet` entry on
/// whichever table it pointed at, symmetric with `createForeignKey`
/// installing both. The target's name is copied out before
/// `self.foreignKeys.remove` frees the spec's own strings, since the
/// reverse cleanup below still needs it afterwards. See
/// `createForeignKey`'s doc comment on why the reverse entry is always
/// loaded/saved transiently rather than through a cached `Table` field.
pub fn dropForeignKey(self: *Table, field: []const u8) !void {
const existing = self.foreignKeys.find(field) orelse return;
const refTable = try self.allocator.dupe(u8, existing.refTable);
defer self.allocator.free(refTable);
_ = self.foreignKeys.remove(field);
try self.foreignKeys.save(self.db, self.name);
var revSet = try ReferencedBySet.load(self.allocator, self.db, refTable);
defer revSet.deinit();
_ = revSet.remove(self.name, field);
try revSet.save(self.db, refTable);
}
pub fn hasCheckConstraint(self: *const Table, name: []const u8) bool {
return self.checks.has(name);
}
/// Installs a named CHECK constraint - SQL's `CHECK (source)` - built on
/// the exact same predicate language `table select`/`table join` already
/// use (`expr.zig`'s recursive-descent parser over `Predicate`). `source`
/// is parsed once up front so a typo is rejected before anything is
/// persisted (`error.InvalidCheckExpression` wraps any `expr.ParseError`,
/// since a caller that only sees this constraint's name/source has no use
/// for which specific parse error fired), then evaluated against every
/// existing row - the same "refuse before mutating, existing data always
/// wins" shape `createUniqueIndex`/`createForeignKey` use, so installing
/// a CHECK that current data already violates fails outright rather than
/// silently starting to enforce only from the next write on.
pub fn createCheckConstraint(self: *Table, name: []const u8, source: []const u8) !void {
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
if (self.checks.has(name)) return;
var parsed = exprmod.parse(self.allocator, source) catch return error.InvalidCheckExpression;
defer parsed.deinit();
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
for (rows) |*r| {
if (!parsed.predicate.evaluate(r)) return error.CheckViolation;
}
try self.checks.add(name, source);
try self.checks.save(self.db, self.name);
}
pub fn dropCheckConstraint(self: *Table, name: []const u8) !void {
if (!self.checks.remove(name)) return;
try self.checks.save(self.db, self.name);
}
/// Creates a multi-column secondary index named `name` over `fields`, in
/// order - order matters, since only a leading ("leftmost") prefix of it
/// can be used to narrow a query (see `findCompositeMatch`). Rows
/// missing any of the fields are skipped entirely, both here and when
/// staying in sync on `put`/`delete`, since a composite entry with a
/// missing column would not be a well-formed tuple.
pub fn createCompositeIndex(self: *Table, name: []const u8, fields: []const []const u8) !void {
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
if (fields.len < 2 or fields.len > maxCompositeFields) return error.InvalidCompositeIndex;
for (fields) |f| if (!idx.validIdentifier(f)) return error.InvalidIdentifier;
if (self.composite.has(name)) return;
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
var values: [maxCompositeFields]Value = undefined;
for (rows) |*r| {
const pk = r.get(pkField) orelse continue;
var complete = true;
for (fields, 0..) |f, i| {
values[i] = r.get(f) orelse {
complete = false;
break;
};
}
if (!complete) continue;
try idx.addCompositeEntry(self.db, self.allocator, self.name, name, values[0..fields.len], pk);
}
try self.composite.add(name, fields);
try self.composite.save(self.db, self.name);
}
pub fn dropCompositeIndex(self: *Table, name: []const u8) !void {
if (!self.composite.remove(name)) return;
try idx.dropAllCompositeEntries(self.db, self.allocator, self.name, name);
try self.composite.save(self.db, self.name);
}
pub fn hasCompositeIndex(self: *const Table, name: []const u8) bool {
return self.composite.has(name);
}
/// Fully replaces the row stored at `pk` with `fields`, syncing every
/// registered secondary index against whatever the row held before.
pub fn put(self: *Table, pk: Value, fields: []const Field) !void {
var old = try self.get(pk);
defer if (old) |*o| o.deinit();
var newRow = Row.init(self.allocator);
defer newRow.deinit();
try newRow.set(pkField, pk);
for (fields) |f| try newRow.set(f.name, f.value);
const encoded = try newRow.encode(self.allocator);
defer self.allocator.free(encoded);
const key = try idx.rowKey(self.allocator, self.name, pk);
defer self.allocator.free(key);
// CHECK constraints run first, ahead of UNIQUE/FOREIGN KEY and any
// index or row mutation - a row that fails a CHECK never gets far
// enough to matter whether it would also have violated UNIQUE, same
// "cheapest, purely-local check first" ordering a real database
// uses. Re-parsed from source on every put (no cached Predicate is
// kept on Table), the same cost `table select`/`table join` already
// pay for their own expr text on every call.
for (self.checks.specs.items) |spec| {
var parsed = exprmod.parse(self.allocator, spec.source) catch return error.InvalidCheckExpression;
defer parsed.deinit();
if (!parsed.predicate.evaluate(&newRow)) return error.CheckViolation;
}
// UNIQUE constraint check runs before any index or row mutation
// below, so a violation leaves everything untouched - the same
// all-or-nothing shape callers already get from a plain `put`
// either succeeding outright or failing outright. A field that
// did not change value from the row's own previous value is
// exempt (it already legitimately owns that value), which is why
// updating unrelated fields on a row that already holds a unique
// value never spuriously conflicts with itself.
for (self.unique.fields.items) |field| {
const newValue = newRow.get(field) orelse continue;
const oldValue = if (old) |*o| o.get(field) else null;
if (oldValue != null and Value.eql(oldValue.?, newValue)) continue;
const owners = try idx.scanEq(self.db, self.allocator, self.name, field, newValue);
defer {
for (owners) |v| v.free(self.allocator);
self.allocator.free(owners);
}
for (owners) |ownerPk| {
if (!Value.eql(ownerPk, pk)) return error.UniqueViolation;
}
}
// FOREIGN KEY check, same "before any mutation" placement as UNIQUE
// above. Unlike UNIQUE, an unchanged value is NOT exempt: nothing in
// this codebase stops the referenced row from having been deleted
// out from under an already-valid reference since the last write
// (no catalog of dependent tables exists to block that delete - see
// `Table.createForeignKey`'s doc comment), so every put re-checks
// every constrained field's current target rather than trusting a
// value that merely did not change this time.
for (self.foreignKeys.specs.items) |spec| {
const newValue = newRow.get(spec.field) orelse continue;
if (newValue == .none) continue;
if (!try self.foreignKeyTargetExists(spec.refTable, spec.refField, newValue)) return error.ForeignKeyViolation;
}
// ON DELETE RESTRICT's update-side counterpart, see
// `updateRestrictedByForeignKey`'s doc comment - must run before any
// index or row mutation below, same all-or-nothing placement as
// every other constraint check in this function.
if (try self.updateRestrictedByForeignKey(pk, if (old) |*o| o else null, &newRow)) return error.ForeignKeyInUse;
for (self.indexes.fields.items) |indexedField| {
const newValue = newRow.get(indexedField);
const oldValue = if (old) |*o| o.get(indexedField) else null;
if (oldValue != null and (newValue == null or !Value.eql(oldValue.?, newValue.?))) {
try idx.removeEntry(self.db, self.allocator, self.name, indexedField, oldValue.?, pk);
}
if (newValue) |nv| {
if (oldValue == null or !Value.eql(oldValue.?, nv)) {
try idx.addEntry(self.db, self.allocator, self.name, indexedField, nv, pk);
}
}
}
for (self.composite.specs.items) |spec| {
var oldValues: [maxCompositeFields]Value = undefined;
var oldComplete = old != null;
if (old) |*o| {
for (spec.fields, 0..) |f, i| {
oldValues[i] = o.get(f) orelse {
oldComplete = false;
break;
};
}
}
var newValues: [maxCompositeFields]Value = undefined;
var newComplete = true;
for (spec.fields, 0..) |f, i| {
newValues[i] = newRow.get(f) orelse {
newComplete = false;
break;
};
}
const unchanged = oldComplete and newComplete and valueTupleEql(oldValues[0..spec.fields.len], newValues[0..spec.fields.len]);
if (unchanged) continue;
if (oldComplete) try idx.removeCompositeEntry(self.db, self.allocator, self.name, spec.name, oldValues[0..spec.fields.len], pk);
if (newComplete) try idx.addCompositeEntry(self.db, self.allocator, self.name, spec.name, newValues[0..spec.fields.len], pk);
}
try self.db.put(key, encoded);
}
pub fn get(self: *Table, pk: Value) !?Row {
const key = try idx.rowKey(self.allocator, self.name, pk);
defer self.allocator.free(key);
const raw = try self.db.get(self.allocator, key) orelse return null;
defer self.allocator.free(raw);
return try Row.decode(self.allocator, raw);
}
/// One (table, pk) a cascading delete has decided to remove, in the
/// order `planCascade` discovered it - deepest dependents first, the
/// row that was actually asked for last. Owns copies of both fields
/// independently of whatever `Row`/`Table` produced them, since the
/// scan that found them is long gone (and possibly a different
/// `Table` handle entirely) by the time `delete` executes the plan.
const CascadeAction = struct {
table: []u8,
pk: Value,
fn deinit(self: *const CascadeAction, allocator: Allocator) void {
allocator.free(self.table);
self.pk.free(allocator);
}
};
/// Cycle guard for `planCascade`: every (table, pk) already visited
/// this call, so a cascade that loops back on itself (two tables
/// `ON DELETE CASCADE`-ing at each other, or a longer cycle through
/// three or more) terminates instead of recursing forever. Linear
/// scan is fine here - a cascading delete's fan-out is bounded by how
/// much data actually exists, and this runs once per row touched, not
/// once per row scanned.
const CascadeVisited = struct {
allocator: Allocator,
entries: std.ArrayList(CascadeAction),
fn init(allocator: Allocator) CascadeVisited {
return .{ .allocator = allocator, .entries = std.ArrayList(CascadeAction).init(allocator) };
}
fn contains(self: *const CascadeVisited, table: []const u8, pk: Value) bool {
for (self.entries.items) |e| {
if (std.mem.eql(u8, e.table, table) and Value.eql(e.pk, pk)) return true;
}
return false;
}
fn add(self: *CascadeVisited, table: []const u8, pk: Value) !void {
const tableCopy = try self.allocator.dupe(u8, table);
errdefer self.allocator.free(tableCopy);
const pkCopy = try pk.dupe(self.allocator);
try self.entries.append(.{ .table = tableCopy, .pk = pkCopy });
}
fn deinit(self: *CascadeVisited) void {
for (self.entries.items) |e| e.deinit(self.allocator);
self.entries.deinit();
}
};
/// Recursively works out everything a delete of `pk` must remove,
/// WITHOUT deleting anything yet, appending each decision to `plan` in
/// dependent-before-dependency order (see `CascadeAction`'s doc
/// comment) so `delete` can execute it leaves-first. This two-phase
/// plan/execute split is what keeps a cascading delete all-or-nothing:
/// a `restrict`-mode violation discovered three levels deep must abort
/// with NOTHING mutated, exactly like the single-table check this
/// replaced - if planning and deleting were interleaved, an earlier
/// sibling subtree could already be gone by the time a later one fails.
///
/// For every `ReferencedBySet` entry naming a table with a live row
/// pointing at `old`'s value for the referenced field (see the
/// previous version of this function for why the child is opened and
/// scanned in full rather than through `idx.scanEq`, and why a
/// self-referencing FK excludes `pk` itself from matching): `restrict`
/// aborts the whole plan with `error.ForeignKeyInUse`; `cascade`
/// recurses into that child row first, so it lands in `plan` ahead of
/// `self` (a child must be gone before its parent is, or the delete
/// order would itself violate the very constraint being enforced).
///
/// `visited` is checked (and updated) BEFORE any of that per-row work,
/// so a cycle (`planCascade(A)` reaching `planCascade(B)` reaching
/// `planCascade(A)` again) short-circuits the re-entrant call instead
/// of looping - the ORIGINAL call for that (table, pk) is still the one
/// that appends it to `plan`, once its own scan finishes, so nothing is
/// silently dropped from the plan by the cycle.
fn planCascade(self: *Table, pk: Value, visited: *CascadeVisited, plan: *std.ArrayList(CascadeAction)) !void {
if (visited.contains(self.name, pk)) return;
var old = try self.get(pk) orelse return;
defer old.deinit();
try visited.add(self.name, pk);
var referencedBy = try ReferencedBySet.load(self.allocator, self.db, self.name);
defer referencedBy.deinit();
for (referencedBy.specs.items) |spec| {
const value = old.get(spec.refField) orelse continue;
if (value == .none) continue;
var child = try Table.open(self.db, self.allocator, spec.childTable);
defer child.deinit();
const rows = try child.scanAll();
defer freeRows(self.allocator, rows);
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
for (rows) |*r| {
if (selfReferencing) {
if (r.get(pkField)) |rowPk| {
if (Value.eql(rowPk, pk)) continue;
}
}
const childValue = r.get(spec.childField) orelse continue;
if (childValue == .none) continue;
if (!Value.eql(childValue, value)) continue;
switch (spec.onDelete) {
.restrict => return error.ForeignKeyInUse,
.cascade => {
const childPk = r.get(pkField) orelse continue;
try child.planCascade(childPk, visited, plan);
},
}
}
}
try plan.append(.{ .table = try self.allocator.dupe(u8, self.name), .pk = try pk.dupe(self.allocator) });
}
/// The actual row/index removal `delete` used to do inline before
/// cascading existed - no FOREIGN KEY check here at all, since by the
/// time this runs `planCascade` has already proven the entire plan is
/// safe to execute in order. Takes `pk` rather than a decoded row
/// because `CascadeAction` only carries the two, and re-fetching here
/// (rather than threading the `Row` `planCascade` already read) is
/// what lets `delete` stay tolerant of a row that no longer exists by
/// execution time - unreachable in practice (nothing between planning
/// and executing can delete a row out from under this single-threaded
/// call), but cheap to make robust to rather than to assume away.
fn executeDelete(self: *Table, pk: Value) !void {
var old = try self.get(pk) orelse return;
defer old.deinit();
for (self.indexes.fields.items) |indexedField| {
if (old.get(indexedField)) |v| {
try idx.removeEntry(self.db, self.allocator, self.name, indexedField, v, pk);
}
}
for (self.composite.specs.items) |spec| {
var values: [maxCompositeFields]Value = undefined;
var complete = true;
for (spec.fields, 0..) |f, i| {
values[i] = old.get(f) orelse {
complete = false;
break;
};
}
if (complete) try idx.removeCompositeEntry(self.db, self.allocator, self.name, spec.name, values[0..spec.fields.len], pk);
}
const key = try idx.rowKey(self.allocator, self.name, pk);
defer self.allocator.free(key);
try self.db.delete(key);
}
/// SQL's `ON DELETE RESTRICT`/`CASCADE` (see `planCascade` above) has an
/// easy-to-miss counterpart: this codebase never leaves a child dangling
/// when its parent ROW disappears, but until now nothing stopped a `put`
/// from changing the parent's *value* out from under a still-live child -
/// `refTable.dropIndex(refField)` and `updateWhere`/direct `put` changing
/// a `refField` are exactly the "carpet pulled from under the child"
/// cases `createForeignKey`'s own doc comment already named as open
/// gaps. Reuses the identical scan and self-reference exclusion as
/// `planCascade` (a value that stops existing under this pk is exactly
/// as dangerous to a live reference as a row that stops existing
/// entirely), but the trigger is "this pk's value for `refField` is
/// about to change", not "this pk is about to be deleted" - so `old` may
/// be null (a fresh insert has nothing to pull out from under anyone)
/// and a field whose new value equals its old value is exempt, the same
/// "unchanged is always safe" exemption `put`'s own UNIQUE check makes.
/// Unlike `planCascade`, this ALWAYS restricts regardless of the
/// constraint's `onDelete` mode - there is no `ON UPDATE CASCADE` here,
/// only `ON DELETE CASCADE`, so even a `cascade`-mode FK still blocks a
/// value change under a live reference.
fn updateRestrictedByForeignKey(self: *Table, pk: Value, old: ?*const Row, newRow: *const Row) !bool {
var referencedBy = try ReferencedBySet.load(self.allocator, self.db, self.name);
defer referencedBy.deinit();
for (referencedBy.specs.items) |spec| {
const oldValue = if (old) |o| o.get(spec.refField) else null;
if (oldValue == null or oldValue.? == .none) continue;
const newValue = newRow.get(spec.refField);
if (newValue != null and Value.eql(oldValue.?, newValue.?)) continue;
var child = try Table.open(self.db, self.allocator, spec.childTable);
defer child.deinit();
const rows = try child.scanAll();
defer freeRows(self.allocator, rows);
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
for (rows) |*r| {
if (selfReferencing) {
if (r.get(pkField)) |rowPk| {
if (Value.eql(rowPk, pk)) continue;
}
}
const childValue = r.get(spec.childField) orelse continue;
if (childValue == .none) continue;
if (Value.eql(childValue, oldValue.?)) return true;
}
}
return false;
}
/// Deletes `pk`, honoring every `ON DELETE` mode registered against
/// this table by some OTHER table's `createForeignKey` (see
/// `idx.OnDelete`'s doc comment): a `restrict`-mode reference still
/// pointing at this row aborts the whole call with
/// `error.ForeignKeyInUse` and nothing is mutated; a `cascade`-mode
/// one instead removes the referencing rows too, recursively, before
/// this row itself goes - see `planCascade`'s doc comment for why
/// that has to happen in two passes (plan every removal first, then
/// execute the plan) rather than deleting as it discovers each one.
/// A `pk` that does not exist is a silent no-op, same as before
/// cascading existed.
pub fn delete(self: *Table, pk: Value) !void {
var visited = CascadeVisited.init(self.allocator);
defer visited.deinit();
var plan = std.ArrayList(CascadeAction).init(self.allocator);
defer {
for (plan.items) |a| a.deinit(self.allocator);
plan.deinit();
}
try self.planCascade(pk, &visited, &plan);
for (plan.items) |action| {
var t = try Table.open(self.db, self.allocator, action.table);
defer t.deinit();
try t.executeDelete(action.pk);
}
}
/// Applies `sets` on top of every row matching `pred` (or every row, if
/// null) - SQL's `UPDATE <table> SET ... WHERE pred`. Deliberately not a
/// bespoke in-place mutation path: each match is merged with `sets`
/// (a `set` entry overrides a same-named existing field, or is appended
/// if the row does not have that field yet) and written back through
/// `put`, the exact same call an external caller doing `get` then `put`
/// by hand would make - so every secondary/composite index touched by a
/// changed field is maintained exactly like any other write, with zero
/// special-casing here. Uses `selectAllMatches` rather than `selectPage`
/// because every match must be visited regardless of any page window -
/// there is no `LIMIT` on an `UPDATE`. Returns the number of rows
/// updated.
pub fn updateWhere(self: *Table, pred: ?Predicate, sets: []const Field) !usize {
const matches = try self.selectAllMatches(pred);
defer freeRows(self.allocator, matches);
for (matches) |*row| {
const pk = row.get(pkField) orelse continue;
var merged = Row.init(self.allocator);
defer merged.deinit();
for (row.fields.items) |f| {
if (std.mem.eql(u8, f.name, pkField)) continue;
try merged.set(f.name, f.value);
}
for (sets) |f| try merged.set(f.name, f.value);
try self.put(pk, merged.fields.items);
}
return matches.len;
}
/// Deletes every row matching `pred` (or every row, if null) - SQL's
/// `DELETE FROM <table> WHERE pred`. Mirrors `updateWhere`: materializes
/// the full match set with `selectAllMatches` (a `DELETE` has no
/// `LIMIT` either, so partial-scan short-circuiting is wrong here same
/// as for update) then removes each match through the existing single-row
/// `delete`, so every secondary/composite index touched by the deleted
/// row is maintained exactly like a caller doing `get` then `delete` by
/// hand would - zero special-casing. Returns the number of rows deleted.
pub fn deleteWhere(self: *Table, pred: ?Predicate) !usize {
const matches = try self.selectAllMatches(pred);
defer freeRows(self.allocator, matches);
for (matches) |*row| {
const pk = row.get(pkField) orelse continue;
try self.delete(pk);
}
return matches.len;
}
/// Full table scan decoding every row, oldest key order. Callers own the
/// returned slice and each Row in it (see freeRows).
pub fn scanAll(self: *Table) ![]Row {
const prefix = try idx.rowPrefix(self.allocator, self.name);
defer self.allocator.free(prefix);
var results = std.ArrayList(Row).init(self.allocator);
errdefer {
for (results.items) |*r| r.deinit();
results.deinit();
}
var it = try self.db.scanPrefix(self.allocator, prefix);
defer it.deinit();
while (try it.next()) |entry| {
const decoded = try Row.decode(self.allocator, entry.value);
try results.append(decoded);
}
return results.toOwnedSlice();
}
/// Selects rows matching `pred` (or every row, if null). When `pred` is
/// a single indexed condition, or an And containing one, the matching
/// index is used to build a narrowed candidate set before the full
/// predicate is re-checked against each candidate row - so results are
/// always correct even though the optimization only ever inspects one
/// conjunct's index.
pub fn select(self: *Table, pred: ?Predicate) ![]Row {
return self.selectPage(pred, .{});
}
/// Same as `select`, but skips the first `page.offset` matches and stops
/// once `page.limit` matches have been collected (0 = unlimited, the
/// same convention `protocol.ScanArgs` uses). Both the full-scan and
/// index-narrowed paths stop iterating as soon as the limit is met
/// instead of materializing the whole match set first - a real
/// efficiency win for `LIMIT`-bounded queries against large tables, not
/// just a post-hoc slice of an already-full result.
pub fn selectPage(self: *Table, pred: ?Predicate, page: Page) ![]Row {
if (pred) |p| {
if (self.findCompositeMatch(p)) |match| {
const cursor = try self.compositeCursorForMatch(match);
return self.selectByIndexPage(p, cursor, page);
}
if (self.findIndexableLeaf(p)) |leaf| {
const cursor = try self.cursorForLeaf(leaf);
return self.selectByIndexPage(p, cursor, page);
}
}
return self.scanFilteredPage(pred, page);
}
/// Same as `selectPage`, but sorts the matches by `orderBy` (when
/// non-empty, one or more columns applied left to right - see
/// `OrderBy`'s doc comment) before applying `page`'s offset/limit
/// window. `selectPage`'s early-stop optimization (dropping a full scan
/// or index cursor the moment `page.limit` matches are collected) is
/// only correct when results come out in the strategy's natural order -
/// sorting requires the full match set first, so an `orderBy` request
/// materializes every match via `selectAllMatches` (still using
/// whichever index `explain` would report, just without the early
/// stop), sorts it, then slices the requested window with
/// `applyRowPage`. An empty `orderBy` falls straight through to
/// `selectPage`, keeping the early-stop behavior unchanged for the
/// common unordered case.
pub fn selectPageOrdered(self: *Table, pred: ?Predicate, page: Page, orderBy: []const OrderBy) ![]Row {
if (orderBy.len == 0) return self.selectPage(pred, page);
const all = try self.selectAllMatches(pred);
errdefer freeRows(self.allocator, all);
sortRowsBy(all, orderBy);
return applyRowPage(self.allocator, all, page);
}
/// Collects every row matching `pred`, unpaged - the same
/// index-vs-full-scan strategy choice `selectPage`/`explain` make,
/// just always run to completion instead of stopping at a page limit.
fn selectAllMatches(self: *Table, pred: ?Predicate) ![]Row {
if (pred) |p| {
if (self.findCompositeMatch(p)) |match| {
const cursor = try self.compositeCursorForMatch(match);
return self.selectByIndexPage(p, cursor, .{});
}
if (self.findIndexableLeaf(p)) |leaf| {
const cursor = try self.cursorForLeaf(leaf);
return self.selectByIndexPage(p, cursor, .{});
}
}
return self.scanFilteredPage(pred, .{});
}
/// Reports which strategy `selectPage` would pick for `pred` without
/// running it - the exact same decision ladder (composite match first,
/// then a single-field indexable leaf, then a full scan), just stopping
/// one step short of building a cursor. Exists so a caller (the CLI's
/// `table explain`, its network/repl mirrors) can answer "would this
/// query use an index" without materializing rows or mutating anything,
/// which is why this takes `*const Table` unlike `selectPage`.
pub fn explain(self: *const Table, allocator: Allocator, pred: ?Predicate) !Plan {
if (pred) |p| {
if (self.findCompositeMatch(p)) |match| {
const spec = self.composite.specs.items[match.specIdx];
const isRange = match.rangeLow != null or match.rangeHigh != null;
return .{
.kind = if (isRange) .composite_range else .composite_eq,
.indexName = try allocator.dupe(u8, spec.name),
.matchedFields = match.matchedLen,
.totalFields = spec.fields.len,
};
}
if (self.findIndexableLeaf(p)) |leaf| {
const field = switch (leaf) {
.eq, .lt, .lte, .gt, .gte => |c| c.field,
else => unreachable,
};
const isRange = switch (leaf) {
.lt, .lte, .gt, .gte => true,
else => false,
};
return .{
.kind = if (isRange) .index_range else .index_eq,
.indexName = try allocator.dupe(u8, field),
.matchedFields = 1,
.totalFields = 1,
};
}
}
return .{ .kind = .full_scan };
}
fn scanFilteredPage(self: *Table, pred: ?Predicate, page: Page) ![]Row {
const prefix = try idx.rowPrefix(self.allocator, self.name);
defer self.allocator.free(prefix);
var results = std.ArrayList(Row).init(self.allocator);
errdefer {
for (results.items) |*r| r.deinit();
results.deinit();
}
var skipped: usize = 0;
var it = try self.db.scanPrefix(self.allocator, prefix);
defer it.deinit();
while (try it.next()) |entry| {
var decoded = try Row.decode(self.allocator, entry.value);
const matches = if (pred) |p| p.evaluate(&decoded) else true;
if (!matches) {
decoded.deinit();
continue;
}
if (skipped < page.offset) {
skipped += 1;
decoded.deinit();
continue;
}
try results.append(decoded);
if (page.limit != 0 and results.items.len >= page.limit) break;
}
return results.toOwnedSlice();
}
/// Streams primary keys straight from the index cursor instead of first
/// materializing the whole candidate list (see `idx.EqCursor`/
/// `idx.RangeCursor`): once `page.limit` rows are collected the
/// underlying `Db` scan is simply dropped without decoding or even
/// reading whatever index entries would have come after it, which
/// matters when the indexed value has far more matches than the page
/// being asked for.
fn selectByIndexPage(self: *Table, p: Predicate, cursorIn: IndexCursor, page: Page) ![]Row {
var cursor = cursorIn;
defer cursor.deinit();
var results = std.ArrayList(Row).init(self.allocator);
errdefer {
for (results.items) |*r| r.deinit();
results.deinit();
}
var skipped: usize = 0;
while (try cursor.next(self.allocator)) |pk| {
defer pk.free(self.allocator);
var r = try self.get(pk) orelse continue;
if (!p.evaluate(&r)) {
r.deinit();
continue;
}
if (skipped < page.offset) {
skipped += 1;
r.deinit();
continue;
}
try results.append(r);
if (page.limit != 0 and results.items.len >= page.limit) break;
}
return results.toOwnedSlice();
}
fn findIndexableLeaf(self: *const Table, p: Predicate) ?Predicate {
switch (p) {
.eq, .lt, .lte, .gt, .gte => |c| {
if (self.indexes.has(c.field)) return p;
return null;
},
.and_ => |preds| {
for (preds) |sub| {
if (self.findIndexableLeaf(sub)) |found| return found;
}
return null;
},
else => return null,
}
}
/// Streaming counterpart of the old `pksForLeaf` (which returned a fully
/// materialized `[]Value`): wraps whichever `idx` cursor constructor
/// matches the leaf's comparison so `selectByIndexPage` can decode and
/// discard candidates one at a time.
const IndexCursor = union(enum) {
eq: idx.EqCursor,
range: idx.RangeCursor,
compositeEq: idx.CompositeEqCursor,
compositeRange: idx.CompositeRangeCursor,
fn next(self: *IndexCursor, allocator: Allocator) !?Value {
return switch (self.*) {
.eq => |*c| c.next(allocator),
.range => |*c| c.next(allocator),
.compositeEq => |*c| c.next(allocator),
.compositeRange => |*c| c.next(allocator),
};
}
fn deinit(self: *IndexCursor) void {
switch (self.*) {
.eq => |*c| c.deinit(),
.range => |*c| c.deinit(),
.compositeEq => |*c| c.deinit(),
.compositeRange => |*c| c.deinit(),
}
}
};
fn cursorForLeaf(self: *Table, leaf: Predicate) !IndexCursor {
return switch (leaf) {
.eq => |c| .{ .eq = try idx.scanEqCursor(self.db, self.allocator, self.name, c.field, c.value) },
.lt => |c| .{ .range = try idx.scanRangeCursor(self.db, self.allocator, self.name, c.field, null, .{ .value = c.value, .inclusive = false }) },
.lte => |c| .{ .range = try idx.scanRangeCursor(self.db, self.allocator, self.name, c.field, null, .{ .value = c.value, .inclusive = true }) },
.gt => |c| .{ .range = try idx.scanRangeCursor(self.db, self.allocator, self.name, c.field, .{ .value = c.value, .inclusive = false }, null) },
.gte => |c| .{ .range = try idx.scanRangeCursor(self.db, self.allocator, self.name, c.field, .{ .value = c.value, .inclusive = true }, null) },
else => unreachable,
};
}
const CompositeMatch = struct {
specIdx: usize,
values: [maxCompositeFields]Value,
matchedLen: usize,
// A range condition on the column immediately after the matched
// eq prefix (spec.fields[matchedLen]), if the predicate has one -
// same leftmost-prefix rule a real multi-column B-tree range scan
// follows: only the column right after the pinned prefix can be
// range-bound by the index, anything further is unconstrained.
rangeLow: ?idx.Bound = null,
rangeHigh: ?idx.Bound = null,
};
/// Looks for a composite index whose leading ("leftmost") columns are
/// all pinned down by a top-level `eq` conjunct somewhere in `p` (an
/// `and_`, in any order - unlike a real SQL planner this does not
/// recurse into nested and_/or_, matching `findIndexableLeaf`'s scope).
/// The match may cover a strict prefix of the index's columns, not
/// necessarily all of them: a query naming only the first K of N
/// indexed fields still gets a narrowed lookup, same leftmost-prefix
/// rule a multi-column B-tree index gives you. When the column right
/// after the matched prefix also has a top-level lt/lte/gt/gte conjunct
/// on it, that becomes a range bound narrowing the scan further instead
/// of leaving it unconstrained. `selectByIndexPage` re-evaluates the
/// full predicate against every candidate row regardless, so an
/// unmatched suffix of columns (or any other conjunct entirely) never
/// affects correctness, only how much the index narrows the scan.
fn findCompositeMatch(self: *const Table, p: Predicate) ?CompositeMatch {
const preds = switch (p) {
.and_ => |ps| ps,
else => return null,
};
for (self.composite.specs.items, 0..) |spec, si| {
var values: [maxCompositeFields]Value = undefined;
var matched: usize = 0;
for (spec.fields) |f| {
const found = for (preds) |leaf| {
if (leaf == .eq and std.mem.eql(u8, leaf.eq.field, f)) break leaf.eq.value;
} else null;
const v = found orelse break;
values[matched] = v;
matched += 1;
}
if (matched == 0) continue;
var rangeLow: ?idx.Bound = null;
var rangeHigh: ?idx.Bound = null;
if (matched < spec.fields.len) {
const nextField = spec.fields[matched];
for (preds) |leaf| {
switch (leaf) {
.gt => |c| if (std.mem.eql(u8, c.field, nextField)) {
rangeLow = .{ .value = c.value, .inclusive = false };
},
.gte => |c| if (std.mem.eql(u8, c.field, nextField)) {
rangeLow = .{ .value = c.value, .inclusive = true };
},
.lt => |c| if (std.mem.eql(u8, c.field, nextField)) {
rangeHigh = .{ .value = c.value, .inclusive = false };
},
.lte => |c| if (std.mem.eql(u8, c.field, nextField)) {
rangeHigh = .{ .value = c.value, .inclusive = true };
},
else => {},
}
}
}
return .{ .specIdx = si, .values = values, .matchedLen = matched, .rangeLow = rangeLow, .rangeHigh = rangeHigh };
}
return null;
}
fn compositeCursorForMatch(self: *Table, match: CompositeMatch) !IndexCursor {
const spec = self.composite.specs.items[match.specIdx];
const eqValues = match.values[0..match.matchedLen];
if (match.rangeLow != null or match.rangeHigh != null) {
return .{ .compositeRange = try idx.scanCompositeRangeCursor(self.db, self.allocator, self.name, spec.name, eqValues, match.rangeLow, match.rangeHigh) };
}
return .{ .compositeEq = try idx.scanCompositeEqCursor(self.db, self.allocator, self.name, spec.name, eqValues) };
}
};
fn valueTupleEql(a: []const Value, b: []const Value) bool {
if (a.len != b.len) return false;
for (a, b) |av, bv| {
if (!Value.eql(av, bv)) return false;
}
return true;
}
pub fn freeRows(allocator: Allocator, rows: []Row) void {
for (rows) |*r| r.deinit();
allocator.free(rows);
}
/// Stable in-place sort of `rows` by `order`, using `rowmod.compare`'s
/// total order across value types (see `OrderBy`'s doc comment for how a
/// missing field sorts). Columns are compared left to right: the first
/// entry decides unless it ties (`.eq`), in which case the next entry
/// breaks the tie, and so on - a plain lexicographic comparator chain, the
/// same rule SQL's `ORDER BY a, b, c` follows. `std.sort.block` rather than
/// the faster unstable `std.sort.pdq` specifically so ties that survive
/// every column preserve scan order, matching what `OrderBy`'s doc comment
/// promises callers.
fn sortRowsBy(rows: []Row, order: []const OrderBy) void {
const Ctx = struct {
order: []const OrderBy,
fn lessThan(ctx: @This(), a: Row, b: Row) bool {
for (ctx.order) |ob| {
const av = a.get(ob.field) orelse Value{ .none = {} };
const bv = b.get(ob.field) orelse Value{ .none = {} };
const cmp = rowmod.compare(av, bv);
if (cmp == .eq) continue;
return if (ob.descending) cmp == .gt else cmp == .lt;
}
return false;
}
};
std.sort.block(Row, rows, Ctx{ .order = order }, Ctx.lessThan);
}
/// Slices an already-materialized, unpaged match set (as `selectAllMatches`
/// returns) down to `page`'s offset/limit window, freeing every row that
/// falls outside it and the original backing allocation - the ordered
/// counterpart of the early-stop windowing `selectByIndexPage`/
/// `scanFilteredPage` do inline while scanning. `rows` must not be used
/// after this call regardless of error (see `errdefer` at the call site).
fn applyRowPage(allocator: Allocator, rows: []Row, page: Page) ![]Row {
const start = @min(page.offset, rows.len);
const end = if (page.limit == 0) rows.len else @min(rows.len, start + page.limit);
for (rows[0..start]) |*r| r.deinit();
for (rows[end..]) |*r| r.deinit();
const kept = rows[start..end];
const out = try allocator.alloc(Row, kept.len);
@memcpy(out, kept);
allocator.free(rows);
return out;
}
fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db {
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
return Db.open(allocator, dbPath, .{});
}
test "table put get delete round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{
.{ .name = "name", .value = .{ .text = "ada" } },
.{ .name = "age", .value = .{ .int = 36 } },
});
var got = (try table.get(.{ .int = 1 })).?;
defer got.deinit();
try std.testing.expectEqualStrings("ada", got.get("name").?.text);
try std.testing.expectEqual(@as(i64, 36), got.get("age").?.int);
try std.testing.expectEqual(@as(i64, 1), got.get(pkField).?.int);
try table.delete(.{ .int = 1 });
try std.testing.expect((try table.get(.{ .int = 1 })) == null);
}
test "table put replaces whole row" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{
.{ .name = "name", .value = .{ .text = "ada" } },
.{ .name = "age", .value = .{ .int = 36 } },
});
try table.put(.{ .int = 1 }, &.{
.{ .name = "name", .value = .{ .text = "ada" } },
});
var got = (try table.get(.{ .int = 1 })).?;
defer got.deinit();
try std.testing.expect(got.get("age") == null);
try std.testing.expectEqualStrings("ada", got.get("name").?.text);
}
test "createIndex backfills then eq select uses index" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.createIndex("city");
try std.testing.expect(table.hasIndex("city"));
const results = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 2), results.len);
}
test "like predicate filters by pattern and never uses an index on that field" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
// Index the very field the like-predicate targets: `like` must still
// fall back to a full scan (`Value.compare`-based index entries have no
// notion of substring matching), never mistake this for an eq lookup.
try table.createIndex("name");
try table.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada Lovelace" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "Alan Turing" } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "name", .value = .{ .text = "Grace Hopper" } }});
{
const results = try table.select(.{ .like = .{ .field = "name", .value = .{ .text = "A%" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 2), results.len);
}
{
const results = try table.select(.{ .like = .{ .field = "name", .value = .{ .text = "%ing" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Alan Turing", results[0].get("name").?.text);
}
{
const results = try table.select(.{ .like = .{ .field = "name", .value = .{ .text = "%z%" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 0), results.len);
}
var plan = try table.explain(allocator, .{ .like = .{ .field = "name", .value = .{ .text = "A%" } } });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.full_scan, plan.kind);
}
test "like combines with an indexed eq leaf inside and_ without breaking the index plan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "name", .value = .{ .text = "Ada" } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "name", .value = .{ .text = "Boris" } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "name", .value = .{ .text = "Ada" } } });
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .like = .{ .field = "name", .value = .{ .text = "A%" } } },
};
const pred = Predicate{ .and_ = &preds };
// findIndexableLeaf must still pick the `eq` leaf on `city` for the
// index scan even though `like` sits right next to it in the same and_;
// selectByIndexPage then re-evaluates the whole predicate (including
// `like`) against every index candidate.
var plan = try table.explain(allocator, pred);
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, plan.kind);
try std.testing.expectEqualStrings("city", plan.indexName.?);
const results = try table.select(pred);
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqual(@as(i64, 1), results[0].get(pkField).?.int);
}
test "index stays in sync across update and delete" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
{
const warsaw = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, warsaw);
try std.testing.expectEqual(@as(usize, 1), warsaw.len);
}
// Move the row to a different city: the old index entry must disappear.
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
{
const warsaw = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, warsaw);
try std.testing.expectEqual(@as(usize, 0), warsaw.len);
const krakow = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } });
defer freeRows(allocator, krakow);
try std.testing.expectEqual(@as(usize, 1), krakow.len);
}
try table.delete(.{ .int = 1 });
{
const krakow = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } });
defer freeRows(allocator, krakow);
try std.testing.expectEqual(@as(usize, 0), krakow.len);
}
}
test "dropIndex removes entries and falls back to full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.dropIndex("city");
try std.testing.expect(!table.hasIndex("city"));
// Still correct without the index, just via full scan.
const results = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
}
test "range predicate uses index and respects bounds" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "events");
defer table.deinit();
try table.createIndex("score");
var i: i64 = 0;
while (i < 10) : (i += 1) {
try table.put(.{ .int = i }, &.{.{ .name = "score", .value = .{ .int = i } }});
}
const results = try table.select(.{ .gte = .{ .field = "score", .value = .{ .int = 7 } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 3), results.len);
}
test "compound and predicate narrows via one indexed conjunct" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 20 } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 40 } },
});
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .gte = .{ .field = "age", .value = .{ .int = 30 } } },
};
const results = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqual(@as(i64, 2), results[0].get(pkField).?.int);
}
test "not and or predicates evaluate via full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "age", .value = .{ .int = 10 } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 20 } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 30 } }});
const orPreds = [_]Predicate{
.{ .eq = .{ .field = "age", .value = .{ .int = 10 } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const orResults = try table.select(.{ .or_ = &orPreds });
defer freeRows(allocator, orResults);
try std.testing.expectEqual(@as(usize, 2), orResults.len);
const inner = Predicate{ .eq = .{ .field = "age", .value = .{ .int = 20 } } };
const notResult = try table.select(.{ .not_ = &inner });
defer freeRows(allocator, notResult);
try std.testing.expectEqual(@as(usize, 2), notResult.len);
}
test "is_null/is_not_null evaluate against missing fields, explicit none, and present values" {
const allocator = std.testing.allocator;
var sparse = Row.init(allocator);
defer sparse.deinit();
try sparse.set("age", .{ .int = 10 });
var withNone = Row.init(allocator);
defer withNone.deinit();
try withNone.set("city", .{ .none = {} });
var withCity = Row.init(allocator);
defer withCity.deinit();
try withCity.set("city", .{ .text = "warsaw" });
const isNull = Predicate{ .is_null = "city" };
const isNotNull = Predicate{ .is_not_null = "city" };
try std.testing.expect(isNull.evaluate(&sparse));
try std.testing.expect(!isNotNull.evaluate(&sparse));
try std.testing.expect(isNull.evaluate(&withNone));
try std.testing.expect(!isNotNull.evaluate(&withNone));
try std.testing.expect(!isNull.evaluate(&withCity));
try std.testing.expect(isNotNull.evaluate(&withCity));
}
test "is_null selects only sparse rows missing the field, over a full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "people");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 40 } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 50 } }});
var plan = try table.explain(allocator, .{ .is_null = "city" });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.full_scan, plan.kind);
const missing = try table.select(.{ .is_null = "city" });
defer freeRows(allocator, missing);
try std.testing.expectEqual(@as(usize, 2), missing.len);
const present = try table.select(.{ .is_not_null = "city" });
defer freeRows(allocator, present);
try std.testing.expectEqual(@as(usize, 1), present.len);
// is_null on an indexed field still falls back to full_scan (not
// indexable), but the result is unaffected by the index's presence.
try table.createIndex("age");
const stillMissing = try table.select(.{ .is_null = "city" });
defer freeRows(allocator, stillMissing);
try std.testing.expectEqual(@as(usize, 2), stillMissing.len);
}
test "invalid identifiers are rejected" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
try std.testing.expectError(error.InvalidIdentifier, Table.open(&db, allocator, ""));
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try std.testing.expectError(error.InvalidIdentifier, table.createIndex("bad\x00field"));
}
test "selectPage skips offset and stops at limit on a full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
// Row keys are the pk's order-preserving encoding, so full-scan order
// is ascending pk regardless of insertion order.
var pk: i64 = 10;
while (pk >= 1) : (pk -= 1) {
try table.put(.{ .int = pk }, &.{});
}
const page = try table.selectPage(null, .{ .offset = 3, .limit = 4 });
defer freeRows(allocator, page);
try std.testing.expectEqual(@as(usize, 4), page.len);
var expected: i64 = 4;
for (page) |r| {
try std.testing.expectEqual(expected, r.get(pkField).?.int);
expected += 1;
}
}
test "selectPage with limit 0 matches unlimited select" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
var pk: i64 = 1;
while (pk <= 5) : (pk += 1) try table.put(.{ .int = pk }, &.{});
const all = try table.select(null);
defer freeRows(allocator, all);
const page = try table.selectPage(null, .{});
defer freeRows(allocator, page);
try std.testing.expectEqual(all.len, page.len);
}
test "selectPage offset past the end returns nothing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{});
try table.put(.{ .int = 2 }, &.{});
const page = try table.selectPage(null, .{ .offset = 50 });
defer freeRows(allocator, page);
try std.testing.expectEqual(@as(usize, 0), page.len);
}
test "selectPage paginates through an index-narrowed predicate in pk order" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
var pk: i64 = 1;
while (pk <= 6) : (pk += 1) {
try table.put(.{ .int = pk }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
}
// Noise row indexed under a different value - must never leak into the
// paginated "warsaw" result regardless of offset/limit.
try table.put(.{ .int = 99 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
const first = try table.selectPage(pred, .{ .limit = 2 });
defer freeRows(allocator, first);
try std.testing.expectEqual(@as(usize, 2), first.len);
try std.testing.expectEqual(@as(i64, 1), first[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 2), first[1].get(pkField).?.int);
const second = try table.selectPage(pred, .{ .offset = 2, .limit = 2 });
defer freeRows(allocator, second);
try std.testing.expectEqual(@as(usize, 2), second.len);
try std.testing.expectEqual(@as(i64, 3), second[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 4), second[1].get(pkField).?.int);
const rest = try table.selectPage(pred, .{ .offset = 4 });
defer freeRows(allocator, rest);
try std.testing.expectEqual(@as(usize, 2), rest.len);
try std.testing.expectEqual(@as(i64, 5), rest[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 6), rest[1].get(pkField).?.int);
}
test "selectPage against a large indexed bucket stays correct with a small limit" {
// Exercises the streaming `cursorForLeaf` path (see index.zig's
// EqCursor) against a bucket far larger than the requested page: a
// regression that only materialized the first `limit` matches would
// silently pass this test too, but a regression that went back to
// materializing the whole 500-row match set first would still pass on
// correctness alone - the point of the streaming rewrite is that this
// scan touches roughly `offset + limit` index entries, not 500, which is
// asserted structurally in index.zig's cursor tests via early deinit.
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
var pk: i64 = 0;
while (pk < 500) : (pk += 1) {
try table.put(.{ .int = pk }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
}
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
const page = try table.selectPage(pred, .{ .offset = 497, .limit = 10 });
defer freeRows(allocator, page);
try std.testing.expectEqual(@as(usize, 3), page.len);
try std.testing.expectEqual(@as(i64, 497), page[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 498), page[1].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 499), page[2].get(pkField).?.int);
}
test "selectPageOrdered sorts a full scan ascending and descending by an int field" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
// Insert pks out of score order so ascending pk (the natural full-scan
// order) is NOT already sorted by `score` - a regression that forgot to
// sort and just returned scan order would fail both assertions below.
const scores = [_]i64{ 30, 10, 50, 20, 40 };
for (scores, 1..) |score, pk| {
try table.put(.{ .int = @intCast(pk) }, &.{.{ .name = "score", .value = .{ .int = score } }});
}
const asc = try table.selectPageOrdered(null, .{}, &.{.{ .field = "score" }});
defer freeRows(allocator, asc);
var expected = [_]i64{ 10, 20, 30, 40, 50 };
try std.testing.expectEqual(expected.len, asc.len);
for (asc, 0..) |r, i| try std.testing.expectEqual(expected[i], r.get("score").?.int);
const desc = try table.selectPageOrdered(null, .{}, &.{.{ .field = "score", .descending = true }});
defer freeRows(allocator, desc);
std.mem.reverse(i64, &expected);
for (desc, 0..) |r, i| try std.testing.expectEqual(expected[i], r.get("score").?.int);
}
test "selectPageOrdered applies offset/limit after sorting, not before" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
var pk: i64 = 1;
while (pk <= 10) : (pk += 1) {
// Descending score as pk increases, so "top 3 by score desc" is the
// FIRST 3 pks, not the last 3 - a page-then-sort bug (paging the
// natural scan order before sorting) would return the wrong rows.
try table.put(.{ .int = pk }, &.{.{ .name = "score", .value = .{ .int = 100 - pk } }});
}
const top3 = try table.selectPageOrdered(null, .{ .limit = 3 }, &.{.{ .field = "score", .descending = true }});
defer freeRows(allocator, top3);
try std.testing.expectEqual(@as(usize, 3), top3.len);
try std.testing.expectEqual(@as(i64, 1), top3[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 2), top3[1].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 3), top3[2].get(pkField).?.int);
const nextPage = try table.selectPageOrdered(null, .{ .offset = 3, .limit = 3 }, &.{.{ .field = "score", .descending = true }});
defer freeRows(allocator, nextPage);
try std.testing.expectEqual(@as(i64, 4), nextPage[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 6), nextPage[2].get(pkField).?.int);
}
test "selectPageOrdered sorts text fields lexically and is stable on ties" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
// Two "warsaw" rows (pk 1 and 3) tie on `city` - a stable sort must keep
// them in scan order (1 before 3) relative to each other even though
// "krakow" (pk 2) sorts between them lexically... except it doesn't,
// "krakow" < "warsaw", so both warsaw rows land after it, still in
// their original relative order (1 then 3).
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const rows = try table.selectPageOrdered(null, .{}, &.{.{ .field = "city" }});
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 3), rows.len);
try std.testing.expectEqualStrings("krakow", rows[0].get("city").?.text);
try std.testing.expectEqual(@as(i64, 2), rows[0].get(pkField).?.int);
try std.testing.expectEqualStrings("warsaw", rows[1].get("city").?.text);
try std.testing.expectEqual(@as(i64, 1), rows[1].get(pkField).?.int);
try std.testing.expectEqualStrings("warsaw", rows[2].get("city").?.text);
try std.testing.expectEqual(@as(i64, 3), rows[2].get(pkField).?.int);
}
test "selectPageOrdered groups rows missing the sort field at the front" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "score", .value = .{ .int = 5 } }});
try table.put(.{ .int = 2 }, &.{});
try table.put(.{ .int = 3 }, &.{.{ .name = "score", .value = .{ .int = 1 } }});
const rows = try table.selectPageOrdered(null, .{}, &.{.{ .field = "score" }});
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(i64, 2), rows[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 3), rows[1].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 1), rows[2].get(pkField).?.int);
}
test "selectPageOrdered on an indexed predicate still uses the index, not a full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
var pk: i64 = 1;
while (pk <= 20) : (pk += 1) {
const city: []const u8 = if (@mod(pk, 2) == 0) "warsaw" else "krakow";
try table.put(.{ .int = pk }, &.{
.{ .name = "city", .value = .{ .text = city } },
.{ .name = "score", .value = .{ .int = 100 - pk } },
});
}
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
// Cross-check against `explain` (same predicate shape it already tests
// elsewhere): `selectAllMatches` must still pick the `city` index, only
// skipping the early-stop, not falling back to a full scan.
var plan = try table.explain(allocator, pred);
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, plan.kind);
const rows = try table.selectPageOrdered(pred, .{}, &.{.{ .field = "score" }});
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 10), rows.len);
var lastScore: i64 = std.math.minInt(i64);
for (rows) |r| {
const s = r.get("score").?.int;
try std.testing.expect(s >= lastScore);
lastScore = s;
try std.testing.expectEqualStrings("warsaw", r.get("city").?.text);
}
}
test "selectPageOrdered with two columns sorts by the first, breaking ties with the second" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "people");
defer table.deinit();
// Two cities, each with scores NOT already grouped or sorted by pk -
// a single-column sort on `city` alone would leave each city's rows in
// scan order (which happens to already be reverse-score here), so
// this only passes if `score` is truly applied as a tiebreaker.
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 30 } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 20 } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 10 } } });
try table.put(.{ .int = 4 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 40 } } });
const rows = try table.selectPageOrdered(null, .{}, &.{ .{ .field = "city" }, .{ .field = "score" } });
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 4), rows.len);
// krakow (score 20, 40) before warsaw (score 10, 30), each group
// internally ascending by score regardless of pk/insertion order.
try std.testing.expectEqual(@as(i64, 2), rows[0].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 4), rows[1].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 3), rows[2].get(pkField).?.int);
try std.testing.expectEqual(@as(i64, 1), rows[3].get(pkField).?.int);
}
test "selectPageOrdered with two columns honors independent directions per column" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "people");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 10 } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 20 } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 5 } } });
// city ascending, score DESCENDING - a comparator that only flips the
// whole tuple's sign (instead of applying `descending` per column)
// would get the city order right but the within-city score order
// backwards.
const rows = try table.selectPageOrdered(null, .{}, &.{ .{ .field = "city" }, .{ .field = "score", .descending = true } });
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(i64, 3), rows[0].get(pkField).?.int); // krakow
try std.testing.expectEqual(@as(i64, 2), rows[1].get(pkField).?.int); // warsaw score 20
try std.testing.expectEqual(@as(i64, 1), rows[2].get(pkField).?.int); // warsaw score 10
}
test "selectPageOrdered with an empty order-by falls through to selectPage's early-stop behavior" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "people");
defer table.deinit();
var pk: i64 = 1;
while (pk <= 5) : (pk += 1) {
try table.put(.{ .int = pk }, &.{});
}
const ordered = try table.selectPageOrdered(null, .{ .limit = 2 }, &.{});
defer freeRows(allocator, ordered);
const plain = try table.selectPage(null, .{ .limit = 2 });
defer freeRows(allocator, plain);
try std.testing.expectEqual(plain.len, ordered.len);
for (ordered, plain) |o, p| try std.testing.expectEqual(o.get(pkField).?.int, p.get(pkField).?.int);
}
test "createCompositeIndex backfills then full-tuple eq select uses it" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 40 } },
});
try table.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try std.testing.expect(table.hasCompositeIndex("city_age"));
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const results = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqual(@as(i64, 1), results[0].get(pkField).?.int);
}
test "composite index leftmost prefix narrows without constraining every column" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 40 } },
});
try table.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 50 } },
});
// Only the leading column is constrained - the composite index must
// still narrow the scan to the two warsaw rows via its leftmost prefix,
// and the (unindexed-alone) age column is unconstrained here.
const results = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 2), results.len);
}
test "composite index uses a range bound on the column after the matched prefix" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
var age: i64 = 20;
while (age < 30) : (age += 1) {
try table.put(.{ .int = age }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = age } },
});
try table.put(.{ .int = age + 100 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = age } },
});
}
// city == "warsaw" and 23 <= age < 27: must stay inside the warsaw
// bucket of the composite index even though krakow rows overlap the
// same age range byte-for-byte.
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .gte = .{ .field = "age", .value = .{ .int = 23 } } },
.{ .lt = .{ .field = "age", .value = .{ .int = 27 } } },
};
const results = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 4), results.len);
for (results) |r| {
try std.testing.expectEqualStrings("warsaw", r.get("city").?.text);
try std.testing.expect(r.get("age").?.int >= 23 and r.get("age").?.int < 27);
}
// A one-sided range (age > 27) narrows the same way.
const openPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .gt = .{ .field = "age", .value = .{ .int = 27 } } },
};
const openResults = try table.select(.{ .and_ = &openPreds });
defer freeRows(allocator, openResults);
try std.testing.expectEqual(@as(usize, 2), openResults.len);
for (openResults) |r| {
try std.testing.expectEqualStrings("warsaw", r.get("city").?.text);
try std.testing.expect(r.get("age").?.int > 27);
}
}
test "composite index stays in sync across update and delete" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const pred = Predicate{ .and_ = &preds };
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
{
const found = try table.select(pred);
defer freeRows(allocator, found);
try std.testing.expectEqual(@as(usize, 1), found.len);
}
// Moving age away from 30 must drop the old composite entry.
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 31 } },
});
{
const stale = try table.select(pred);
defer freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
}
// A row missing one of the composite columns entirely must not produce
// a (partial, ill-formed) entry: the put must still succeed, and the
// row must never surface as a false match on any age value including
// its own current (missing) one.
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
{
const wholeTable = try table.select(null);
defer freeRows(allocator, wholeTable);
try std.testing.expectEqual(@as(usize, 2), wholeTable.len);
const ageless = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 31 } } },
};
const noMatch = try table.select(.{ .and_ = &ageless });
defer freeRows(allocator, noMatch);
try std.testing.expectEqual(@as(usize, 1), noMatch.len);
try std.testing.expectEqual(@as(i64, 1), noMatch[0].get(pkField).?.int);
}
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.delete(.{ .int = 1 });
{
const afterDelete = try table.select(pred);
defer freeRows(allocator, afterDelete);
try std.testing.expectEqual(@as(usize, 0), afterDelete.len);
}
}
test "dropCompositeIndex removes entries and falls back to full scan" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.dropCompositeIndex("city_age");
try std.testing.expect(!table.hasCompositeIndex("city_age"));
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const results = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
}
test "createCompositeIndex rejects bad field counts and identifiers" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try std.testing.expectError(error.InvalidCompositeIndex, table.createCompositeIndex("solo", &.{"city"}));
try std.testing.expectError(error.InvalidIdentifier, table.createCompositeIndex("bad\x00name", &.{ "city", "age" }));
try std.testing.expectError(error.InvalidIdentifier, table.createCompositeIndex("ok", &.{ "city", "bad\x00field" }));
}
test "composite index survives a table reopen (persisted across process restart)" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
{
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
}
var reopened = try Table.open(&db, allocator, "users");
defer reopened.deinit();
try std.testing.expect(reopened.hasCompositeIndex("city_age"));
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const results = try reopened.select(.{ .and_ = &preds });
defer freeRows(allocator, results);
try std.testing.expectEqual(@as(usize, 1), results.len);
}
test "explain reports full_scan for a null predicate and an unindexed field" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
var noPred = try table.explain(allocator, null);
defer noPred.deinit(allocator);
try std.testing.expectEqual(PlanKind.full_scan, noPred.kind);
try std.testing.expect(noPred.indexName == null);
var unindexed = try table.explain(allocator, .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer unindexed.deinit(allocator);
try std.testing.expectEqual(PlanKind.full_scan, unindexed.kind);
}
test "explain reports index_eq and index_range for a single-field index" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "events");
defer table.deinit();
try table.createIndex("score");
try table.put(.{ .int = 1 }, &.{.{ .name = "score", .value = .{ .int = 5 } }});
var eqPlan = try table.explain(allocator, .{ .eq = .{ .field = "score", .value = .{ .int = 5 } } });
defer eqPlan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, eqPlan.kind);
try std.testing.expectEqualStrings("score", eqPlan.indexName.?);
try std.testing.expectEqual(@as(usize, 1), eqPlan.matchedFields);
try std.testing.expectEqual(@as(usize, 1), eqPlan.totalFields);
var rangePlan = try table.explain(allocator, .{ .gte = .{ .field = "score", .value = .{ .int = 5 } } });
defer rangePlan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_range, rangePlan.kind);
try std.testing.expectEqualStrings("score", rangePlan.indexName.?);
}
test "explain reports composite_eq and composite_range with matched/total field counts" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
// Only the leading column pinned -> composite_eq over a strict prefix
// (matchedFields 1 of 2). `findCompositeMatch` only looks inside a
// top-level `and_` (see its doc comment), so a bare single `eq` outside
// one does not reach the composite index at all - wrapping in `and_`
// with itself as the sole conjunct is what actually exercises the
// leftmost-prefix match here.
const prefixPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
};
var prefixOnly = try table.explain(allocator, .{ .and_ = &prefixPreds });
defer prefixOnly.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_eq, prefixOnly.kind);
try std.testing.expectEqualStrings("city_age", prefixOnly.indexName.?);
try std.testing.expectEqual(@as(usize, 1), prefixOnly.matchedFields);
try std.testing.expectEqual(@as(usize, 2), prefixOnly.totalFields);
// Both columns pinned by equality -> composite_eq, fully matched.
const eqPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
var fullEq = try table.explain(allocator, .{ .and_ = &eqPreds });
defer fullEq.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_eq, fullEq.kind);
try std.testing.expectEqual(@as(usize, 2), fullEq.matchedFields);
// Leading column pinned by equality, next column range-bound -> composite_range.
const rangePreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .gte = .{ .field = "age", .value = .{ .int = 18 } } },
};
var rangeMatch = try table.explain(allocator, .{ .and_ = &rangePreds });
defer rangeMatch.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_range, rangeMatch.kind);
try std.testing.expectEqualStrings("city_age", rangeMatch.indexName.?);
try std.testing.expectEqual(@as(usize, 1), rangeMatch.matchedFields);
}
test "explain never mutates the table (const receiver, no rows touched)" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
const constTable: *const Table = &table;
var plan = try constTable.explain(allocator, pred);
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, plan.kind);
// The row explain() was asked about is still there afterwards.
const rows = try table.select(pred);
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
}
test "updateWhere applies a set clause to every matching row and reports the count" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } });
try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } });
try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "active", .value = .{ .int = 0 } } });
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
const updated = try table.updateWhere(pred, &.{.{ .name = "active", .value = .{ .int = 1 } }});
try std.testing.expectEqual(@as(usize, 2), updated);
var row1 = (try table.get(.{ .int = 1 })).?;
defer row1.deinit();
try std.testing.expectEqual(Value{ .int = 1 }, row1.get("active").?);
try std.testing.expectEqualStrings("warsaw", row1.get("city").?.text);
var row3 = (try table.get(.{ .int = 3 })).?;
defer row3.deinit();
try std.testing.expectEqual(Value{ .int = 0 }, row3.get("active").?);
}
test "updateWhere with a null predicate touches every row" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "counters");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "n", .value = .{ .int = 5 } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "n", .value = .{ .int = 7 } }});
const updated = try table.updateWhere(null, &.{.{ .name = "flag", .value = .{ .text = "seen" } }});
try std.testing.expectEqual(@as(usize, 2), updated);
var row1 = (try table.get(.{ .int = 1 })).?;
defer row1.deinit();
try std.testing.expectEqualStrings("seen", row1.get("flag").?.text);
// Untouched fields survive the merge.
try std.testing.expectEqual(Value{ .int = 5 }, row1.get("n").?);
}
test "updateWhere adds a brand new field a matched row never had" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
_ = try table.updateWhere(null, &.{.{ .name = "tier", .value = .{ .text = "gold" } }});
var row = (try table.get(.{ .int = 1 })).?;
defer row.deinit();
try std.testing.expectEqualStrings("gold", row.get("tier").?.text);
try std.testing.expectEqualStrings("warsaw", row.get("city").?.text);
}
test "updateWhere keeps a secondary index in sync when the indexed field changes" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const updated = try table.updateWhere(
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
&.{.{ .name = "city", .value = .{ .text = "krakow" } }},
);
try std.testing.expectEqual(@as(usize, 1), updated);
// The stale index entry is gone, a fresh one under the new value works,
// and explain() confirms the index (not a full scan) still serves it.
const stale = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
const fresh = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } });
defer freeRows(allocator, fresh);
try std.testing.expectEqual(@as(usize, 1), fresh.len);
const constTable: *const Table = &table;
var plan = try constTable.explain(allocator, .{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, plan.kind);
}
test "updateWhere keeps a composite index in sync across leading and trailing column moves" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
.{ .name = "name", .value = .{ .text = "alice" } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 31 } },
.{ .name = "name", .value = .{ .text = "bob" } },
});
try table.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 30 } },
.{ .name = "name", .value = .{ .text = "carol" } },
});
try table.put(.{ .int = 4 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 40 } },
.{ .name = "name", .value = .{ .text = "dave" } },
});
// updateWhere matched through the composite index (both columns pinned
// by equality) moves the TRAILING column (age). The old composite entry
// for row 1 must vanish and a fresh one under the new age must serve it.
{
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const updated = try table.updateWhere(
.{ .and_ = &preds },
&.{.{ .name = "age", .value = .{ .int = 45 } }},
);
try std.testing.expectEqual(@as(usize, 1), updated);
}
{
const stalePreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const stale = try table.select(.{ .and_ = &stalePreds });
defer freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
const freshPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 45 } } },
};
const fresh = try table.select(.{ .and_ = &freshPreds });
defer freeRows(allocator, fresh);
try std.testing.expectEqual(@as(usize, 1), fresh.len);
try std.testing.expectEqual(@as(i64, 1), fresh[0].get(pkField).?.int);
const constTable: *const Table = &table;
var plan = try constTable.explain(allocator, .{ .and_ = &freshPreds });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_eq, plan.kind);
try std.testing.expectEqualStrings("city_age", plan.indexName.?);
try std.testing.expectEqual(@as(usize, 2), plan.matchedFields);
}
// Same check but moving the LEADING column (city) instead of the
// trailing one - a different code path in the composite key encoding
// (the prefix itself shifts, not just the suffix).
{
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 31 } } },
};
const updated = try table.updateWhere(
.{ .and_ = &preds },
&.{.{ .name = "city", .value = .{ .text = "gdansk" } }},
);
try std.testing.expectEqual(@as(usize, 1), updated);
}
{
const stalePreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 31 } } },
};
const stale = try table.select(.{ .and_ = &stalePreds });
defer freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
const freshPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "gdansk" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 31 } } },
};
const fresh = try table.select(.{ .and_ = &freshPreds });
defer freeRows(allocator, fresh);
try std.testing.expectEqual(@as(usize, 1), fresh.len);
try std.testing.expectEqual(@as(i64, 2), fresh[0].get(pkField).?.int);
}
// updateWhere reached through composite_range (leading column pinned by
// equality, trailing column range-bound) must update every row the
// range covers, and leave the composite index able to keep serving
// both afterwards even though only the unindexed "name" field changed.
{
const rangePreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } },
.{ .gte = .{ .field = "age", .value = .{ .int = 30 } } },
};
const constTable: *const Table = &table;
var rangePlan = try constTable.explain(allocator, .{ .and_ = &rangePreds });
defer rangePlan.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_range, rangePlan.kind);
try std.testing.expectEqualStrings("city_age", rangePlan.indexName.?);
const updated = try table.updateWhere(
.{ .and_ = &rangePreds },
&.{.{ .name = "name", .value = .{ .text = "updated" } }},
);
try std.testing.expectEqual(@as(usize, 2), updated);
}
// Final sanity sweep: every row is still reachable through the
// composite index under exactly its current (city, age), nothing was
// lost, duplicated, or left under a stale key.
const expectations = [_]struct { pk: i64, city: []const u8, age: i64, name: []const u8 }{
.{ .pk = 1, .city = "warsaw", .age = 45, .name = "alice" },
.{ .pk = 2, .city = "gdansk", .age = 31, .name = "bob" },
.{ .pk = 3, .city = "krakow", .age = 30, .name = "updated" },
.{ .pk = 4, .city = "krakow", .age = 40, .name = "updated" },
};
for (expectations) |exp| {
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = exp.city } } },
.{ .eq = .{ .field = "age", .value = .{ .int = exp.age } } },
};
const rows = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqual(exp.pk, rows[0].get(pkField).?.int);
try std.testing.expectEqualStrings(exp.name, rows[0].get("name").?.text);
}
const everyone = try table.select(null);
defer freeRows(allocator, everyone);
try std.testing.expectEqual(@as(usize, 4), everyone.len);
}
test "deleteWhere removes every matching row and reports the count" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
try table.put(.{ .int = 3 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } };
const deleted = try table.deleteWhere(pred);
try std.testing.expectEqual(@as(usize, 2), deleted);
try std.testing.expectEqual(@as(?Row, null), try table.get(.{ .int = 1 }));
try std.testing.expectEqual(@as(?Row, null), try table.get(.{ .int = 2 }));
var row3 = (try table.get(.{ .int = 3 })).?;
defer row3.deinit();
try std.testing.expectEqualStrings("krakow", row3.get("city").?.text);
}
test "deleteWhere with a null predicate empties the table" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "counters");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "n", .value = .{ .int = 5 } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "n", .value = .{ .int = 7 } }});
const deleted = try table.deleteWhere(null);
try std.testing.expectEqual(@as(usize, 2), deleted);
const everyone = try table.select(null);
defer freeRows(allocator, everyone);
try std.testing.expectEqual(@as(usize, 0), everyone.len);
}
test "deleteWhere with no matches deletes nothing and leaves the table untouched" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const deleted = try table.deleteWhere(.{ .eq = .{ .field = "city", .value = .{ .text = "gdansk" } } });
try std.testing.expectEqual(@as(usize, 0), deleted);
var row = (try table.get(.{ .int = 1 })).?;
defer row.deinit();
try std.testing.expectEqualStrings("warsaw", row.get("city").?.text);
}
test "deleteWhere removes stale secondary index entries so a fresh row can reuse the value" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("city");
try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const deleted = try table.deleteWhere(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
try std.testing.expectEqual(@as(usize, 1), deleted);
// The old index entry for pk 1 under "warsaw" must be gone, not just the
// row - otherwise a later put of a *different* pk under the same "warsaw"
// value would find a phantom sibling in the index.
const stale = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, stale);
try std.testing.expectEqual(@as(usize, 0), stale.len);
try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
const fresh = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer freeRows(allocator, fresh);
try std.testing.expectEqual(@as(usize, 1), fresh.len);
try std.testing.expectEqual(@as(i64, 2), fresh[0].get(pkField).?.int);
const constTable: *const Table = &table;
var plan = try constTable.explain(allocator, .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.index_eq, plan.kind);
}
test "deleteWhere removes stale composite index entries across matched equality prefix" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createCompositeIndex("city_age", &.{ "city", "age" });
try table.put(.{ .int = 1 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.put(.{ .int = 2 }, &.{
.{ .name = "city", .value = .{ .text = "warsaw" } },
.{ .name = "age", .value = .{ .int = 31 } },
});
try table.put(.{ .int = 3 }, &.{
.{ .name = "city", .value = .{ .text = "krakow" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
const preds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } },
.{ .eq = .{ .field = "age", .value = .{ .int = 30 } } },
};
const deleted = try table.deleteWhere(.{ .and_ = &preds });
try std.testing.expectEqual(@as(usize, 1), deleted);
const gone = try table.select(.{ .and_ = &preds });
defer freeRows(allocator, gone);
try std.testing.expectEqual(@as(usize, 0), gone.len);
// The composite index must still correctly serve the two untouched rows,
// proving the deleted row's entry was surgically removed, not the whole
// (city="warsaw") or (age=30) bucket.
const survivors = [_]struct { pk: i64, city: []const u8, age: i64 }{
.{ .pk = 2, .city = "warsaw", .age = 31 },
.{ .pk = 3, .city = "krakow", .age = 30 },
};
for (survivors) |exp| {
const survivorPreds = [_]Predicate{
.{ .eq = .{ .field = "city", .value = .{ .text = exp.city } } },
.{ .eq = .{ .field = "age", .value = .{ .int = exp.age } } },
};
const rows = try table.select(.{ .and_ = &survivorPreds });
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqual(exp.pk, rows[0].get(pkField).?.int);
const constTable: *const Table = &table;
var plan = try constTable.explain(allocator, .{ .and_ = &survivorPreds });
defer plan.deinit(allocator);
try std.testing.expectEqual(PlanKind.composite_eq, plan.kind);
}
}
test "createUniqueIndex backfills the plain index and rejects a colliding insert" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
try table.createUniqueIndex("email");
try std.testing.expect(table.hasUniqueIndex("email"));
try std.testing.expect(table.hasIndex("email"));
try std.testing.expectError(error.UniqueViolation, table.put(.{ .int = 2 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
}));
try table.put(.{ .int = 2 }, &.{.{ .name = "email", .value = .{ .text = "b@x.com" } }});
const rows = try table.scanAll();
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 2), rows.len);
}
test "createUniqueIndex refuses to install over data that already has a duplicate" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "dup@x.com" } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "email", .value = .{ .text = "dup@x.com" } }});
try std.testing.expectError(error.UniqueViolation, table.createUniqueIndex("email"));
try std.testing.expect(!table.hasUniqueIndex("email"));
// Refused install must not have left a half-installed plain index either.
try std.testing.expect(!table.hasIndex("email"));
}
test "createUniqueIndex over an already-indexed field reuses the index instead of duplicating entries" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createIndex("email");
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
try table.createUniqueIndex("email");
try std.testing.expect(table.hasUniqueIndex("email"));
try std.testing.expectError(error.UniqueViolation, table.put(.{ .int = 2 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
}));
const preds = Predicate{ .eq = .{ .field = "email", .value = .{ .text = "a@x.com" } } };
const rows = try table.select(preds);
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
}
test "unique constraint does not block updating a row's own value or an unrelated field" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
.{ .name = "age", .value = .{ .int = 30 } },
});
try table.createUniqueIndex("email");
// Re-writing the same email on the same row must not conflict with itself.
try table.put(.{ .int = 1 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
.{ .name = "age", .value = .{ .int = 31 } },
});
var row = (try table.get(.{ .int = 1 })).?;
defer row.deinit();
try std.testing.expectEqual(@as(i64, 31), row.get("age").?.int);
}
test "unique constraint frees a value for reuse once the owning row is deleted" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createUniqueIndex("email");
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
try std.testing.expectError(error.UniqueViolation, table.put(.{ .int = 2 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
}));
try table.delete(.{ .int = 1 });
try table.put(.{ .int = 2 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
var row = (try table.get(.{ .int = 2 })).?;
defer row.deinit();
try std.testing.expectEqualStrings("a@x.com", row.get("email").?.text);
}
test "dropIndex on a unique field also drops the unique constraint" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createUniqueIndex("email");
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
try table.dropIndex("email");
try std.testing.expect(!table.hasUniqueIndex("email"));
try std.testing.expect(!table.hasIndex("email"));
// With no constraint left, a colliding value must now be accepted.
try table.put(.{ .int = 2 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
const rows = try table.scanAll();
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 2), rows.len);
}
test "unique constraint survives a table reopen (persisted across process restart)" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
{
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.createUniqueIndex("email");
try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }});
}
var reopened = try Table.open(&db, allocator, "users");
defer reopened.deinit();
try std.testing.expect(reopened.hasUniqueIndex("email"));
try std.testing.expectError(error.UniqueViolation, reopened.put(.{ .int = 2 }, &.{
.{ .name = "email", .value = .{ .text = "a@x.com" } },
}));
}
test "createUniqueIndex rejects a bad field identifier" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try std.testing.expectError(error.InvalidIdentifier, table.createUniqueIndex("bad\x00field"));
}
test "rows missing the unique field entirely never collide with each other" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var table = try Table.open(&db, allocator, "users");
defer table.deinit();
try table.put(.{ .int = 1 }, &.{.{ .name = "age", .value = .{ .int = 20 } }});
try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 21 } }});
try table.createUniqueIndex("email");
try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 22 } }});
const rows = try table.scanAll();
defer freeRows(allocator, rows);
try std.testing.expectEqual(@as(usize, 3), rows.len);
}
test "createForeignKey against the primary key rejects a dangling reference and accepts a real one" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try std.testing.expect(orders.hasForeignKey("customer_id"));
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try std.testing.expectError(error.ForeignKeyViolation, orders.put(.{ .int = 101 }, &.{
.{ .name = "customer_id", .value = .{ .int = 999 } },
}));
}
test "createForeignKey against a UNIQUE non-pk field works, and refuses a target with no unique backing" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var products = try Table.open(&db, allocator, "products");
defer products.deinit();
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "SKU-1" } }});
var lineItems = try Table.open(&db, allocator, "line_items");
defer lineItems.deinit();
// "sku" has no unique constraint on products yet.
try std.testing.expectError(error.ForeignKeyRequiresUniqueTarget, lineItems.createForeignKey("product_sku", "products", "sku", .restrict));
try products.createUniqueIndex("sku");
try lineItems.createForeignKey("product_sku", "products", "sku", .restrict);
try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "SKU-1" } }});
try std.testing.expectError(error.ForeignKeyViolation, lineItems.put(.{ .int = 2 }, &.{
.{ .name = "product_sku", .value = .{ .text = "NOPE" } },
}));
}
test "createForeignKey refuses to install over a child row that already dangles" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.put(.{ .int = 1 }, &.{.{ .name = "customer_id", .value = .{ .int = 42 } }});
try std.testing.expectError(error.ForeignKeyViolation, orders.createForeignKey("customer_id", "customers", pkField, .restrict));
try std.testing.expect(!orders.hasForeignKey("customer_id"));
}
test "a row with no value for the foreign key field is exempt from the constraint" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
// No "customer_id" field at all - a nullable foreign key, not a violation.
try orders.put(.{ .int = 1 }, &.{.{ .name = "note", .value = .{ .text = "guest checkout" } }});
var row = (try orders.get(.{ .int = 1 })).?;
defer row.deinit();
try std.testing.expect(row.get("customer_id") == null);
}
test "dropForeignKey lifts the constraint" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try std.testing.expectError(error.ForeignKeyViolation, orders.put(.{ .int = 1 }, &.{
.{ .name = "customer_id", .value = .{ .int = 42 } },
}));
try orders.dropForeignKey("customer_id");
try std.testing.expect(!orders.hasForeignKey("customer_id"));
try orders.put(.{ .int = 1 }, &.{.{ .name = "customer_id", .value = .{ .int = 42 } }});
}
test "deleting a parent row is restricted while a child still references it via the primary key" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 }));
// Restricted, not silently ignored: the row is still there afterwards.
var stillThere = (try customers.get(.{ .int = 1 })).?;
stillThere.deinit();
// Once the referencing child row is gone, the parent can be deleted.
try orders.delete(.{ .int = 100 });
try customers.delete(.{ .int = 1 });
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
}
test "deleteWhere on a parent table stops at the first row a child still references" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
try customers.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "Boris" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 2 } }});
try std.testing.expectError(error.ForeignKeyInUse, customers.deleteWhere(null));
// customer 1 had no children and was already removed by the time
// deleteWhere hit customer 2's violation - deleteWhere has no
// per-batch atomicity (see its own doc comment), so a partial effect
// here is expected, not a bug this test is trying to hide.
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
var stillThere = (try customers.get(.{ .int = 2 })).?;
stillThere.deinit();
}
test "dropForeignKey lifts the delete restriction on the table it pointed at" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 }));
try orders.dropForeignKey("customer_id");
try customers.delete(.{ .int = 1 });
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
}
test "delete restriction works through a UNIQUE non-pk foreign key target too" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var products = try Table.open(&db, allocator, "products");
defer products.deinit();
try products.createUniqueIndex("sku");
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }});
var lineItems = try Table.open(&db, allocator, "line_items");
defer lineItems.deinit();
try lineItems.createForeignKey("product_sku", "products", "sku", .restrict);
try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } }});
try std.testing.expectError(error.ForeignKeyInUse, products.delete(.{ .int = 1 }));
try lineItems.delete(.{ .int = 1 });
try products.delete(.{ .int = 1 });
try std.testing.expect((try products.get(.{ .int = 1 })) == null);
}
test "a self-referencing foreign key restricts deleting a manager with reports, but not a childless leaf" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var employees = try Table.open(&db, allocator, "employees");
defer employees.deinit();
try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }});
try employees.createForeignKey("manager_id", "employees", pkField, .restrict);
try employees.put(.{ .int = 2 }, &.{
.{ .name = "name", .value = .{ .text = "VP" } },
.{ .name = "manager_id", .value = .{ .int = 1 } },
});
try std.testing.expectError(error.ForeignKeyInUse, employees.delete(.{ .int = 1 }));
// The leaf (no reports) deletes cleanly - and does not restrict its
// own deletion against itself despite scanning the same table.
try employees.delete(.{ .int = 2 });
try employees.delete(.{ .int = 1 });
try std.testing.expect((try employees.get(.{ .int = 1 })) == null);
}
test "foreign key constraint survives a table reopen" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
{
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
}
var reopened = try Table.open(&db, allocator, "orders");
defer reopened.deinit();
try std.testing.expect(reopened.hasForeignKey("customer_id"));
try std.testing.expectError(error.ForeignKeyViolation, reopened.put(.{ .int = 1 }, &.{
.{ .name = "customer_id", .value = .{ .int = 7 } },
}));
}
test "createForeignKey rejects bad identifiers for field, refTable, and refField" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try std.testing.expectError(error.InvalidIdentifier, orders.createForeignKey("bad\x00field", "customers", pkField, .restrict));
try std.testing.expectError(error.InvalidIdentifier, orders.createForeignKey("customer_id", "bad\x00table", pkField, .restrict));
try std.testing.expectError(error.InvalidIdentifier, orders.createForeignKey("customer_id", "customers", "bad\x00field", .restrict));
}
test "a self-referencing foreign key enforces a tree structure against the table's own primary key" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var employees = try Table.open(&db, allocator, "employees");
defer employees.deinit();
try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }});
try employees.createForeignKey("manager_id", "employees", pkField, .restrict);
// A row with no manager (the root of the tree) is exempt.
try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }});
// A report naming an existing manager is fine.
try employees.put(.{ .int = 2 }, &.{
.{ .name = "name", .value = .{ .text = "VP" } },
.{ .name = "manager_id", .value = .{ .int = 1 } },
});
try std.testing.expectError(error.ForeignKeyViolation, employees.put(.{ .int = 3 }, &.{
.{ .name = "name", .value = .{ .text = "Ghost report" } },
.{ .name = "manager_id", .value = .{ .int = 999 } },
}));
}
test "deleting a referenced parent is blocked before it can leave a child's foreign key dangling" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
// This used to be the one gap `updateWhere`'s own per-row FK re-check
// existed to catch after the fact: a bare `customers.delete` leaving
// orders#100's reference dangling even though it was valid when first
// written (see createForeignKey's OLD doc comment). `Table.delete`'s
// ON DELETE RESTRICT check (backed by `ReferencedBySet`) now closes it
// at the source instead - the dangling state this test used to
// construct is no longer reachable through delete at all.
try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 }));
// updateWhere re-writes the row through put with every field carried
// over unchanged, including "customer_id" - it has nothing to catch,
// because the reference was never allowed to go dangling in the first
// place.
try std.testing.expectEqual(@as(usize, 1), try orders.updateWhere(null, &.{
.{ .name = "note", .value = .{ .text = "touch" } },
}));
}
test "put is restricted from changing a UNIQUE foreign key target still referenced by a child" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var products = try Table.open(&db, allocator, "products");
defer products.deinit();
try products.createUniqueIndex("sku");
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }});
var lineItems = try Table.open(&db, allocator, "line_items");
defer lineItems.deinit();
try lineItems.createForeignKey("product_sku", "products", "sku", .restrict);
try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } }});
// Renaming the sku out from under the still-live line item must not
// silently strand it - the same RESTRICT a delete would get.
try std.testing.expectError(error.ForeignKeyInUse, products.put(.{ .int = 1 }, &.{
.{ .name = "sku", .value = .{ .text = "WIDGET-2" } },
}));
var stillThere = (try products.get(.{ .int = 1 })).?;
defer stillThere.deinit();
try std.testing.expectEqualStrings("WIDGET-1", stillThere.get("sku").?.text);
// Rewriting the same value back (or any other unrelated field) is not
// a change and is always exempt, mirroring the UNIQUE check's own
// "unchanged is safe" exemption.
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }});
// Once the reference is gone, the rename goes through.
try lineItems.delete(.{ .int = 1 });
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-2" } }});
var renamed = (try products.get(.{ .int = 1 })).?;
defer renamed.deinit();
try std.testing.expectEqualStrings("WIDGET-2", renamed.get("sku").?.text);
}
test "updateWhere is restricted from pulling a UNIQUE foreign key target out from under a child" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var products = try Table.open(&db, allocator, "products");
defer products.deinit();
try products.createUniqueIndex("sku");
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }});
var lineItems = try Table.open(&db, allocator, "line_items");
defer lineItems.deinit();
try lineItems.createForeignKey("product_sku", "products", "sku", .restrict);
try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } }});
try std.testing.expectError(error.ForeignKeyInUse, products.updateWhere(null, &.{
.{ .name = "sku", .value = .{ .text = "WIDGET-2" } },
}));
// A bulk update that leaves the referenced field untouched has
// nothing to restrict.
try std.testing.expectEqual(@as(usize, 1), try products.updateWhere(null, &.{
.{ .name = "note", .value = .{ .text = "touch" } },
}));
}
test "a self-referencing foreign key restricts renaming a UNIQUE field still targeted by a report, but not by itself" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var employees = try Table.open(&db, allocator, "employees");
defer employees.deinit();
try employees.createUniqueIndex("badge");
try employees.createForeignKey("manager_badge", "employees", "badge", .restrict);
try employees.put(.{ .int = 1 }, &.{.{ .name = "badge", .value = .{ .text = "B1" } }});
try employees.put(.{ .int = 2 }, &.{
.{ .name = "badge", .value = .{ .text = "B2" } },
.{ .name = "manager_badge", .value = .{ .text = "B1" } },
});
// Employee 2 still points at "B1" - renaming employee 1's own badge
// must be restricted.
try std.testing.expectError(error.ForeignKeyInUse, employees.put(.{ .int = 1 }, &.{
.{ .name = "badge", .value = .{ .text = "B1-NEW" } },
}));
// Employee 2 renaming its OWN badge (not the field being referenced by
// anyone) is unaffected - and does not restrict against itself despite
// the scan covering the same table.
try employees.put(.{ .int = 2 }, &.{
.{ .name = "badge", .value = .{ .text = "B2-NEW" } },
.{ .name = "manager_badge", .value = .{ .text = "B1" } },
});
// Once the report's manager_badge field is dropped (a row with no
// value for the FK field is exempt from the constraint, so this is a
// valid way to stop referencing anything), the manager's badge can be
// renamed.
try employees.put(.{ .int = 2 }, &.{
.{ .name = "badge", .value = .{ .text = "B2-NEW" } },
});
try employees.put(.{ .int = 1 }, &.{.{ .name = "badge", .value = .{ .text = "B1-NEW" } }});
var renamed = (try employees.get(.{ .int = 1 })).?;
defer renamed.deinit();
try std.testing.expectEqualStrings("B1-NEW", renamed.get("badge").?.text);
}
test "createCheckConstraint rejects a row that violates it and accepts one that satisfies it" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createCheckConstraint("qty_positive", "qty gt i:0");
try std.testing.expect(orders.hasCheckConstraint("qty_positive"));
try orders.put(.{ .int = 1 }, &.{.{ .name = "qty", .value = .{ .int = 5 } }});
try std.testing.expectError(error.CheckViolation, orders.put(.{ .int = 2 }, &.{
.{ .name = "qty", .value = .{ .int = 0 } },
}));
try std.testing.expectError(error.CheckViolation, orders.put(.{ .int = 3 }, &.{
.{ .name = "qty", .value = .{ .int = -1 } },
}));
}
test "createCheckConstraint refuses to install over existing rows that already fail it" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.put(.{ .int = 1 }, &.{.{ .name = "qty", .value = .{ .int = -5 } }});
try std.testing.expectError(error.CheckViolation, orders.createCheckConstraint("qty_positive", "qty gt i:0"));
try std.testing.expect(!orders.hasCheckConstraint("qty_positive"));
}
test "createCheckConstraint rejects a malformed expression before persisting anything" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try std.testing.expectError(error.InvalidCheckExpression, orders.createCheckConstraint("bad", "qty gt"));
try std.testing.expect(!orders.hasCheckConstraint("bad"));
}
test "dropCheckConstraint lifts the constraint, and a check constraint survives a table reopen" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
{
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createCheckConstraint("qty_positive", "qty gt i:0");
}
var reopened = try Table.open(&db, allocator, "orders");
defer reopened.deinit();
try std.testing.expect(reopened.hasCheckConstraint("qty_positive"));
try std.testing.expectError(error.CheckViolation, reopened.put(.{ .int = 1 }, &.{
.{ .name = "qty", .value = .{ .int = 0 } },
}));
try reopened.dropCheckConstraint("qty_positive");
try std.testing.expect(!reopened.hasCheckConstraint("qty_positive"));
try reopened.put(.{ .int = 1 }, &.{.{ .name = "qty", .value = .{ .int = 0 } }});
}
test "a row missing the checked field entirely is exempt (like is_null), but present-and-none is still checked" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createCheckConstraint("qty_known", "qty is_not_null");
try std.testing.expectError(error.CheckViolation, orders.put(.{ .int = 1 }, &.{}));
}
test "multiple CHECK constraints on the same table all must pass" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createCheckConstraint("qty_positive", "qty gt i:0");
try orders.createCheckConstraint("qty_capped", "qty lt i:1000");
try orders.put(.{ .int = 1 }, &.{.{ .name = "qty", .value = .{ .int = 500 } }});
try std.testing.expectError(error.CheckViolation, orders.put(.{ .int = 2 }, &.{
.{ .name = "qty", .value = .{ .int = 1000 } },
}));
}
test "ON DELETE CASCADE removes referencing rows instead of restricting the parent delete" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
try customers.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "Boris" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try orders.put(.{ .int = 101 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try orders.put(.{ .int = 200 }, &.{.{ .name = "customer_id", .value = .{ .int = 2 } }});
try customers.delete(.{ .int = 1 });
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
try std.testing.expect((try orders.get(.{ .int = 100 })) == null);
try std.testing.expect((try orders.get(.{ .int = 101 })) == null);
// Untouched: customer 2 and its own order were never in the cascade's
// path.
var otherCustomer = (try customers.get(.{ .int = 2 })).?;
otherCustomer.deinit();
var otherOrder = (try orders.get(.{ .int = 200 })).?;
otherOrder.deinit();
}
test "ON DELETE CASCADE keeps secondary indexes on the cascaded rows in sync" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createIndex("status");
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{
.{ .name = "customer_id", .value = .{ .int = 1 } },
.{ .name = "status", .value = .{ .text = "open" } },
});
const before = try idx.scanEq(&db, allocator, "orders", "status", .{ .text = "open" });
defer allocator.free(before);
try std.testing.expectEqual(@as(usize, 1), before.len);
try customers.delete(.{ .int = 1 });
// The cascaded delete went through `executeDelete`, which maintains
// secondary indexes exactly like a direct `orders.delete` would - a
// stale index entry pointing at a now-gone row would otherwise poison
// every future `status = "open"` scan.
const after = try idx.scanEq(&db, allocator, "orders", "status", .{ .text = "open" });
defer allocator.free(after);
try std.testing.expectEqual(@as(usize, 0), after.len);
}
test "ON DELETE CASCADE recurses transitively across more than one hop" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
var lineItems = try Table.open(&db, allocator, "line_items");
defer lineItems.deinit();
try lineItems.createForeignKey("order_id", "orders", pkField, .cascade);
try lineItems.put(.{ .int = 1000 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }});
try lineItems.put(.{ .int = 1001 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }});
// Deleting the customer must cascade into orders, and from there into
// line_items - two hops, not just one.
try customers.delete(.{ .int = 1 });
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
try std.testing.expect((try orders.get(.{ .int = 100 })) == null);
try std.testing.expect((try lineItems.get(.{ .int = 1000 })) == null);
try std.testing.expect((try lineItems.get(.{ .int = 1001 })) == null);
}
test "ON DELETE CASCADE is all-or-nothing: a RESTRICT hit anywhere in the fan-out aborts with nothing deleted" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
// A shipment row RESTRICTs deleting the order it points at - deleting
// the customer would need to cascade into this order, but the order
// itself cannot legally go while its own restrict-mode reference is
// still live.
var shipments = try Table.open(&db, allocator, "shipments");
defer shipments.deinit();
try shipments.createForeignKey("order_id", "orders", pkField, .restrict);
try shipments.put(.{ .int = 9000 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }});
try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 }));
// Two-phase plan-then-execute means the customer AND the order (which
// had already been planned for cascade removal before the restrict
// hit further down was discovered) are both still there - not just
// the customer.
var stillCustomer = (try customers.get(.{ .int = 1 })).?;
stillCustomer.deinit();
var stillOrder = (try orders.get(.{ .int = 100 })).?;
stillOrder.deinit();
var stillShipment = (try shipments.get(.{ .int = 9000 })).?;
stillShipment.deinit();
}
test "ON DELETE CASCADE terminates on a two-table delete cycle instead of recursing forever" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
// `a` and `b` each carry a nullable FK at the other, both ON DELETE
// CASCADE - a delete starting from either side must not infinite-loop
// bouncing back and forth between the two tables.
var a = try Table.open(&db, allocator, "a");
defer a.deinit();
var b = try Table.open(&db, allocator, "b");
defer b.deinit();
try a.put(.{ .int = 1 }, &.{});
try b.put(.{ .int = 1 }, &.{.{ .name = "a_id", .value = .{ .int = 1 } }});
try a.createForeignKey("b_id", "b", pkField, .cascade);
try a.put(.{ .int = 1 }, &.{.{ .name = "b_id", .value = .{ .int = 1 } }});
try b.createForeignKey("a_id", "a", pkField, .cascade);
try a.delete(.{ .int = 1 });
try std.testing.expect((try a.get(.{ .int = 1 })) == null);
try std.testing.expect((try b.get(.{ .int = 1 })) == null);
}
test "ON DELETE CASCADE on a self-referencing foreign key removes an entire subtree" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var employees = try Table.open(&db, allocator, "employees");
defer employees.deinit();
try employees.createForeignKey("manager_id", "employees", pkField, .cascade);
// 1 (CEO) -> 2 (VP) -> 3 and 4 (reports of the VP).
try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }});
try employees.put(.{ .int = 2 }, &.{
.{ .name = "name", .value = .{ .text = "VP" } },
.{ .name = "manager_id", .value = .{ .int = 1 } },
});
try employees.put(.{ .int = 3 }, &.{
.{ .name = "name", .value = .{ .text = "IC-A" } },
.{ .name = "manager_id", .value = .{ .int = 2 } },
});
try employees.put(.{ .int = 4 }, &.{
.{ .name = "name", .value = .{ .text = "IC-B" } },
.{ .name = "manager_id", .value = .{ .int = 2 } },
});
// Deleting the VP must cascade to both reports, but must not touch the
// CEO (an ancestor, not a dependent).
try employees.delete(.{ .int = 2 });
var ceo = (try employees.get(.{ .int = 1 })).?;
ceo.deinit();
try std.testing.expect((try employees.get(.{ .int = 2 })) == null);
try std.testing.expect((try employees.get(.{ .int = 3 })) == null);
try std.testing.expect((try employees.get(.{ .int = 4 })) == null);
}
test "deleteWhere cascades every match, not just the first" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{.{ .name = "region", .value = .{ .text = "eu" } }});
try customers.put(.{ .int = 2 }, &.{.{ .name = "region", .value = .{ .text = "eu" } }});
try customers.put(.{ .int = 3 }, &.{.{ .name = "region", .value = .{ .text = "us" } }});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try orders.put(.{ .int = 200 }, &.{.{ .name = "customer_id", .value = .{ .int = 2 } }});
try orders.put(.{ .int = 300 }, &.{.{ .name = "customer_id", .value = .{ .int = 3 } }});
const deleted = try customers.deleteWhere(.{ .eq = .{ .field = "region", .value = .{ .text = "eu" } } });
try std.testing.expectEqual(@as(usize, 2), deleted);
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
try std.testing.expect((try customers.get(.{ .int = 2 })) == null);
try std.testing.expect((try orders.get(.{ .int = 100 })) == null);
try std.testing.expect((try orders.get(.{ .int = 200 })) == null);
var stillCustomer = (try customers.get(.{ .int = 3 })).?;
stillCustomer.deinit();
var stillOrder = (try orders.get(.{ .int = 300 })).?;
stillOrder.deinit();
}
test "dropForeignKey lifts a CASCADE constraint the same as a RESTRICT one" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try openTemp(&tmp, allocator);
defer db.close();
var customers = try Table.open(&db, allocator, "customers");
defer customers.deinit();
try customers.put(.{ .int = 1 }, &.{});
var orders = try Table.open(&db, allocator, "orders");
defer orders.deinit();
try orders.createForeignKey("customer_id", "customers", pkField, .cascade);
try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
try orders.dropForeignKey("customer_id");
try customers.delete(.{ .int = 1 });
try std.testing.expect((try customers.get(.{ .int = 1 })) == null);
// No FK left at all, so the order is untouched by the parent's delete -
// not restricted, not cascaded, simply irrelevant now.
var stillOrder = (try orders.get(.{ .int = 100 })).?;
stillOrder.deinit();
}