3869 lines
168 KiB
Zig
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();
|
|
}
|