From 0cebee24b2d924551fe273e8873b9fdd915f98b1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 20:20:10 +0200 Subject: [PATCH] feat: add CascadeAction and deinit to query --- src/query.zig | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/src/query.zig b/src/query.zig index 0d7d6bf..b1aed4f 100644 --- a/src/query.zig +++ b/src/query.zig @@ -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) }); + } +