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 (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); }