feat: add hasForeignKey and foreignKeyTargetExists to query

This commit is contained in:
dualfroz committed 2026-09-08 19:58:10 +02:00
1 parent 5512aa824a
commit 7de513e158
1 file changed
+155
+155
View File
@@ -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);
}