From da6a9cb28944c595922f53a9449a6b2d69e433e3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 20:48:01 +0200 Subject: [PATCH] feat: add executeDelete and updateRestrictedByForeignKey to query --- src/query.zig | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/src/query.zig b/src/query.zig index b1aed4f..09c385d 100644 --- a/src/query.zig +++ b/src/query.zig @@ -788,3 +788,160 @@ pub const Table = struct { try plan.append(.{ .table = try self.allocator.dupe(u8, self.name), .pk = try pk.dupe(self.allocator) }); } + /// The actual row/index removal `delete` used to do inline before + /// cascading existed - no FOREIGN KEY check here at all, since by the + /// time this runs `planCascade` has already proven the entire plan is + /// safe to execute in order. Takes `pk` rather than a decoded row + /// because `CascadeAction` only carries the two, and re-fetching here + /// (rather than threading the `Row` `planCascade` already read) is + /// what lets `delete` stay tolerant of a row that no longer exists by + /// execution time - unreachable in practice (nothing between planning + /// and executing can delete a row out from under this single-threaded + /// call), but cheap to make robust to rather than to assume away. + fn executeDelete(self: *Table, pk: Value) !void { + var old = try self.get(pk) orelse return; + defer old.deinit(); + + for (self.indexes.fields.items) |indexedField| { + if (old.get(indexedField)) |v| { + try idx.removeEntry(self.db, self.allocator, self.name, indexedField, v, pk); + } + } + + for (self.composite.specs.items) |spec| { + var values: [maxCompositeFields]Value = undefined; + var complete = true; + for (spec.fields, 0..) |f, i| { + values[i] = old.get(f) orelse { + complete = false; + break; + }; + } + if (complete) try idx.removeCompositeEntry(self.db, self.allocator, self.name, spec.name, values[0..spec.fields.len], pk); + } + + const key = try idx.rowKey(self.allocator, self.name, pk); + defer self.allocator.free(key); + try self.db.delete(key); + } + + /// SQL's `ON DELETE RESTRICT`/`CASCADE` (see `planCascade` above) has an + /// easy-to-miss counterpart: this codebase never leaves a child dangling + /// when its parent ROW disappears, but until now nothing stopped a `put` + /// from changing the parent's *value* out from under a still-live child - + /// `refTable.dropIndex(refField)` and `updateWhere`/direct `put` changing + /// a `refField` are exactly the "carpet pulled from under the child" + /// cases `createForeignKey`'s own doc comment already named as open + /// gaps. Reuses the identical scan and self-reference exclusion as + /// `planCascade` (a value that stops existing under this pk is exactly + /// as dangerous to a live reference as a row that stops existing + /// entirely), but the trigger is "this pk's value for `refField` is + /// about to change", not "this pk is about to be deleted" - so `old` may + /// be null (a fresh insert has nothing to pull out from under anyone) + /// and a field whose new value equals its old value is exempt, the same + /// "unchanged is always safe" exemption `put`'s own UNIQUE check makes. + /// Unlike `planCascade`, this ALWAYS restricts regardless of the + /// constraint's `onDelete` mode - there is no `ON UPDATE CASCADE` here, + /// only `ON DELETE CASCADE`, so even a `cascade`-mode FK still blocks a + /// value change under a live reference. + fn updateRestrictedByForeignKey(self: *Table, pk: Value, old: ?*const Row, newRow: *const Row) !bool { + var referencedBy = try ReferencedBySet.load(self.allocator, self.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; + + 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, oldValue.?)) return true; + } + } + return false; + } + + /// Deletes `pk`, honoring every `ON DELETE` mode registered against + /// this table by some OTHER table's `createForeignKey` (see + /// `idx.OnDelete`'s doc comment): a `restrict`-mode reference still + /// pointing at this row aborts the whole call with + /// `error.ForeignKeyInUse` and nothing is mutated; a `cascade`-mode + /// one instead removes the referencing rows too, recursively, before + /// this row itself goes - see `planCascade`'s doc comment for why + /// that has to happen in two passes (plan every removal first, then + /// execute the plan) rather than deleting as it discovers each one. + /// A `pk` that does not exist is a silent no-op, same as before + /// cascading existed. + pub fn delete(self: *Table, pk: Value) !void { + var visited = CascadeVisited.init(self.allocator); + defer visited.deinit(); + var plan = std.ArrayList(CascadeAction).init(self.allocator); + defer { + for (plan.items) |a| a.deinit(self.allocator); + plan.deinit(); + } + + try self.planCascade(pk, &visited, &plan); + + for (plan.items) |action| { + var t = try Table.open(self.db, self.allocator, action.table); + defer t.deinit(); + try t.executeDelete(action.pk); + } + } + + /// Applies `sets` on top of every row matching `pred` (or every row, if + /// null) - SQL's `UPDATE SET ... WHERE pred`. Deliberately not a + /// bespoke in-place mutation path: each match is merged with `sets` + /// (a `set` entry overrides a same-named existing field, or is appended + /// if the row does not have that field yet) and written back through + /// `put`, the exact same call an external caller doing `get` then `put` + /// by hand would make - so every secondary/composite index touched by a + /// changed field is maintained exactly like any other write, with zero + /// special-casing here. Uses `selectAllMatches` rather than `selectPage` + /// because every match must be visited regardless of any page window - + /// there is no `LIMIT` on an `UPDATE`. Returns the number of rows + /// updated. + pub fn updateWhere(self: *Table, pred: ?Predicate, sets: []const Field) !usize { + const matches = try self.selectAllMatches(pred); + defer freeRows(self.allocator, matches); + + for (matches) |*row| { + const pk = row.get(pkField) orelse continue; + + var merged = Row.init(self.allocator); + defer merged.deinit(); + for (row.fields.items) |f| { + if (std.mem.eql(u8, f.name, pkField)) continue; + try merged.set(f.name, f.value); + } + for (sets) |f| try merged.set(f.name, f.value); + + try self.put(pk, merged.fields.items); + } + return matches.len; + } + + /// Deletes every row matching `pred` (or every row, if null) - SQL's + /// `DELETE FROM
WHERE pred`. Mirrors `updateWhere`: materializes + /// the full match set with `selectAllMatches` (a `DELETE` has no + /// `LIMIT` either, so partial-scan short-circuiting is wrong here same + /// as for update) then removes each match through the existing single-row + /// `delete`, so every secondary/composite index touched by the deleted + /// row is maintained exactly like a caller doing `get` then `delete` by + /// hand would - zero special-casing. Returns the number of rows deleted. + pub fn deleteWhere(self: *Table, pred: ?Predicate) !usize { + const matches = try self.selectAllMatches(pred); + defer freeRows(self.allocator, matches); +