feat: add hasForeignKey and foreignKeyTargetExists to query
This commit is contained in:
1 parent
5512aa824a
commit
7de513e158
1 file changed
+155
+155
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user