From f16fc2ab584465d5e9a07049616cef11896136b2 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 21:59:52 +0200 Subject: [PATCH] feat: add deletionRestrictedByForeignKey --- src/txn_table.zig | 161 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/txn_table.zig b/src/txn_table.zig index 568366d..d85830f 100644 --- a/src/txn_table.zig +++ b/src/txn_table.zig @@ -149,3 +149,164 @@ pub const TableTxn = struct { 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); + } +