Files
cobalt/src/index.zig
T
2026-09-06 14:19:54 +02:00

1938 lines
76 KiB
Zig

const std = @import("std");
const dbmod = @import("db.zig");
const row = @import("row.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
const Value = row.Value;
/// Keyspace layout shared by every table and index living inside a single
/// Db instance:
/// "R\x00" table "\x00" orderedPk -> encoded Row
/// "I\x00" table "\x00" field "\x00" orderedValue orderedPk -> encoded pk Value
/// "J\x00" table "\x00" indexName "\x00" orderedValue1 .. orderedValueN orderedPk -> encoded pk Value
/// "M\x00INDEXES\x00" table -> encoded list of indexed field names
/// "M\x00CINDEXES\x00" table -> encoded list of composite index specs
/// "M\x00UNIQUE\x00" table -> encoded list of UNIQUE-constrained field names
/// "M\x00FKEYS\x00" table -> encoded list of FOREIGN KEY specs (field, refTable, refField)
/// "M\x00CHECKS\x00" table -> encoded list of CHECK constraint specs (name, expr source)
/// "M\x00REFBY\x00" table -> encoded list of reverse FK specs (childTable, childField, refField),
/// one entry per OTHER table's FOREIGN KEY that names this table+refField
///
/// All namespaces share one flat LSM keyspace (there is only one Db), so the
/// leading tag byte plus NUL separators keep tables, single-field indexes,
/// composite indexes, and metadata from ever colliding or shadowing each
/// other during a scan. A composite index entry's value columns are just N
/// single-column `row.appendOrdered` encodings concatenated in the index's
/// declared field order ahead of the primary key's - each encoding is
/// already self-delimiting (fixed width for none/boolean/int/float, a
/// 0x00 0x00 terminator for text/bytes with 0x00 bytes escaped), so the
/// concatenation compares byte-for-byte in exactly the same order as
/// comparing the value tuples component by component. That is what makes a
/// prefix scan over the leading K of N components ("leftmost prefix" rule,
/// same as a real multi-column B-tree index) a correct equality lookup on
/// just those K columns, unconstrained on the rest.
pub const rowTag: u8 = 'R';
pub const indexTag: u8 = 'I';
pub const compositeIndexTag: u8 = 'J';
pub const metaTag: u8 = 'M';
const sep: u8 = 0x00;
fn appendSegment(out: *std.ArrayList(u8), segment: []const u8) !void {
try out.appendSlice(segment);
try out.append(sep);
}
pub fn rowPrefix(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(rowTag);
try appendSegment(&out, table);
return out.toOwnedSlice();
}
pub fn rowKey(allocator: Allocator, table: []const u8, pk: Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(rowTag);
try appendSegment(&out, table);
try row.appendOrdered(&out, pk);
return out.toOwnedSlice();
}
pub fn indexPrefix(allocator: Allocator, table: []const u8, field: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(indexTag);
try appendSegment(&out, table);
try appendSegment(&out, field);
return out.toOwnedSlice();
}
pub fn indexEntryKey(allocator: Allocator, table: []const u8, field: []const u8, value: Value, pk: Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(indexTag);
try appendSegment(&out, table);
try appendSegment(&out, field);
try row.appendOrdered(&out, value);
try row.appendOrdered(&out, pk);
return out.toOwnedSlice();
}
pub fn indexValueBound(allocator: Allocator, table: []const u8, field: []const u8, value: Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(indexTag);
try appendSegment(&out, table);
try appendSegment(&out, field);
try row.appendOrdered(&out, value);
return out.toOwnedSlice();
}
fn metaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "INDEXES");
try out.appendSlice(table);
return out.toOwnedSlice();
}
fn compositeMetaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "CINDEXES");
try out.appendSlice(table);
return out.toOwnedSlice();
}
fn uniqueMetaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "UNIQUE");
try out.appendSlice(table);
return out.toOwnedSlice();
}
fn foreignKeyMetaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "FKEYS");
try out.appendSlice(table);
return out.toOwnedSlice();
}
fn checkMetaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "CHECKS");
try out.appendSlice(table);
return out.toOwnedSlice();
}
fn referencedByMetaKey(allocator: Allocator, table: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(metaTag);
try appendSegment(&out, "REFBY");
try out.appendSlice(table);
return out.toOwnedSlice();
}
pub fn compositeIndexPrefix(allocator: Allocator, table: []const u8, indexName: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(compositeIndexTag);
try appendSegment(&out, table);
try appendSegment(&out, indexName);
return out.toOwnedSlice();
}
pub fn compositeIndexEntryKey(allocator: Allocator, table: []const u8, indexName: []const u8, values: []const Value, pk: Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(compositeIndexTag);
try appendSegment(&out, table);
try appendSegment(&out, indexName);
for (values) |v| try row.appendOrdered(&out, v);
try row.appendOrdered(&out, pk);
return out.toOwnedSlice();
}
/// Bound for a leftmost-prefix scan of a composite index: `values.len` may
/// be less than the index's full column count, in which case the scan
/// matches every entry whose leading columns equal `values` regardless of
/// what the remaining (unconstrained) columns hold - see the module-level
/// keyspace comment for why the concatenation makes this a correct prefix.
pub fn compositeIndexValueBound(allocator: Allocator, table: []const u8, indexName: []const u8, values: []const Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(compositeIndexTag);
try appendSegment(&out, table);
try appendSegment(&out, indexName);
for (values) |v| try row.appendOrdered(&out, v);
return out.toOwnedSlice();
}
/// Bound for a composite index scan that pins `eqValues` as the leading
/// columns (leftmost prefix, same as `compositeIndexValueBound`) and then
/// appends one more ordered value for a range condition on the very next
/// column - the byte-extension argument from the module-level keyspace
/// comment applies here exactly as it does for a bare `indexValueBound`, so
/// `scanCompositeRangeCursor` can reuse the same inclusive/exclusive
/// gap trick `scanRangeCursor` uses for single-field indexes.
fn compositeIndexRangeBound(allocator: Allocator, table: []const u8, indexName: []const u8, eqValues: []const Value, extra: Value) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try out.append(compositeIndexTag);
try appendSegment(&out, table);
try appendSegment(&out, indexName);
for (eqValues) |v| try row.appendOrdered(&out, v);
try row.appendOrdered(&out, extra);
return out.toOwnedSlice();
}
/// Smallest byte string strictly greater than every string with `prefix` as
/// a prefix, used as the exclusive upper bound of a prefix scan. Returns
/// null when the prefix is all 0xFF bytes (unbounded above, e.g. empty
/// table/field names never hit this in practice).
pub fn prefixUpperBound(allocator: Allocator, prefix: []const u8) !?[]u8 {
var end = prefix.len;
while (end > 0 and prefix[end - 1] == 0xff) end -= 1;
if (end == 0) return null;
const upper = try allocator.alloc(u8, end);
@memcpy(upper, prefix[0..end]);
upper[end - 1] += 1;
return upper;
}
/// Whether a table name or field name is safe to embed as a NUL-delimited
/// keyspace segment: table/field identifiers may not themselves contain
/// NUL bytes or they would corrupt the segment boundaries above.
pub fn validIdentifier(name: []const u8) bool {
if (name.len == 0) return false;
for (name) |c| {
if (c == sep) return false;
}
return true;
}
/// Persisted set of field names that have a secondary index on a table.
/// Loaded once per Table.open and written back to the Db's metadata
/// namespace whenever an index is created or dropped, so indexes survive
/// process restarts without any external schema file.
pub const IndexSet = struct {
allocator: Allocator,
fields: std.ArrayList([]u8),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !IndexSet {
const key = try metaKey(allocator, table);
defer allocator.free(key);
var set = IndexSet{ .allocator = allocator, .fields = std.ArrayList([]u8).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const count = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < count) : (i += 1) {
if (pos + 4 > raw.len) return error.Malformed;
const len = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
if (pos + len > raw.len) return error.Malformed;
const name = try allocator.dupe(u8, raw[pos .. pos + len]);
pos += len;
try set.fields.append(name);
}
return set;
}
pub fn save(self: *const IndexSet, db: *Db, table: []const u8) !void {
const key = try metaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.fields.items.len), .little);
try out.appendSlice(&countBuf);
for (self.fields.items) |name| {
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, @intCast(name.len), .little);
try out.appendSlice(&lenBuf);
try out.appendSlice(name);
}
try db.put(key, out.items);
}
pub fn has(self: *const IndexSet, field: []const u8) bool {
for (self.fields.items) |f| {
if (std.mem.eql(u8, f, field)) return true;
}
return false;
}
pub fn add(self: *IndexSet, field: []const u8) !void {
if (self.has(field)) return;
const copy = try self.allocator.dupe(u8, field);
try self.fields.append(copy);
}
/// Returns true if the field was present and has been removed.
pub fn remove(self: *IndexSet, field: []const u8) bool {
for (self.fields.items, 0..) |f, i| {
if (std.mem.eql(u8, f, field)) {
self.allocator.free(f);
_ = self.fields.orderedRemove(i);
return true;
}
}
return false;
}
pub fn deinit(self: *IndexSet) void {
for (self.fields.items) |f| self.allocator.free(f);
self.fields.deinit();
}
};
/// Persisted set of field names carrying a UNIQUE constraint on a table -
/// same on-disk shape as `IndexSet` (own meta key, own namespace), kept as
/// a separate type because the two sets have different invariants: every
/// member of `UniqueSet` is also required to be a member of the table's
/// `IndexSet` (enforced by `Table.createUniqueIndex`/`Table.dropIndex`),
/// since `Table.put` detects a colliding value by reusing the field's
/// regular index entries via `scanEq` rather than a full table scan - a
/// unique field with no backing index would make every write pay for a
/// full scan just to check the constraint.
pub const UniqueSet = struct {
allocator: Allocator,
fields: std.ArrayList([]u8),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !UniqueSet {
const key = try uniqueMetaKey(allocator, table);
defer allocator.free(key);
var set = UniqueSet{ .allocator = allocator, .fields = std.ArrayList([]u8).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const count = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < count) : (i += 1) {
if (pos + 4 > raw.len) return error.Malformed;
const len = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
if (pos + len > raw.len) return error.Malformed;
const name = try allocator.dupe(u8, raw[pos .. pos + len]);
pos += len;
try set.fields.append(name);
}
return set;
}
pub fn save(self: *const UniqueSet, db: *Db, table: []const u8) !void {
const key = try uniqueMetaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.fields.items.len), .little);
try out.appendSlice(&countBuf);
for (self.fields.items) |name| {
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, @intCast(name.len), .little);
try out.appendSlice(&lenBuf);
try out.appendSlice(name);
}
try db.put(key, out.items);
}
pub fn has(self: *const UniqueSet, field: []const u8) bool {
for (self.fields.items) |f| {
if (std.mem.eql(u8, f, field)) return true;
}
return false;
}
pub fn add(self: *UniqueSet, field: []const u8) !void {
if (self.has(field)) return;
const copy = try self.allocator.dupe(u8, field);
try self.fields.append(copy);
}
/// Returns true if the field was present and has been removed.
pub fn remove(self: *UniqueSet, field: []const u8) bool {
for (self.fields.items, 0..) |f, i| {
if (std.mem.eql(u8, f, field)) {
self.allocator.free(f);
_ = self.fields.orderedRemove(i);
return true;
}
}
return false;
}
pub fn deinit(self: *UniqueSet) void {
for (self.fields.items) |f| self.allocator.free(f);
self.fields.deinit();
}
};
/// One FOREIGN KEY constraint: `field` on the owning table must, on every
/// row where it is present and non-none, name a value that exists as
/// `refField` on `refTable` (SQL's `FOREIGN KEY (field) REFERENCES
/// refTable(refField)`). `refField` is either `query.pkField` (the
/// referenced table's primary key, checked via a plain `get`) or a field
/// carrying its own UNIQUE constraint on `refTable` (checked via `scanEq`) -
/// `Table.createForeignKey` requires one of the two before installing,
/// exactly like a real database refusing a foreign key against a column
/// with no unique/primary-key backing, since otherwise "the value exists"
/// would not pin down a single unambiguous parent row.
/// SQL's `ON DELETE` clause: what a FOREIGN KEY does when the row it points
/// at is deleted. `restrict` (the SQL default, and this codebase's only
/// behavior before cascading deletes existed) refuses the delete outright
/// while a referencing row still exists; `cascade` instead deletes every
/// referencing row too, recursively (a cascaded child can itself have
/// dependents), before the parent row itself is removed. Stored as a plain
/// byte in `ForeignKeySpec`/`ReferencedBySpec` rather than a bare bool so a
/// third mode (e.g. `set_null`) can be added later without another format
/// bump.
pub const OnDelete = enum(u8) {
restrict = 0,
cascade = 1,
};
fn onDeleteFromByte(b: u8) !OnDelete {
return switch (b) {
0 => .restrict,
1 => .cascade,
else => error.Malformed,
};
}
pub const ForeignKeySpec = struct {
field: []u8,
refTable: []u8,
refField: []u8,
onDelete: OnDelete,
};
/// Persisted set of FOREIGN KEY constraints on a table, at most one per
/// owning field - same on-disk shape family as `IndexSet`/`UniqueSet` (own
/// meta key, own namespace) but each entry carries two extra strings
/// (`refTable`/`refField`) plus an `onDelete` mode byte instead of being a
/// bare field name.
pub const ForeignKeySet = struct {
allocator: Allocator,
specs: std.ArrayList(ForeignKeySpec),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !ForeignKeySet {
const key = try foreignKeyMetaKey(allocator, table);
defer allocator.free(key);
var set = ForeignKeySet{ .allocator = allocator, .specs = std.ArrayList(ForeignKeySpec).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const count = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < count) : (i += 1) {
const field = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(field);
const refTable = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(refTable);
const refField = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(refField);
if (pos + 1 > raw.len) return error.Malformed;
const onDelete = try onDeleteFromByte(raw[pos]);
pos += 1;
try set.specs.append(.{ .field = field, .refTable = refTable, .refField = refField, .onDelete = onDelete });
}
return set;
}
pub fn save(self: *const ForeignKeySet, db: *Db, table: []const u8) !void {
const key = try foreignKeyMetaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.specs.items.len), .little);
try out.appendSlice(&countBuf);
for (self.specs.items) |spec| {
try appendLenPrefixed(&out, spec.field);
try appendLenPrefixed(&out, spec.refTable);
try appendLenPrefixed(&out, spec.refField);
try out.append(@intFromEnum(spec.onDelete));
}
try db.put(key, out.items);
}
pub fn has(self: *const ForeignKeySet, field: []const u8) bool {
return self.find(field) != null;
}
pub fn find(self: *const ForeignKeySet, field: []const u8) ?ForeignKeySpec {
for (self.specs.items) |spec| {
if (std.mem.eql(u8, spec.field, field)) return spec;
}
return null;
}
pub fn add(self: *ForeignKeySet, field: []const u8, refTable: []const u8, refField: []const u8, onDelete: OnDelete) !void {
if (self.has(field)) return;
const spec = ForeignKeySpec{
.field = try self.allocator.dupe(u8, field),
.refTable = try self.allocator.dupe(u8, refTable),
.refField = try self.allocator.dupe(u8, refField),
.onDelete = onDelete,
};
try self.specs.append(spec);
}
/// Returns true if a constraint on `field` was present and has been
/// removed.
pub fn remove(self: *ForeignKeySet, field: []const u8) bool {
for (self.specs.items, 0..) |spec, i| {
if (std.mem.eql(u8, spec.field, field)) {
self.allocator.free(spec.field);
self.allocator.free(spec.refTable);
self.allocator.free(spec.refField);
_ = self.specs.orderedRemove(i);
return true;
}
}
return false;
}
pub fn deinit(self: *ForeignKeySet) void {
for (self.specs.items) |spec| {
self.allocator.free(spec.field);
self.allocator.free(spec.refTable);
self.allocator.free(spec.refField);
}
self.specs.deinit();
}
};
/// One named CHECK constraint: a caller-chosen `name` (used only as the key
/// under which the constraint can be dropped again, not part of the
/// keyspace layout - unlike `CompositeIndexSpec.name`, no index entries are
/// ever written for a CHECK) paired with the raw `expr.zig` predicate source
/// text it must evaluate true against. Stored as source rather than a parsed
/// tree because `index.zig` sits below `query.zig`/`expr.zig` in the
/// dependency graph and knows nothing about `Predicate` - parsing happens
/// fresh in whichever layer enforces it (`query.zig`'s `Table.put`,
/// `txn_table.zig`'s `TableTxn.put`), the same "reparse on every use" cost
/// `table select`/`table join` already pay for their own expr text.
pub const CheckConstraintSpec = struct {
name: []u8,
source: []u8,
};
/// Persisted set of CHECK constraints on a table, at most one per name -
/// same on-disk shape family as `ForeignKeySet` (own meta key, own
/// namespace, two length-prefixed strings per entry).
pub const CheckConstraintSet = struct {
allocator: Allocator,
specs: std.ArrayList(CheckConstraintSpec),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !CheckConstraintSet {
const key = try checkMetaKey(allocator, table);
defer allocator.free(key);
var set = CheckConstraintSet{ .allocator = allocator, .specs = std.ArrayList(CheckConstraintSpec).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const count = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < count) : (i += 1) {
const name = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(name);
const source = try readLenPrefixed(allocator, raw, &pos);
try set.specs.append(.{ .name = name, .source = source });
}
return set;
}
pub fn save(self: *const CheckConstraintSet, db: *Db, table: []const u8) !void {
const key = try checkMetaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.specs.items.len), .little);
try out.appendSlice(&countBuf);
for (self.specs.items) |spec| {
try appendLenPrefixed(&out, spec.name);
try appendLenPrefixed(&out, spec.source);
}
try db.put(key, out.items);
}
pub fn has(self: *const CheckConstraintSet, name: []const u8) bool {
return self.find(name) != null;
}
pub fn find(self: *const CheckConstraintSet, name: []const u8) ?CheckConstraintSpec {
for (self.specs.items) |spec| {
if (std.mem.eql(u8, spec.name, name)) return spec;
}
return null;
}
pub fn add(self: *CheckConstraintSet, name: []const u8, source: []const u8) !void {
if (self.has(name)) return;
const spec = CheckConstraintSpec{
.name = try self.allocator.dupe(u8, name),
.source = try self.allocator.dupe(u8, source),
};
try self.specs.append(spec);
}
/// Returns true if a constraint named `name` was present and has been
/// removed.
pub fn remove(self: *CheckConstraintSet, name: []const u8) bool {
for (self.specs.items, 0..) |spec, i| {
if (std.mem.eql(u8, spec.name, name)) {
self.allocator.free(spec.name);
self.allocator.free(spec.source);
_ = self.specs.orderedRemove(i);
return true;
}
}
return false;
}
pub fn deinit(self: *CheckConstraintSet) void {
for (self.specs.items) |spec| {
self.allocator.free(spec.name);
self.allocator.free(spec.source);
}
self.specs.deinit();
}
};
/// One reverse FOREIGN KEY entry, recorded on the REFERENCED table rather
/// than the referencing one: some other table `childTable` has a
/// `FOREIGN KEY (childField) REFERENCES <this table>(refField)`. This is
/// the registry `Table.createForeignKey`'s doc comment calls out as
/// missing ("tables are opened ad hoc by name and never catalogued
/// anywhere") - without it, deleting a parent row has no way to discover
/// which other tables might still be pointing at it, since `ForeignKeySet`
/// is only ever stored on the referencing (child) side.
pub const ReferencedBySpec = struct {
childTable: []u8,
childField: []u8,
refField: []u8,
onDelete: OnDelete,
};
/// Persisted set of reverse FK entries on a table - same on-disk shape
/// family as `ForeignKeySet` (own meta key, own namespace, three
/// length-prefixed strings plus an `onDelete` mode byte per entry), but
/// populated by a DIFFERENT table's `createForeignKey`/`dropForeignKey`
/// call rather than by mutations against this table directly. `onDelete`
/// is duplicated here rather than looked up from the child's own
/// `ForeignKeySet` on every delete, so `Table.delete`'s cascade planner
/// (see `query.zig`'s `planCascade`) can decide restrict-vs-cascade
/// without opening and loading the child a second time just to answer
/// that. `add` is keyed by the (childTable, childField) pair - a child
/// field can only ever carry one FOREIGN KEY (enforced by
/// `ForeignKeySet.has` at the child), so at most one reverse entry per
/// pair can ever be correct; re-adding the same pair (only reachable by
/// first dropping and recreating the child's constraint, possibly with a
/// different `refField`/`onDelete`) replaces it in place instead of
/// accumulating a stale duplicate.
pub const ReferencedBySet = struct {
allocator: Allocator,
specs: std.ArrayList(ReferencedBySpec),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !ReferencedBySet {
const key = try referencedByMetaKey(allocator, table);
defer allocator.free(key);
var set = ReferencedBySet{ .allocator = allocator, .specs = std.ArrayList(ReferencedBySpec).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const count = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < count) : (i += 1) {
const childTable = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(childTable);
const childField = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(childField);
const refField = try readLenPrefixed(allocator, raw, &pos);
errdefer allocator.free(refField);
if (pos + 1 > raw.len) return error.Malformed;
const onDelete = try onDeleteFromByte(raw[pos]);
pos += 1;
try set.specs.append(.{ .childTable = childTable, .childField = childField, .refField = refField, .onDelete = onDelete });
}
return set;
}
pub fn save(self: *const ReferencedBySet, db: *Db, table: []const u8) !void {
const key = try referencedByMetaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.specs.items.len), .little);
try out.appendSlice(&countBuf);
for (self.specs.items) |spec| {
try appendLenPrefixed(&out, spec.childTable);
try appendLenPrefixed(&out, spec.childField);
try appendLenPrefixed(&out, spec.refField);
try out.append(@intFromEnum(spec.onDelete));
}
try db.put(key, out.items);
}
fn indexOf(self: *const ReferencedBySet, childTable: []const u8, childField: []const u8) ?usize {
for (self.specs.items, 0..) |spec, i| {
if (std.mem.eql(u8, spec.childTable, childTable) and std.mem.eql(u8, spec.childField, childField)) return i;
}
return null;
}
pub fn add(self: *ReferencedBySet, childTable: []const u8, childField: []const u8, refField: []const u8, onDelete: OnDelete) !void {
const refFieldCopy = try self.allocator.dupe(u8, refField);
errdefer self.allocator.free(refFieldCopy);
if (self.indexOf(childTable, childField)) |i| {
self.allocator.free(self.specs.items[i].refField);
self.specs.items[i].refField = refFieldCopy;
self.specs.items[i].onDelete = onDelete;
return;
}
const spec = ReferencedBySpec{
.childTable = try self.allocator.dupe(u8, childTable),
.childField = try self.allocator.dupe(u8, childField),
.refField = refFieldCopy,
.onDelete = onDelete,
};
try self.specs.append(spec);
}
/// Returns true if a reverse entry for (childTable, childField) was
/// present and has been removed.
pub fn remove(self: *ReferencedBySet, childTable: []const u8, childField: []const u8) bool {
const i = self.indexOf(childTable, childField) orelse return false;
const spec = self.specs.items[i];
self.allocator.free(spec.childTable);
self.allocator.free(spec.childField);
self.allocator.free(spec.refField);
_ = self.specs.orderedRemove(i);
return true;
}
pub fn deinit(self: *ReferencedBySet) void {
for (self.specs.items) |spec| {
self.allocator.free(spec.childTable);
self.allocator.free(spec.childField);
self.allocator.free(spec.refField);
}
self.specs.deinit();
}
};
fn readLenPrefixed(allocator: Allocator, raw: []const u8, pos: *usize) ![]u8 {
if (pos.* + 4 > raw.len) return error.Malformed;
const len = std.mem.readInt(u32, raw[pos.*..][0..4], .little);
pos.* += 4;
if (pos.* + len > raw.len) return error.Malformed;
const value = try allocator.dupe(u8, raw[pos.* .. pos.* + len]);
pos.* += len;
return value;
}
fn appendLenPrefixed(out: *std.ArrayList(u8), value: []const u8) !void {
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, @intCast(value.len), .little);
try out.appendSlice(&lenBuf);
try out.appendSlice(value);
}
/// One multi-column ("composite") index: a caller-chosen name (used as the
/// keyspace discriminator, so it must be a `validIdentifier` like a table or
/// field name) plus the ordered list of fields it indexes. Field order is
/// significant - it defines both the on-disk key layout and which leading
/// ("leftmost") prefix of columns a query can use for a narrowed lookup.
pub const CompositeIndexSpec = struct {
name: []u8,
fields: [][]u8,
fn free(self: *const CompositeIndexSpec, allocator: Allocator) void {
allocator.free(self.name);
for (self.fields) |f| allocator.free(f);
allocator.free(self.fields);
}
};
/// Persisted set of composite indexes on a table, the multi-column
/// counterpart to `IndexSet`. Stored under a separate metadata key
/// ("CINDEXES" vs "INDEXES") so single-field and composite indexes never
/// share or shadow each other's schema record.
pub const CompositeIndexSet = struct {
allocator: Allocator,
specs: std.ArrayList(CompositeIndexSpec),
pub fn load(allocator: Allocator, db: *Db, table: []const u8) !CompositeIndexSet {
const key = try compositeMetaKey(allocator, table);
defer allocator.free(key);
var set = CompositeIndexSet{ .allocator = allocator, .specs = std.ArrayList(CompositeIndexSpec).init(allocator) };
errdefer set.deinit();
const raw = try db.get(allocator, key) orelse return set;
defer allocator.free(raw);
var pos: usize = 0;
if (pos + 4 > raw.len) return error.Malformed;
const specCount = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
var i: u32 = 0;
while (i < specCount) : (i += 1) {
if (pos + 4 > raw.len) return error.Malformed;
const nameLen = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
if (pos + nameLen > raw.len) return error.Malformed;
const name = try allocator.dupe(u8, raw[pos .. pos + nameLen]);
errdefer allocator.free(name);
pos += nameLen;
if (pos + 4 > raw.len) return error.Malformed;
const fieldCount = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
const fields = try allocator.alloc([]u8, fieldCount);
var filled: usize = 0;
errdefer {
for (fields[0..filled]) |f| allocator.free(f);
allocator.free(fields);
}
var j: u32 = 0;
while (j < fieldCount) : (j += 1) {
if (pos + 4 > raw.len) return error.Malformed;
const len = std.mem.readInt(u32, raw[pos..][0..4], .little);
pos += 4;
if (pos + len > raw.len) return error.Malformed;
fields[j] = try allocator.dupe(u8, raw[pos .. pos + len]);
filled += 1;
pos += len;
}
try set.specs.append(.{ .name = name, .fields = fields });
}
return set;
}
pub fn save(self: *const CompositeIndexSet, db: *Db, table: []const u8) !void {
const key = try compositeMetaKey(self.allocator, table);
defer self.allocator.free(key);
var out = std.ArrayList(u8).init(self.allocator);
defer out.deinit();
var countBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &countBuf, @intCast(self.specs.items.len), .little);
try out.appendSlice(&countBuf);
for (self.specs.items) |spec| {
var lenBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &lenBuf, @intCast(spec.name.len), .little);
try out.appendSlice(&lenBuf);
try out.appendSlice(spec.name);
std.mem.writeInt(u32, &lenBuf, @intCast(spec.fields.len), .little);
try out.appendSlice(&lenBuf);
for (spec.fields) |f| {
std.mem.writeInt(u32, &lenBuf, @intCast(f.len), .little);
try out.appendSlice(&lenBuf);
try out.appendSlice(f);
}
}
try db.put(key, out.items);
}
pub fn find(self: *const CompositeIndexSet, name: []const u8) ?*const CompositeIndexSpec {
for (self.specs.items) |*s| {
if (std.mem.eql(u8, s.name, name)) return s;
}
return null;
}
pub fn has(self: *const CompositeIndexSet, name: []const u8) bool {
return self.find(name) != null;
}
pub fn add(self: *CompositeIndexSet, name: []const u8, fields: []const []const u8) !void {
if (self.has(name)) return;
const nameCopy = try self.allocator.dupe(u8, name);
errdefer self.allocator.free(nameCopy);
const fieldsCopy = try self.allocator.alloc([]u8, fields.len);
var filled: usize = 0;
errdefer {
for (fieldsCopy[0..filled]) |f| self.allocator.free(f);
self.allocator.free(fieldsCopy);
}
for (fields, 0..) |f, i| {
fieldsCopy[i] = try self.allocator.dupe(u8, f);
filled += 1;
}
try self.specs.append(.{ .name = nameCopy, .fields = fieldsCopy });
}
/// Returns true if a composite index by that name was present and has
/// been removed.
pub fn remove(self: *CompositeIndexSet, name: []const u8) bool {
for (self.specs.items, 0..) |*s, i| {
if (std.mem.eql(u8, s.name, name)) {
s.free(self.allocator);
_ = self.specs.orderedRemove(i);
return true;
}
}
return false;
}
pub fn deinit(self: *CompositeIndexSet) void {
for (self.specs.items) |*s| s.free(self.allocator);
self.specs.deinit();
}
};
/// Adds one index entry mapping `value` -> `pk` under `table.field`. The
/// entry's stored value is the plain (non order-preserving) encoding of the
/// primary key, so lookups can recover the exact original Value instead of
/// re-parsing the escaped ordered key bytes.
pub fn addEntry(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value, pk: Value) !void {
const key = try indexEntryKey(allocator, table, field, value, pk);
defer allocator.free(key);
const pkBytes = try row.encodeValue(allocator, pk);
defer allocator.free(pkBytes);
try db.put(key, pkBytes);
}
pub fn removeEntry(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value, pk: Value) !void {
const key = try indexEntryKey(allocator, table, field, value, pk);
defer allocator.free(key);
try db.delete(key);
}
/// Deletes every index entry for `table.field`, used when dropping an index.
pub fn dropAllEntries(db: *Db, allocator: Allocator, table: []const u8, field: []const u8) !void {
const prefix = try indexPrefix(allocator, table, field);
defer allocator.free(prefix);
var keys = std.ArrayList([]u8).init(allocator);
defer {
for (keys.items) |k| allocator.free(k);
keys.deinit();
}
var it = try db.scanPrefix(allocator, prefix);
defer it.deinit();
while (try it.next()) |entry| {
try keys.append(try allocator.dupe(u8, entry.key));
}
for (keys.items) |k| try db.delete(k);
}
/// Adds one composite index entry mapping the full `values` tuple -> `pk`
/// under `table`'s index `indexName`. Same stored-value convention as
/// `addEntry`: the plain (non order-preserving) pk encoding.
pub fn addCompositeEntry(db: *Db, allocator: Allocator, table: []const u8, indexName: []const u8, values: []const Value, pk: Value) !void {
const key = try compositeIndexEntryKey(allocator, table, indexName, values, pk);
defer allocator.free(key);
const pkBytes = try row.encodeValue(allocator, pk);
defer allocator.free(pkBytes);
try db.put(key, pkBytes);
}
pub fn removeCompositeEntry(db: *Db, allocator: Allocator, table: []const u8, indexName: []const u8, values: []const Value, pk: Value) !void {
const key = try compositeIndexEntryKey(allocator, table, indexName, values, pk);
defer allocator.free(key);
try db.delete(key);
}
/// Deletes every entry for a composite index, used when dropping it.
pub fn dropAllCompositeEntries(db: *Db, allocator: Allocator, table: []const u8, indexName: []const u8) !void {
const prefix = try compositeIndexPrefix(allocator, table, indexName);
defer allocator.free(prefix);
var keys = std.ArrayList([]u8).init(allocator);
defer {
for (keys.items) |k| allocator.free(k);
keys.deinit();
}
var it = try db.scanPrefix(allocator, prefix);
defer it.deinit();
while (try it.next()) |entry| {
try keys.append(try allocator.dupe(u8, entry.key));
}
for (keys.items) |k| try db.delete(k);
}
/// Lazy cursor over a leftmost-prefix equality lookup on a composite index:
/// `valuesPrefix` may cover all of the index's columns or just a leading
/// subset of them. Streaming for the same reason as `EqCursor`.
pub const CompositeEqCursor = struct {
it: dbmod.RangeIterator,
pub fn next(self: *CompositeEqCursor, allocator: Allocator) !?Value {
const entry = try self.it.next() orelse return null;
return try row.decodeValue(allocator, entry.value);
}
pub fn deinit(self: *CompositeEqCursor) void {
self.it.deinit();
}
};
pub fn scanCompositeEqCursor(db: *Db, allocator: Allocator, table: []const u8, indexName: []const u8, valuesPrefix: []const Value) !CompositeEqCursor {
const prefix = try compositeIndexValueBound(allocator, table, indexName, valuesPrefix);
defer allocator.free(prefix);
return .{ .it = try db.scanPrefix(allocator, prefix) };
}
/// Eager counterpart of `scanCompositeEqCursor`, all matching primary keys.
pub fn scanCompositeEq(db: *Db, allocator: Allocator, table: []const u8, indexName: []const u8, valuesPrefix: []const Value) ![]Value {
var cursor = try scanCompositeEqCursor(db, allocator, table, indexName, valuesPrefix);
defer cursor.deinit();
var results = std.ArrayList(Value).init(allocator);
errdefer {
for (results.items) |v| v.free(allocator);
results.deinit();
}
while (try cursor.next(allocator)) |pk| try results.append(pk);
return results.toOwnedSlice();
}
/// Lazy cursor over a range lookup on the column immediately following a
/// leftmost-prefix of equality columns on a composite index: `eqValues`
/// pins the leading columns exactly like `CompositeEqCursor`, `low`/`high`
/// bound the next column. Columns beyond that one are left unconstrained by
/// the scan, same leftmost-prefix rule as the equality-only cursor.
pub const CompositeRangeCursor = struct {
it: dbmod.RangeIterator,
pub fn next(self: *CompositeRangeCursor, allocator: Allocator) !?Value {
const entry = try self.it.next() orelse return null;
return try row.decodeValue(allocator, entry.value);
}
pub fn deinit(self: *CompositeRangeCursor) void {
self.it.deinit();
}
};
pub fn scanCompositeRangeCursor(
db: *Db,
allocator: Allocator,
table: []const u8,
indexName: []const u8,
eqValues: []const Value,
low: ?Bound,
high: ?Bound,
) !CompositeRangeCursor {
const prefix = try compositeIndexValueBound(allocator, table, indexName, eqValues);
defer allocator.free(prefix);
var startBuf: ?[]u8 = null;
defer if (startBuf) |b| allocator.free(b);
var endBuf: ?[]u8 = null;
defer if (endBuf) |b| allocator.free(b);
// Same inclusive/exclusive gap trick as `scanRangeCursor`: a real entry
// key is always `compositeIndexRangeBound(eqValues, value) ++ orderedPk`,
// a strict byte-extension of the bare per-value bound (pk encodings are
// never empty), so a key with value == threshold always sorts after
// `compositeIndexRangeBound(eqValues, threshold)` itself.
const start = if (low) |b| blk: {
const bound = try compositeIndexRangeBound(allocator, table, indexName, eqValues, b.value);
if (b.inclusive) {
startBuf = bound;
} else {
defer allocator.free(bound);
startBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound);
}
break :blk startBuf.?;
} else prefix;
const end = if (high) |b| blk: {
const bound = try compositeIndexRangeBound(allocator, table, indexName, eqValues, b.value);
if (b.inclusive) {
defer allocator.free(bound);
endBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound);
} else {
endBuf = bound;
}
break :blk endBuf.?;
} else blk: {
endBuf = try prefixUpperBound(allocator, prefix) orelse try allocator.dupe(u8, prefix);
break :blk endBuf.?;
};
return .{ .it = try db.scanRange(allocator, .{
.start = start,
.end = end,
.startInclusive = true,
.endInclusive = false,
}) };
}
/// Lazy cursor over an equality index lookup: decodes one primary key at a
/// time from the underlying `Db.RangeIterator` instead of materializing the
/// whole match set up front. This is what lets `Table.selectPage` honour a
/// small `limit` without reading and decoding every index entry for a value
/// that may have thousands of matches.
pub const EqCursor = struct {
it: dbmod.RangeIterator,
pub fn next(self: *EqCursor, allocator: Allocator) !?Value {
const entry = try self.it.next() orelse return null;
return try row.decodeValue(allocator, entry.value);
}
pub fn deinit(self: *EqCursor) void {
self.it.deinit();
}
};
/// Point lookup, streaming: opens the underlying scan but decodes nothing
/// until `EqCursor.next` is called.
pub fn scanEqCursor(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value) !EqCursor {
const prefix = try indexValueBound(allocator, table, field, value);
defer allocator.free(prefix);
return .{ .it = try db.scanPrefix(allocator, prefix) };
}
/// Point lookup: all primary keys currently indexed under `value`.
pub fn scanEq(db: *Db, allocator: Allocator, table: []const u8, field: []const u8, value: Value) ![]Value {
var cursor = try scanEqCursor(db, allocator, table, field, value);
defer cursor.deinit();
var results = std.ArrayList(Value).init(allocator);
errdefer {
for (results.items) |v| v.free(allocator);
results.deinit();
}
while (try cursor.next(allocator)) |pk| try results.append(pk);
return results.toOwnedSlice();
}
pub const Bound = struct {
value: Value,
inclusive: bool,
};
/// Lazy cursor over a range index lookup, same rationale as `EqCursor`.
pub const RangeCursor = struct {
it: dbmod.RangeIterator,
pub fn next(self: *RangeCursor, allocator: Allocator) !?Value {
const entry = try self.it.next() orelse return null;
return try row.decodeValue(allocator, entry.value);
}
pub fn deinit(self: *RangeCursor) void {
self.it.deinit();
}
};
/// Range lookup over `table.field`'s index, streaming: primary keys whose
/// indexed value falls within [low, high] (either side may be open/null),
/// decoded one at a time as the caller consumes the cursor.
pub fn scanRangeCursor(
db: *Db,
allocator: Allocator,
table: []const u8,
field: []const u8,
low: ?Bound,
high: ?Bound,
) !RangeCursor {
const prefix = try indexPrefix(allocator, table, field);
defer allocator.free(prefix);
var startBuf: ?[]u8 = null;
defer if (startBuf) |b| allocator.free(b);
var endBuf: ?[]u8 = null;
defer if (endBuf) |b| allocator.free(b);
// Every real index-entry key is `indexValueBound(value) ++ orderedPk`, a
// strict byte-extension of the bare value bound (pk encodings are never
// empty). So a key with value == threshold always compares greater than
// `indexValueBound(threshold)` itself - inclusive/exclusive thresholds
// are therefore expressed by choosing which side of that gap to start
// or stop the scan at, not by the start/endInclusive flags.
const start = if (low) |b| blk: {
const bound = try indexValueBound(allocator, table, field, b.value);
if (b.inclusive) {
startBuf = bound;
} else {
defer allocator.free(bound);
startBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound);
}
break :blk startBuf.?;
} else prefix;
const end = if (high) |b| blk: {
const bound = try indexValueBound(allocator, table, field, b.value);
if (b.inclusive) {
defer allocator.free(bound);
endBuf = try prefixUpperBound(allocator, bound) orelse try allocator.dupe(u8, bound);
} else {
endBuf = bound;
}
break :blk endBuf.?;
} else blk: {
endBuf = try prefixUpperBound(allocator, prefix) orelse try allocator.dupe(u8, prefix);
break :blk endBuf.?;
};
return .{ .it = try db.scanRange(allocator, .{
.start = start,
.end = end,
.startInclusive = true,
.endInclusive = false,
}) };
}
/// Range lookup over `table.field`'s index: all primary keys whose indexed
/// value falls within [low, high] (either side may be open/null).
pub fn scanRange(
db: *Db,
allocator: Allocator,
table: []const u8,
field: []const u8,
low: ?Bound,
high: ?Bound,
) ![]Value {
var cursor = try scanRangeCursor(db, allocator, table, field, low, high);
defer cursor.deinit();
var results = std.ArrayList(Value).init(allocator);
errdefer {
for (results.items) |v| v.free(allocator);
results.deinit();
}
while (try cursor.next(allocator)) |pk| try results.append(pk);
return results.toOwnedSlice();
}
test "rowKey and indexEntryKey stay within their own namespace" {
const allocator = std.testing.allocator;
const rk = try rowKey(allocator, "users", .{ .int = 1 });
defer allocator.free(rk);
const ik = try indexEntryKey(allocator, "users", "email", .{ .text = "a@b.c" }, .{ .int = 1 });
defer allocator.free(ik);
try std.testing.expectEqual(@as(u8, 'R'), rk[0]);
try std.testing.expectEqual(@as(u8, 'I'), ik[0]);
}
test "prefixUpperBound bounds a prefix scan" {
const allocator = std.testing.allocator;
const upper = (try prefixUpperBound(allocator, "abc")).?;
defer allocator.free(upper);
try std.testing.expect(std.mem.order(u8, "abc", upper) == .lt);
try std.testing.expect(std.mem.order(u8, "abd", upper) != .lt);
}
test "IndexSet add remove save load round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try IndexSet.load(allocator, &db, "users");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.fields.items.len);
try set.add("email");
try set.add("age");
try set.add("email");
try std.testing.expectEqual(@as(usize, 2), set.fields.items.len);
try set.save(&db, "users");
var reloaded = try IndexSet.load(allocator, &db, "users");
defer reloaded.deinit();
try std.testing.expect(reloaded.has("email"));
try std.testing.expect(reloaded.has("age"));
try std.testing.expect(!reloaded.has("missing"));
try std.testing.expect(reloaded.remove("email"));
try std.testing.expect(!reloaded.remove("email"));
try reloaded.save(&db, "users");
var third = try IndexSet.load(allocator, &db, "users");
defer third.deinit();
try std.testing.expect(!third.has("email"));
try std.testing.expect(third.has("age"));
}
test "UniqueSet add remove save load round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try UniqueSet.load(allocator, &db, "users");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.fields.items.len);
try set.add("email");
try set.add("ssn");
try set.add("email");
try std.testing.expectEqual(@as(usize, 2), set.fields.items.len);
try set.save(&db, "users");
var reloaded = try UniqueSet.load(allocator, &db, "users");
defer reloaded.deinit();
try std.testing.expect(reloaded.has("email"));
try std.testing.expect(reloaded.has("ssn"));
try std.testing.expect(!reloaded.has("missing"));
try std.testing.expect(reloaded.remove("email"));
try std.testing.expect(!reloaded.remove("email"));
try reloaded.save(&db, "users");
var third = try UniqueSet.load(allocator, &db, "users");
defer third.deinit();
try std.testing.expect(!third.has("email"));
try std.testing.expect(third.has("ssn"));
}
test "UniqueSet and IndexSet on the same table never collide, each keeps its own membership" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var indexes = try IndexSet.load(allocator, &db, "users");
defer indexes.deinit();
try indexes.add("email");
try indexes.add("city");
try indexes.save(&db, "users");
var unique = try UniqueSet.load(allocator, &db, "users");
defer unique.deinit();
try unique.add("email");
try unique.save(&db, "users");
// "city" is indexed but not unique, "email" is both - the two sets
// must agree on "email" and disagree on "city" after an independent
// reload of each, proving neither save() overwrote the other's key.
var reloadedIndexes = try IndexSet.load(allocator, &db, "users");
defer reloadedIndexes.deinit();
var reloadedUnique = try UniqueSet.load(allocator, &db, "users");
defer reloadedUnique.deinit();
try std.testing.expect(reloadedIndexes.has("email"));
try std.testing.expect(reloadedIndexes.has("city"));
try std.testing.expect(reloadedUnique.has("email"));
try std.testing.expect(!reloadedUnique.has("city"));
}
test "ForeignKeySet add remove save load round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try ForeignKeySet.load(allocator, &db, "orders");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.specs.items.len);
try set.add("customer_id", "customers", "__pk", .cascade);
try set.add("customer_id", "customers", "some_other_field", .restrict);
try std.testing.expectEqual(@as(usize, 1), set.specs.items.len);
try set.save(&db, "orders");
var reloaded = try ForeignKeySet.load(allocator, &db, "orders");
defer reloaded.deinit();
try std.testing.expect(reloaded.has("customer_id"));
try std.testing.expect(!reloaded.has("missing"));
const spec = reloaded.find("customer_id").?;
try std.testing.expectEqualStrings("customers", spec.refTable);
try std.testing.expectEqualStrings("__pk", spec.refField);
// First `add` wins (mirrors `has` guard above) - `onDelete` survives the
// save/load round trip as the mode from that first call, not the
// no-op second one.
try std.testing.expectEqual(OnDelete.cascade, spec.onDelete);
try std.testing.expect(reloaded.remove("customer_id"));
try std.testing.expect(!reloaded.remove("customer_id"));
try reloaded.save(&db, "orders");
var third = try ForeignKeySet.load(allocator, &db, "orders");
defer third.deinit();
try std.testing.expect(!third.has("customer_id"));
}
test "ForeignKeySet.load rejects a corrupted onDelete byte instead of accepting garbage" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try ForeignKeySet.load(allocator, &db, "orders");
defer set.deinit();
try set.add("customer_id", "customers", "__pk", .restrict);
try set.save(&db, "orders");
const key = try foreignKeyMetaKey(allocator, "orders");
defer allocator.free(key);
const raw = try db.get(allocator, key) orelse return error.TestUnexpectedResult;
defer allocator.free(raw);
// Flip the trailing onDelete byte to a value neither `restrict` (0)
// nor `cascade` (1) can ever legitimately be - same "malformed input
// is rejected, not silently coerced" contract every other decoder in
// this file already gives length-prefixed strings.
const corrupted = try allocator.dupe(u8, raw);
defer allocator.free(corrupted);
corrupted[corrupted.len - 1] = 0xFF;
try db.put(key, corrupted);
try std.testing.expectError(error.Malformed, ForeignKeySet.load(allocator, &db, "orders"));
}
test "ReferencedBySet add replaces an existing (childTable, childField) pair instead of duplicating it" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try ReferencedBySet.load(allocator, &db, "customers");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.specs.items.len);
try set.add("orders", "customer_id", "__pk", .restrict);
try set.add("orders", "customer_id", "__pk", .restrict);
try std.testing.expectEqual(@as(usize, 1), set.specs.items.len);
try set.add("line_items", "customer_id", "__pk", .cascade);
try std.testing.expectEqual(@as(usize, 2), set.specs.items.len);
try set.save(&db, "customers");
var reloaded = try ReferencedBySet.load(allocator, &db, "customers");
defer reloaded.deinit();
try std.testing.expectEqual(@as(usize, 2), reloaded.specs.items.len);
for (reloaded.specs.items) |spec| {
if (std.mem.eql(u8, spec.childTable, "line_items")) {
try std.testing.expectEqual(OnDelete.cascade, spec.onDelete);
} else {
try std.testing.expectEqual(OnDelete.restrict, spec.onDelete);
}
}
// Re-adding the same pair under a different refField AND onDelete mode
// replaces both in place rather than accumulating a second, now-stale
// entry.
try reloaded.add("orders", "customer_id", "email", .cascade);
try std.testing.expectEqual(@as(usize, 2), reloaded.specs.items.len);
var sawOrders = false;
for (reloaded.specs.items) |spec| {
if (std.mem.eql(u8, spec.childTable, "orders")) {
sawOrders = true;
try std.testing.expectEqualStrings("email", spec.refField);
try std.testing.expectEqual(OnDelete.cascade, spec.onDelete);
}
}
try std.testing.expect(sawOrders);
try std.testing.expect(reloaded.remove("orders", "customer_id"));
try std.testing.expect(!reloaded.remove("orders", "customer_id"));
try reloaded.save(&db, "customers");
var third = try ReferencedBySet.load(allocator, &db, "customers");
defer third.deinit();
try std.testing.expectEqual(@as(usize, 1), third.specs.items.len);
try std.testing.expectEqualStrings("line_items", third.specs.items[0].childTable);
}
test "CheckConstraintSet add remove save load round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try CheckConstraintSet.load(allocator, &db, "orders");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.specs.items.len);
try set.add("qty_positive", "qty gt 0");
try set.add("qty_positive", "qty gt 100");
try std.testing.expectEqual(@as(usize, 1), set.specs.items.len);
try set.save(&db, "orders");
var reloaded = try CheckConstraintSet.load(allocator, &db, "orders");
defer reloaded.deinit();
try std.testing.expect(reloaded.has("qty_positive"));
try std.testing.expect(!reloaded.has("missing"));
const spec = reloaded.find("qty_positive").?;
try std.testing.expectEqualStrings("qty gt 0", spec.source);
try std.testing.expect(reloaded.remove("qty_positive"));
try std.testing.expect(!reloaded.remove("qty_positive"));
try reloaded.save(&db, "orders");
var third = try CheckConstraintSet.load(allocator, &db, "orders");
defer third.deinit();
try std.testing.expect(!third.has("qty_positive"));
}
test "ForeignKeySet, UniqueSet, and IndexSet on the same table never collide" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var indexes = try IndexSet.load(allocator, &db, "orders");
defer indexes.deinit();
try indexes.add("customer_id");
try indexes.save(&db, "orders");
var unique = try UniqueSet.load(allocator, &db, "orders");
defer unique.deinit();
try unique.add("order_number");
try unique.save(&db, "orders");
var fkeys = try ForeignKeySet.load(allocator, &db, "orders");
defer fkeys.deinit();
try fkeys.add("customer_id", "customers", "__pk", .restrict);
try fkeys.save(&db, "orders");
var checks = try CheckConstraintSet.load(allocator, &db, "orders");
defer checks.deinit();
try checks.add("qty_positive", "qty gt 0");
try checks.save(&db, "orders");
var reloadedIndexes = try IndexSet.load(allocator, &db, "orders");
defer reloadedIndexes.deinit();
var reloadedUnique = try UniqueSet.load(allocator, &db, "orders");
defer reloadedUnique.deinit();
var reloadedFkeys = try ForeignKeySet.load(allocator, &db, "orders");
defer reloadedFkeys.deinit();
var reloadedChecks = try CheckConstraintSet.load(allocator, &db, "orders");
defer reloadedChecks.deinit();
try std.testing.expect(reloadedIndexes.has("customer_id"));
try std.testing.expect(reloadedUnique.has("order_number"));
try std.testing.expect(!reloadedUnique.has("customer_id"));
try std.testing.expect(reloadedFkeys.has("customer_id"));
try std.testing.expect(!reloadedFkeys.has("order_number"));
try std.testing.expect(reloadedChecks.has("qty_positive"));
try std.testing.expect(!reloadedChecks.has("customer_id"));
}
test "addEntry scanEq removeEntry round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
try addEntry(&db, allocator, "users", "city", .{ .text = "warsaw" }, .{ .int = 1 });
try addEntry(&db, allocator, "users", "city", .{ .text = "warsaw" }, .{ .int = 2 });
try addEntry(&db, allocator, "users", "city", .{ .text = "krakow" }, .{ .int = 3 });
const warsawPks = try scanEq(&db, allocator, "users", "city", .{ .text = "warsaw" });
defer {
for (warsawPks) |v| v.free(allocator);
allocator.free(warsawPks);
}
try std.testing.expectEqual(@as(usize, 2), warsawPks.len);
try removeEntry(&db, allocator, "users", "city", .{ .text = "warsaw" }, .{ .int = 1 });
const afterRemove = try scanEq(&db, allocator, "users", "city", .{ .text = "warsaw" });
defer {
for (afterRemove) |v| v.free(allocator);
allocator.free(afterRemove);
}
try std.testing.expectEqual(@as(usize, 1), afterRemove.len);
try std.testing.expectEqual(@as(i64, 2), afterRemove[0].int);
}
test "EqCursor streams the same pks as eager scanEq and can stop early" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var i: i64 = 0;
while (i < 20) : (i += 1) {
try addEntry(&db, allocator, "users", "city", .{ .text = "warsaw" }, .{ .int = i });
}
const eager = try scanEq(&db, allocator, "users", "city", .{ .text = "warsaw" });
defer {
for (eager) |v| v.free(allocator);
allocator.free(eager);
}
try std.testing.expectEqual(@as(usize, 20), eager.len);
// Pull only the first three through the cursor, then drop it: this must
// neither read nor decode the remaining seventeen entries, and the
// testing allocator would flag a leak if the partially-consumed
// RangeIterator's resources were not released on an early deinit.
var cursor = try scanEqCursor(&db, allocator, "users", "city", .{ .text = "warsaw" });
defer cursor.deinit();
var pulled = std.ArrayList(Value).init(allocator);
defer {
for (pulled.items) |v| v.free(allocator);
pulled.deinit();
}
var n: usize = 0;
while (n < 3) : (n += 1) {
const pk = (try cursor.next(allocator)).?;
try pulled.append(pk);
}
try std.testing.expectEqual(@as(usize, 3), pulled.items.len);
for (pulled.items, 0..) |v, idx2| {
try std.testing.expectEqual(eager[idx2].int, v.int);
}
}
test "RangeCursor streams the same pks as eager scanRange and can stop early" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var i: i64 = 0;
while (i < 30) : (i += 1) {
try addEntry(&db, allocator, "events", "score", .{ .int = i }, .{ .int = i });
}
var cursor = try scanRangeCursor(&db, allocator, "events", "score", .{ .value = .{ .int = 5 }, .inclusive = true }, null);
defer cursor.deinit();
const first = (try cursor.next(allocator)).?;
defer first.free(allocator);
const second = (try cursor.next(allocator)).?;
defer second.free(allocator);
try std.testing.expectEqual(@as(i64, 5), first.int);
try std.testing.expectEqual(@as(i64, 6), second.int);
// cursor and its underlying iterator are dropped by `defer` here without
// consuming the other 23 matches.
}
test "scanRange respects bounds and inclusivity" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var i: i64 = 0;
while (i < 10) : (i += 1) {
try addEntry(&db, allocator, "events", "score", .{ .int = i }, .{ .int = i });
}
const all = try scanRange(&db, allocator, "events", "score", null, null);
defer {
for (all) |v| v.free(allocator);
allocator.free(all);
}
try std.testing.expectEqual(@as(usize, 10), all.len);
const midInclusive = try scanRange(&db, allocator, "events", "score", .{ .value = .{ .int = 3 }, .inclusive = true }, .{ .value = .{ .int = 6 }, .inclusive = true });
defer {
for (midInclusive) |v| v.free(allocator);
allocator.free(midInclusive);
}
try std.testing.expectEqual(@as(usize, 4), midInclusive.len);
try std.testing.expectEqual(@as(i64, 3), midInclusive[0].int);
try std.testing.expectEqual(@as(i64, 6), midInclusive[midInclusive.len - 1].int);
const midExclusive = try scanRange(&db, allocator, "events", "score", .{ .value = .{ .int = 3 }, .inclusive = false }, .{ .value = .{ .int = 6 }, .inclusive = false });
defer {
for (midExclusive) |v| v.free(allocator);
allocator.free(midExclusive);
}
try std.testing.expectEqual(@as(usize, 2), midExclusive.len);
try std.testing.expectEqual(@as(i64, 4), midExclusive[0].int);
try std.testing.expectEqual(@as(i64, 5), midExclusive[1].int);
const openAbove = try scanRange(&db, allocator, "events", "score", .{ .value = .{ .int = 8 }, .inclusive = true }, null);
defer {
for (openAbove) |v| v.free(allocator);
allocator.free(openAbove);
}
try std.testing.expectEqual(@as(usize, 2), openAbove.len);
}
test "CompositeIndexSet add remove save load round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var set = try CompositeIndexSet.load(allocator, &db, "users");
defer set.deinit();
try std.testing.expectEqual(@as(usize, 0), set.specs.items.len);
try set.add("city_age", &.{ "city", "age" });
try set.add("city_age", &.{ "city", "age" }); // no-op, already present
try std.testing.expectEqual(@as(usize, 1), set.specs.items.len);
try set.save(&db, "users");
var reloaded = try CompositeIndexSet.load(allocator, &db, "users");
defer reloaded.deinit();
try std.testing.expect(reloaded.has("city_age"));
const spec = reloaded.find("city_age").?;
try std.testing.expectEqual(@as(usize, 2), spec.fields.len);
try std.testing.expectEqualStrings("city", spec.fields[0]);
try std.testing.expectEqualStrings("age", spec.fields[1]);
try std.testing.expect(reloaded.remove("city_age"));
try std.testing.expect(!reloaded.remove("city_age"));
try reloaded.save(&db, "users");
var third = try CompositeIndexSet.load(allocator, &db, "users");
defer third.deinit();
try std.testing.expect(!third.has("city_age"));
}
test "single-field and composite index metadata never shadow each other" {
// Both live under the "M" tag with table-name-suffixed keys that differ
// only in "INDEXES" vs "CINDEXES" - this pins down that they stay two
// genuinely distinct Db keys, not one overwriting the other.
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
var single = try IndexSet.load(allocator, &db, "users");
defer single.deinit();
try single.add("email");
try single.save(&db, "users");
var composite = try CompositeIndexSet.load(allocator, &db, "users");
defer composite.deinit();
try composite.add("city_age", &.{ "city", "age" });
try composite.save(&db, "users");
var singleReloaded = try IndexSet.load(allocator, &db, "users");
defer singleReloaded.deinit();
try std.testing.expect(singleReloaded.has("email"));
var compositeReloaded = try CompositeIndexSet.load(allocator, &db, "users");
defer compositeReloaded.deinit();
try std.testing.expect(compositeReloaded.has("city_age"));
}
test "addCompositeEntry scanCompositeEq removeCompositeEntry round trip" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 });
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 40 } }, .{ .int = 2 });
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "krakow" }, .{ .int = 30 } }, .{ .int = 3 });
// Full-tuple match: exactly one row.
const exact = try scanCompositeEq(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } });
defer {
for (exact) |v| v.free(allocator);
allocator.free(exact);
}
try std.testing.expectEqual(@as(usize, 1), exact.len);
try std.testing.expectEqual(@as(i64, 1), exact[0].int);
// Leftmost-prefix match on just the first column: both warsaw rows,
// never the krakow one, even though it wasn't constrained on age at all.
const prefixMatch = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }});
defer {
for (prefixMatch) |v| v.free(allocator);
allocator.free(prefixMatch);
}
try std.testing.expectEqual(@as(usize, 2), prefixMatch.len);
try removeCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 });
const afterRemove = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }});
defer {
for (afterRemove) |v| v.free(allocator);
allocator.free(afterRemove);
}
try std.testing.expectEqual(@as(usize, 1), afterRemove.len);
try std.testing.expectEqual(@as(i64, 2), afterRemove[0].int);
}
test "scanCompositeRangeCursor narrows on the column after the matched prefix" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
// Two cities, ages 20..29 in each - the composite index is (city, age).
var age: i64 = 20;
while (age < 30) : (age += 1) {
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = age } }, .{ .int = age });
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "krakow" }, .{ .int = age } }, .{ .int = age + 100 });
}
// city == "warsaw" and 23 <= age <= 26: never crosses into krakow's
// bucket even though its ages overlap byte-for-byte with warsaw's.
var midCursor = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 23 }, .inclusive = true }, .{ .value = .{ .int = 26 }, .inclusive = true });
var midInclusive = std.ArrayList(Value).init(allocator);
defer midInclusive.deinit();
while (try midCursor.next(allocator)) |pk| try midInclusive.append(pk);
midCursor.deinit();
try std.testing.expectEqual(@as(usize, 4), midInclusive.items.len);
try std.testing.expectEqual(@as(i64, 23), midInclusive.items[0].int);
try std.testing.expectEqual(@as(i64, 26), midInclusive.items[midInclusive.items.len - 1].int);
// Exclusive both sides: drops the two endpoints.
var exclusive = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 23 }, .inclusive = false }, .{ .value = .{ .int = 26 }, .inclusive = false });
defer exclusive.deinit();
const first = (try exclusive.next(allocator)).?;
defer first.free(allocator);
const second = (try exclusive.next(allocator)).?;
defer second.free(allocator);
try std.testing.expectEqual(@as(i64, 24), first.int);
try std.testing.expectEqual(@as(i64, 25), second.int);
try std.testing.expectEqual(@as(?Value, null), try exclusive.next(allocator));
// Open above: age >= 28 within warsaw only, never touching krakow.
var openAbove = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 28 }, .inclusive = true }, null);
defer openAbove.deinit();
const a1 = (try openAbove.next(allocator)).?;
defer a1.free(allocator);
const a2 = (try openAbove.next(allocator)).?;
defer a2.free(allocator);
try std.testing.expectEqual(@as(i64, 28), a1.int);
try std.testing.expectEqual(@as(i64, 29), a2.int);
try std.testing.expectEqual(@as(?Value, null), try openAbove.next(allocator));
}
test "dropAllCompositeEntries clears an index without touching another one" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
defer allocator.free(dbPath);
var db = try Db.open(allocator, dbPath, .{});
defer db.close();
try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 });
try addCompositeEntry(&db, allocator, "users", "other_idx", &.{ .{ .int = 1 }, .{ .int = 2 } }, .{ .int = 1 });
try dropAllCompositeEntries(&db, allocator, "users", "city_age");
const cityAge = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }});
defer {
for (cityAge) |v| v.free(allocator);
allocator.free(cityAge);
}
try std.testing.expectEqual(@as(usize, 0), cityAge.len);
const other = try scanCompositeEq(&db, allocator, "users", "other_idx", &.{.{ .int = 1 }});
defer {
for (other) |v| v.free(allocator);
allocator.free(other);
}
try std.testing.expectEqual(@as(usize, 1), other.len);
}