feat: add executeDelete and updateRestrictedByForeignKey to query

This commit is contained in:
dualfroz committed 2026-09-08 20:48:01 +02:00
1 parent 0cebee24b2
commit da6a9cb289
1 file changed
+157
+157
View File
@@ -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 <table> 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 <table> 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);