From 2a8227b64d329f9efc2584e3bf9a9faf6a962b9f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:37:37 +0200 Subject: [PATCH] feat: continue work on query --- src/query.zig | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/query.zig b/src/query.zig index b7cc886..1e87434 100644 --- a/src/query.zig +++ b/src/query.zig @@ -3335,3 +3335,166 @@ test "a self-referencing foreign key enforces a tree structure against the table var db = try openTemp(&tmp, allocator); defer db.close(); + var employees = try Table.open(&db, allocator, "employees"); + defer employees.deinit(); + try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }}); + try employees.createForeignKey("manager_id", "employees", pkField, .restrict); + + // A row with no manager (the root of the tree) is exempt. + try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }}); + // A report naming an existing manager is fine. + try employees.put(.{ .int = 2 }, &.{ + .{ .name = "name", .value = .{ .text = "VP" } }, + .{ .name = "manager_id", .value = .{ .int = 1 } }, + }); + try std.testing.expectError(error.ForeignKeyViolation, employees.put(.{ .int = 3 }, &.{ + .{ .name = "name", .value = .{ .text = "Ghost report" } }, + .{ .name = "manager_id", .value = .{ .int = 999 } }, + })); +} + +test "deleting a referenced parent is blocked before it can leave a child's foreign key dangling" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var customers = try Table.open(&db, allocator, "customers"); + defer customers.deinit(); + try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "Ada" } }}); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.createForeignKey("customer_id", "customers", pkField, .restrict); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + // This used to be the one gap `updateWhere`'s own per-row FK re-check + // existed to catch after the fact: a bare `customers.delete` leaving + // orders#100's reference dangling even though it was valid when first + // written (see createForeignKey's OLD doc comment). `Table.delete`'s + // ON DELETE RESTRICT check (backed by `ReferencedBySet`) now closes it + // at the source instead - the dangling state this test used to + // construct is no longer reachable through delete at all. + try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 })); + + // updateWhere re-writes the row through put with every field carried + // over unchanged, including "customer_id" - it has nothing to catch, + // because the reference was never allowed to go dangling in the first + // place. + try std.testing.expectEqual(@as(usize, 1), try orders.updateWhere(null, &.{ + .{ .name = "note", .value = .{ .text = "touch" } }, + })); +} + +test "put is restricted from changing a UNIQUE foreign key target still referenced by a child" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var products = try Table.open(&db, allocator, "products"); + defer products.deinit(); + try products.createUniqueIndex("sku"); + try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }}); + + var lineItems = try Table.open(&db, allocator, "line_items"); + defer lineItems.deinit(); + try lineItems.createForeignKey("product_sku", "products", "sku", .restrict); + try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } }}); + + // Renaming the sku out from under the still-live line item must not + // silently strand it - the same RESTRICT a delete would get. + try std.testing.expectError(error.ForeignKeyInUse, products.put(.{ .int = 1 }, &.{ + .{ .name = "sku", .value = .{ .text = "WIDGET-2" } }, + })); + var stillThere = (try products.get(.{ .int = 1 })).?; + defer stillThere.deinit(); + try std.testing.expectEqualStrings("WIDGET-1", stillThere.get("sku").?.text); + + // Rewriting the same value back (or any other unrelated field) is not + // a change and is always exempt, mirroring the UNIQUE check's own + // "unchanged is safe" exemption. + try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }}); + + // Once the reference is gone, the rename goes through. + try lineItems.delete(.{ .int = 1 }); + try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-2" } }}); + var renamed = (try products.get(.{ .int = 1 })).?; + defer renamed.deinit(); + try std.testing.expectEqualStrings("WIDGET-2", renamed.get("sku").?.text); +} + +test "updateWhere is restricted from pulling a UNIQUE foreign key target out from under a child" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var products = try Table.open(&db, allocator, "products"); + defer products.deinit(); + try products.createUniqueIndex("sku"); + try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }}); + + var lineItems = try Table.open(&db, allocator, "line_items"); + defer lineItems.deinit(); + try lineItems.createForeignKey("product_sku", "products", "sku", .restrict); + try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } }}); + + try std.testing.expectError(error.ForeignKeyInUse, products.updateWhere(null, &.{ + .{ .name = "sku", .value = .{ .text = "WIDGET-2" } }, + })); + + // A bulk update that leaves the referenced field untouched has + // nothing to restrict. + try std.testing.expectEqual(@as(usize, 1), try products.updateWhere(null, &.{ + .{ .name = "note", .value = .{ .text = "touch" } }, + })); +} + +test "a self-referencing foreign key restricts renaming a UNIQUE field still targeted by a report, but not by itself" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var employees = try Table.open(&db, allocator, "employees"); + defer employees.deinit(); + try employees.createUniqueIndex("badge"); + try employees.createForeignKey("manager_badge", "employees", "badge", .restrict); + try employees.put(.{ .int = 1 }, &.{.{ .name = "badge", .value = .{ .text = "B1" } }}); + try employees.put(.{ .int = 2 }, &.{ + .{ .name = "badge", .value = .{ .text = "B2" } }, + .{ .name = "manager_badge", .value = .{ .text = "B1" } }, + }); + + // Employee 2 still points at "B1" - renaming employee 1's own badge + // must be restricted. + try std.testing.expectError(error.ForeignKeyInUse, employees.put(.{ .int = 1 }, &.{ + .{ .name = "badge", .value = .{ .text = "B1-NEW" } }, + })); + + // Employee 2 renaming its OWN badge (not the field being referenced by + // anyone) is unaffected - and does not restrict against itself despite + // the scan covering the same table. + try employees.put(.{ .int = 2 }, &.{ + .{ .name = "badge", .value = .{ .text = "B2-NEW" } }, + .{ .name = "manager_badge", .value = .{ .text = "B1" } }, + }); + + // Once the report's manager_badge field is dropped (a row with no + // value for the FK field is exempt from the constraint, so this is a + // valid way to stop referencing anything), the manager's badge can be + // renamed. + try employees.put(.{ .int = 2 }, &.{ + .{ .name = "badge", .value = .{ .text = "B2-NEW" } }, + }); + try employees.put(.{ .int = 1 }, &.{.{ .name = "badge", .value = .{ .text = "B1-NEW" } }}); + var renamed = (try employees.get(.{ .int = 1 })).?; + defer renamed.deinit(); + try std.testing.expectEqualStrings("B1-NEW", renamed.get("badge").?.text); +} +