From 7de513e158caec61cb490cc0df6e3c7202676dde Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 19:58:10 +0200 Subject: [PATCH] feat: add hasForeignKey and foreignKeyTargetExists to query --- src/query.zig | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/query.zig b/src/query.zig index 9106799..6d684b1 100644 --- a/src/query.zig +++ b/src/query.zig @@ -321,3 +321,158 @@ pub const Table = struct { } } + if (!self.indexes.has(field)) { + for (rows) |*r| { + const pk = r.get(pkField) orelse continue; + const value = r.get(field) orelse continue; + try idx.addEntry(self.db, self.allocator, self.name, field, value, pk); + } + try self.indexes.add(field); + try self.indexes.save(self.db, self.name); + } + + try self.unique.add(field); + try self.unique.save(self.db, self.name); + } + + pub fn hasForeignKey(self: *const Table, field: []const u8) bool { + return self.foreignKeys.has(field); + } + + /// Whether `value` names an existing row of `spec.refTable` under + /// `spec.refField` - `query.pkField` is checked via a plain `get` + /// (the primary key is inherently unique, no secondary index needed), + /// anything else via `idx.scanEq` against `spec.refTable`'s own index + /// entries (required to carry a UNIQUE constraint by + /// `Table.createForeignKey`, so at most one owner is expected here - + /// this only asks "does at least one exist", not "is it exactly one", + /// since a constraint installed correctly can only ever produce zero or + /// one). + fn foreignKeyTargetExists(self: *Table, refTableName: []const u8, refField: []const u8, value: Value) !bool { + if (std.mem.eql(u8, refField, pkField)) { + var refTable = try Table.open(self.db, self.allocator, refTableName); + defer refTable.deinit(); + var found = try refTable.get(value) orelse return false; + found.deinit(); + return true; + } + const owners = try idx.scanEq(self.db, self.allocator, refTableName, refField, value); + defer { + for (owners) |v| v.free(self.allocator); + self.allocator.free(owners); + } + return owners.len > 0; + } + + /// Installs a FOREIGN KEY constraint: every row's (non-null) `field` + /// value must, from this point on, name a value that exists as + /// `refField` on `refTable` - SQL's `FOREIGN KEY (field) REFERENCES + /// refTable(refField)`. `refField` must be either `pkField` or a field + /// already carrying `refTable`'s own UNIQUE constraint (see + /// `foreignKeyTargetExists`'s doc comment for why); anything else is + /// refused with `error.ForeignKeyRequiresUniqueTarget` before touching + /// any row, the same "refuse before mutating" shape `createUniqueIndex` + /// uses for dirty data. Existing rows are checked against the target + /// before the constraint is recorded - a row whose current `field` + /// value has no matching parent aborts the whole install with + /// `error.ForeignKeyViolation`, leaving nothing installed. + /// + /// Registers the reverse relationship in `refTable`'s own + /// `ReferencedBySet` (see its doc comment) so `Table.delete` can later + /// enforce `onDelete` against it (SQL's `ON DELETE RESTRICT` or `ON + /// DELETE CASCADE`, see `idx.OnDelete`'s doc comment) - this used to be + /// a documented, unfixed gap ("no registry of dependent tables exists, + /// since tables are opened ad hoc by name and never catalogued + /// anywhere") before `ReferencedBySet` closed it. + /// + /// `onDelete` only governs `Table.delete`/`Table.deleteWhere`'s plain + /// (non-transactional) path - `txn_table.zig`'s `TableTxn.delete` reads + /// the same `ReferencedBySet` but does not look at this field at all, + /// so it continues to unconditionally restrict any delete that would + /// orphan a reference even for a `cascade` constraint. That is a + /// deliberately conservative gap, not an oversight: refusing is always + /// a safe fallback for a feature that has not been built yet, whereas + /// silently NOT cascading inside a transaction would leave a dangling + /// reference behind. Only `Table.put`'s `updateRestrictedByForeignKey` + /// check is unaffected by `onDelete` in EITHER path - there is no `ON + /// UPDATE CASCADE` here, so a value change under a live reference is + /// always restricted regardless of the FK's delete mode. + /// + /// Known gap that registry does NOT close, same documented-rather- + /// than-fixed shape as `TableTxn`'s missing composite/unique + /// enforcement: nothing here stops a later `refTable.dropIndex + /// (refField)` from removing the UNIQUE constraint this foreign key + /// depends on - doing so leaves the constraint silently checking + /// against an unconstrained (possibly duplicate-holding) column via + /// `scanEq`, which still answers "does at least one owner exist" + /// correctly but no longer guarantees at most one. + pub fn createForeignKey(self: *Table, field: []const u8, refTable: []const u8, refField: []const u8, onDelete: idx.OnDelete) !void { + if (!idx.validIdentifier(field)) return error.InvalidIdentifier; + if (!idx.validIdentifier(refTable)) return error.InvalidIdentifier; + if (!idx.validIdentifier(refField)) return error.InvalidIdentifier; + if (self.foreignKeys.has(field)) return; + + if (!std.mem.eql(u8, refField, pkField)) { + var ref = try Table.open(self.db, self.allocator, refTable); + defer ref.deinit(); + if (!ref.hasUniqueIndex(refField)) return error.ForeignKeyRequiresUniqueTarget; + } + + const rows = try self.scanAll(); + defer freeRows(self.allocator, rows); + for (rows) |*r| { + const value = r.get(field) orelse continue; + if (value == .none) continue; + if (!try self.foreignKeyTargetExists(refTable, refField, value)) return error.ForeignKeyViolation; + } + + try self.foreignKeys.add(field, refTable, refField, onDelete); + try self.foreignKeys.save(self.db, self.name); + + // Records the reverse relationship on `refTable` so a later delete + // against it (see `Table.delete`'s RESTRICT check) can discover + // this constraint without a global table catalog - see + // `ReferencedBySet`'s doc comment. Loaded, mutated, and saved + // transiently rather than cached on a `Table` field: unlike + // `foreignKeys`/`unique`/`checks` (always mutated through the same + // handle that holds them), `referencedBy` is mutated as a side + // effect of some OTHER table's `createForeignKey`/`dropForeignKey` + // call - a `refTable` handle the CALLER already has open (the + // ordinary "open customers, open orders, wire up the FK between + // them" shape) would otherwise go stale the moment this runs, + // silently under-enforcing every delete against it thereafter. + // Works unchanged for a self-referencing FK (`refTable == + // self.name`) too, since it never touches `self`'s own cached + // sets at all - it only ever reads/writes `refTable`'s on-disk + // entry by name. + var revSet = try ReferencedBySet.load(self.allocator, self.db, refTable); + defer revSet.deinit(); + try revSet.add(self.name, field, refField, onDelete); + try revSet.save(self.db, refTable); + } + + /// Lifts a FOREIGN KEY and its reverse `ReferencedBySet` entry on + /// whichever table it pointed at, symmetric with `createForeignKey` + /// installing both. The target's name is copied out before + /// `self.foreignKeys.remove` frees the spec's own strings, since the + /// reverse cleanup below still needs it afterwards. See + /// `createForeignKey`'s doc comment on why the reverse entry is always + /// loaded/saved transiently rather than through a cached `Table` field. + pub fn dropForeignKey(self: *Table, field: []const u8) !void { + const existing = self.foreignKeys.find(field) orelse return; + const refTable = try self.allocator.dupe(u8, existing.refTable); + defer self.allocator.free(refTable); + + _ = self.foreignKeys.remove(field); + try self.foreignKeys.save(self.db, self.name); + + var revSet = try ReferencedBySet.load(self.allocator, self.db, refTable); + defer revSet.deinit(); + _ = revSet.remove(self.name, field); + try revSet.save(self.db, refTable); + } + + pub fn hasCheckConstraint(self: *const Table, name: []const u8) bool { + return self.checks.has(name); + } +