feat: add CascadeAction and deinit to query

This commit is contained in:
dualfroz committed 2026-09-08 20:20:10 +02:00
1 parent b20d2b95ce
commit 0cebee24b2
1 file changed
+153
+153
View File
@@ -635,3 +635,156 @@ pub const Table = struct {
}
}
for (self.composite.specs.items) |spec| {
var oldValues: [maxCompositeFields]Value = undefined;
var oldComplete = old != null;
if (old) |*o| {
for (spec.fields, 0..) |f, i| {
oldValues[i] = o.get(f) orelse {
oldComplete = false;
break;
};
}
}
var newValues: [maxCompositeFields]Value = undefined;
var newComplete = true;
for (spec.fields, 0..) |f, i| {
newValues[i] = newRow.get(f) orelse {
newComplete = false;
break;
};
}
const unchanged = oldComplete and newComplete and valueTupleEql(oldValues[0..spec.fields.len], newValues[0..spec.fields.len]);
if (unchanged) continue;
if (oldComplete) try idx.removeCompositeEntry(self.db, self.allocator, self.name, spec.name, oldValues[0..spec.fields.len], pk);
if (newComplete) try idx.addCompositeEntry(self.db, self.allocator, self.name, spec.name, newValues[0..spec.fields.len], pk);
}
try self.db.put(key, encoded);
}
pub fn get(self: *Table, pk: Value) !?Row {
const key = try idx.rowKey(self.allocator, self.name, pk);
defer self.allocator.free(key);
const raw = try self.db.get(self.allocator, key) orelse return null;
defer self.allocator.free(raw);
return try Row.decode(self.allocator, raw);
}
/// One (table, pk) a cascading delete has decided to remove, in the
/// order `planCascade` discovered it - deepest dependents first, the
/// row that was actually asked for last. Owns copies of both fields
/// independently of whatever `Row`/`Table` produced them, since the
/// scan that found them is long gone (and possibly a different
/// `Table` handle entirely) by the time `delete` executes the plan.
const CascadeAction = struct {
table: []u8,
pk: Value,
fn deinit(self: *const CascadeAction, allocator: Allocator) void {
allocator.free(self.table);
self.pk.free(allocator);
}
};
/// Cycle guard for `planCascade`: every (table, pk) already visited
/// this call, so a cascade that loops back on itself (two tables
/// `ON DELETE CASCADE`-ing at each other, or a longer cycle through
/// three or more) terminates instead of recursing forever. Linear
/// scan is fine here - a cascading delete's fan-out is bounded by how
/// much data actually exists, and this runs once per row touched, not
/// once per row scanned.
const CascadeVisited = struct {
allocator: Allocator,
entries: std.ArrayList(CascadeAction),
fn init(allocator: Allocator) CascadeVisited {
return .{ .allocator = allocator, .entries = std.ArrayList(CascadeAction).init(allocator) };
}
fn contains(self: *const CascadeVisited, table: []const u8, pk: Value) bool {
for (self.entries.items) |e| {
if (std.mem.eql(u8, e.table, table) and Value.eql(e.pk, pk)) return true;
}
return false;
}
fn add(self: *CascadeVisited, table: []const u8, pk: Value) !void {
const tableCopy = try self.allocator.dupe(u8, table);
errdefer self.allocator.free(tableCopy);
const pkCopy = try pk.dupe(self.allocator);
try self.entries.append(.{ .table = tableCopy, .pk = pkCopy });
}
fn deinit(self: *CascadeVisited) void {
for (self.entries.items) |e| e.deinit(self.allocator);
self.entries.deinit();
}
};
/// Recursively works out everything a delete of `pk` must remove,
/// WITHOUT deleting anything yet, appending each decision to `plan` in
/// dependent-before-dependency order (see `CascadeAction`'s doc
/// comment) so `delete` can execute it leaves-first. This two-phase
/// plan/execute split is what keeps a cascading delete all-or-nothing:
/// a `restrict`-mode violation discovered three levels deep must abort
/// with NOTHING mutated, exactly like the single-table check this
/// replaced - if planning and deleting were interleaved, an earlier
/// sibling subtree could already be gone by the time a later one fails.
///
/// For every `ReferencedBySet` entry naming a table with a live row
/// pointing at `old`'s value for the referenced field (see the
/// previous version of this function for why the child is opened and
/// scanned in full rather than through `idx.scanEq`, and why a
/// self-referencing FK excludes `pk` itself from matching): `restrict`
/// aborts the whole plan with `error.ForeignKeyInUse`; `cascade`
/// recurses into that child row first, so it lands in `plan` ahead of
/// `self` (a child must be gone before its parent is, or the delete
/// order would itself violate the very constraint being enforced).
///
/// `visited` is checked (and updated) BEFORE any of that per-row work,
/// so a cycle (`planCascade(A)` reaching `planCascade(B)` reaching
/// `planCascade(A)` again) short-circuits the re-entrant call instead
/// of looping - the ORIGINAL call for that (table, pk) is still the one
/// that appends it to `plan`, once its own scan finishes, so nothing is
/// silently dropped from the plan by the cycle.
fn planCascade(self: *Table, pk: Value, visited: *CascadeVisited, plan: *std.ArrayList(CascadeAction)) !void {
if (visited.contains(self.name, pk)) return;
var old = try self.get(pk) orelse return;
defer old.deinit();
try visited.add(self.name, pk);
var referencedBy = try ReferencedBySet.load(self.allocator, self.db, self.name);
defer referencedBy.deinit();
for (referencedBy.specs.items) |spec| {
const value = old.get(spec.refField) orelse continue;
if (value == .none) continue;
var child = try Table.open(self.db, self.allocator, spec.childTable);
defer child.deinit();
const rows = try child.scanAll();
defer freeRows(self.allocator, rows);
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
for (rows) |*r| {
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)) continue;
switch (spec.onDelete) {
.restrict => return error.ForeignKeyInUse,
.cascade => {
const childPk = r.get(pkField) orelse continue;
try child.planCascade(childPk, visited, plan);
},
}
}
}
try plan.append(.{ .table = try self.allocator.dupe(u8, self.name), .pk = try pk.dupe(self.allocator) });
}