feat: add deletionRestrictedByForeignKey

This commit is contained in:
dualfroz committed 2026-09-12 21:59:52 +02:00
1 parent 5acea0fb1c
commit f16fc2ab58
1 file changed
+161
+161
View File
@@ -149,3 +149,164 @@ pub const TableTxn = struct {
if (!parsed.predicate.evaluate(&newRow)) return error.CheckViolation; if (!parsed.predicate.evaluate(&newRow)) return error.CheckViolation;
} }
// UNIQUE and FOREIGN KEY checks run before any staged mutation
// below, same all-or-nothing placement as `query.zig`'s `Table.put`:
// a violation leaves the transaction's write set exactly as it was
// before this call, not staged with a partial row. Both checks go
// through `Transaction.scanPrefix`, so a UNIQUE value or FK target
// staged by an EARLIER `put` call in this same still-open
// transaction (a different row, possibly a different `TableTxn`
// bound to the same `Transaction`) is caught here already, not left
// to surface as `error.Conflict` at `commit()`.
for (self.unique.fields.items) |field| {
const newValue = newRow.get(field) orelse continue;
const oldValue = if (old) |*o| o.get(field) else null;
if (oldValue != null and Value.eql(oldValue.?, newValue)) continue;
const prefix = try idx.indexValueBound(self.allocator, self.name, field, newValue);
defer self.allocator.free(prefix);
const entries = try self.txn.scanPrefix(self.allocator, prefix);
defer txnmod.Transaction.freeEntries(self.allocator, entries);
for (entries) |e| {
const ownerPk = try rowmod.decodeValue(self.allocator, e.value);
defer ownerPk.free(self.allocator);
if (!Value.eql(ownerPk, pk)) return error.UniqueViolation;
}
}
for (self.foreignKeys.specs.items) |spec| {
const newValue = newRow.get(spec.field) orelse continue;
if (newValue == .none) continue;
if (!try self.foreignKeyTargetExists(spec.refTable, spec.refField, newValue)) return error.ForeignKeyViolation;
}
// ON DELETE RESTRICT's update-side counterpart, see
// `updateRestrictedByForeignKey`'s doc comment - same all-or-nothing
// placement as every other constraint check above.
if (try self.updateRestrictedByForeignKey(pk, if (old) |*o| o else null, &newRow)) return error.ForeignKeyInUse;
for (self.indexes.fields.items) |indexedField| {
const newValue = newRow.get(indexedField);
const oldValue = if (old) |*o| o.get(indexedField) else null;
if (oldValue != null and (newValue == null or !Value.eql(oldValue.?, newValue.?))) {
try self.stageIndexRemove(indexedField, oldValue.?, pk);
}
if (newValue) |nv| {
if (oldValue == null or !Value.eql(oldValue.?, nv)) {
try self.stageIndexAdd(indexedField, nv, pk);
}
}
}
try self.txn.put(key, encoded);
}
/// Mirrors `query.zig`'s `Table.deletionRestrictedByForeignKey` (SQL's
/// `ON DELETE RESTRICT`), reading through `self.txn.scanPrefix` instead
/// of a plain `Db` scan - the one thing this version can do that the
/// plain `Table` path cannot: a child row staged earlier in this SAME
/// uncommitted transaction (via a sibling `TableTxn` on the child
/// table, sharing this `Transaction`) already counts as a live
/// reference, not just whatever is durably committed. `referencedBy`
/// itself is still loaded fresh from `self.txn.db` rather than cached
/// (schema metadata - like `indexes`/`unique`/`foreignKeys` above - is
/// only ever mutated through `query.zig`'s `Table`, never through a
/// `TableTxn`, but unlike those, `ReferencedBySet` is written by some
/// OTHER table's `createForeignKey` call, so a `TableTxn` opened before
/// that ran cannot rely on whatever it loaded at `open()` time either -
/// see `Table.createForeignKey`'s doc comment for the same reasoning).
fn deletionRestrictedByForeignKey(self: *TableTxn, pk: Value, old: *const Row) !bool {
var referencedBy = try idx.ReferencedBySet.load(self.allocator, self.txn.db, self.name);
defer referencedBy.deinit();
for (referencedBy.specs.items) |spec| {
const value = old.get(spec.refField) orelse continue;
if (value == .none) continue;
const prefix = try idx.rowPrefix(self.allocator, spec.childTable);
defer self.allocator.free(prefix);
const entries = try self.txn.scanPrefix(self.allocator, prefix);
defer Transaction.freeEntries(self.allocator, entries);
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
for (entries) |entry| {
var r = try Row.decode(self.allocator, entry.value);
defer r.deinit();
if (selfReferencing) {
if (r.get(pkField)) |rowPk| {
if (Value.eql(rowPk, pk)) continue;
}
}
const childValue = r.get(spec.childField) orelse continue;
if (childValue == .none) continue;
if (Value.eql(childValue, value)) return true;
}
}
return false;
}
/// `TableTxn.put`'s counterpart to `deletionRestrictedByForeignKey`,
/// mirroring `query.zig`'s `Table.updateRestrictedByForeignKey` (see its
/// doc comment for why an update needs the same RESTRICT as a delete)
/// but reading through `self.txn.scanPrefix` like the delete-restrict
/// version above it, so a child row staged earlier in this same
/// uncommitted transaction already counts as a live reference. `old` may
/// be null (a fresh insert has nothing to pull out from under anyone),
/// and a `refField` whose new value equals its old value is exempt, the
/// same exemption `put`'s own UNIQUE check above makes.
fn updateRestrictedByForeignKey(self: *TableTxn, pk: Value, old: ?*const Row, newRow: *const Row) !bool {
var referencedBy = try idx.ReferencedBySet.load(self.allocator, self.txn.db, self.name);
defer referencedBy.deinit();
for (referencedBy.specs.items) |spec| {
const oldValue = if (old) |o| o.get(spec.refField) else null;
if (oldValue == null or oldValue.? == .none) continue;
const newValue = newRow.get(spec.refField);
if (newValue != null and Value.eql(oldValue.?, newValue.?)) continue;
const prefix = try idx.rowPrefix(self.allocator, spec.childTable);
defer self.allocator.free(prefix);
const entries = try self.txn.scanPrefix(self.allocator, prefix);
defer Transaction.freeEntries(self.allocator, entries);
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
for (entries) |entry| {
var r = try Row.decode(self.allocator, entry.value);
defer r.deinit();
if (selfReferencing) {
if (r.get(pkField)) |rowPk| {
if (Value.eql(rowPk, pk)) continue;
}
}
const childValue = r.get(spec.childField) orelse continue;
if (childValue == .none) continue;
if (Value.eql(childValue, oldValue.?)) return true;
}
}
return false;
}
pub fn delete(self: *TableTxn, pk: Value) !void {
var old = try self.get(pk) orelse return;
defer old.deinit();
if (try self.deletionRestrictedByForeignKey(pk, &old)) return error.ForeignKeyInUse;
for (self.indexes.fields.items) |indexedField| {
if (old.get(indexedField)) |v| {
try self.stageIndexRemove(indexedField, v, pk);
}
}
const key = try idx.rowKey(self.allocator, self.name, pk);
defer self.allocator.free(key);
try self.txn.delete(key);
}
fn stageIndexAdd(self: *TableTxn, field: []const u8, value: Value, pk: Value) !void {
const key = try idx.indexEntryKey(self.allocator, self.name, field, value, pk);
defer self.allocator.free(key);
const pkBytes = try rowmod.encodeValue(self.allocator, pk);
defer self.allocator.free(pkBytes);
try self.txn.put(key, pkBytes);
}