From 7fb27cc9dcdc8b7bd8bad36dc640740beaf0d882 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 17:55:42 +0200 Subject: [PATCH] feat: extend query --- src/query.zig | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/query.zig b/src/query.zig index 70446cd..f91a6d4 100644 --- a/src/query.zig +++ b/src/query.zig @@ -3657,3 +3657,212 @@ test "ON DELETE CASCADE keeps secondary indexes on the cascaded rows in sync" { .{ .name = "status", .value = .{ .text = "open" } }, }); + const before = try idx.scanEq(&db, allocator, "orders", "status", .{ .text = "open" }); + defer allocator.free(before); + try std.testing.expectEqual(@as(usize, 1), before.len); + + try customers.delete(.{ .int = 1 }); + + // The cascaded delete went through `executeDelete`, which maintains + // secondary indexes exactly like a direct `orders.delete` would - a + // stale index entry pointing at a now-gone row would otherwise poison + // every future `status = "open"` scan. + const after = try idx.scanEq(&db, allocator, "orders", "status", .{ .text = "open" }); + defer allocator.free(after); + try std.testing.expectEqual(@as(usize, 0), after.len); +} + +test "ON DELETE CASCADE recurses transitively across more than one hop" { + 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, .cascade); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + var lineItems = try Table.open(&db, allocator, "line_items"); + defer lineItems.deinit(); + try lineItems.createForeignKey("order_id", "orders", pkField, .cascade); + try lineItems.put(.{ .int = 1000 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }}); + try lineItems.put(.{ .int = 1001 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }}); + + // Deleting the customer must cascade into orders, and from there into + // line_items - two hops, not just one. + try customers.delete(.{ .int = 1 }); + + try std.testing.expect((try customers.get(.{ .int = 1 })) == null); + try std.testing.expect((try orders.get(.{ .int = 100 })) == null); + try std.testing.expect((try lineItems.get(.{ .int = 1000 })) == null); + try std.testing.expect((try lineItems.get(.{ .int = 1001 })) == null); +} + +test "ON DELETE CASCADE is all-or-nothing: a RESTRICT hit anywhere in the fan-out aborts with nothing deleted" { + 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, .cascade); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + // A shipment row RESTRICTs deleting the order it points at - deleting + // the customer would need to cascade into this order, but the order + // itself cannot legally go while its own restrict-mode reference is + // still live. + var shipments = try Table.open(&db, allocator, "shipments"); + defer shipments.deinit(); + try shipments.createForeignKey("order_id", "orders", pkField, .restrict); + try shipments.put(.{ .int = 9000 }, &.{.{ .name = "order_id", .value = .{ .int = 100 } }}); + + try std.testing.expectError(error.ForeignKeyInUse, customers.delete(.{ .int = 1 })); + + // Two-phase plan-then-execute means the customer AND the order (which + // had already been planned for cascade removal before the restrict + // hit further down was discovered) are both still there - not just + // the customer. + var stillCustomer = (try customers.get(.{ .int = 1 })).?; + stillCustomer.deinit(); + var stillOrder = (try orders.get(.{ .int = 100 })).?; + stillOrder.deinit(); + var stillShipment = (try shipments.get(.{ .int = 9000 })).?; + stillShipment.deinit(); +} + +test "ON DELETE CASCADE terminates on a two-table delete cycle instead of recursing forever" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + // `a` and `b` each carry a nullable FK at the other, both ON DELETE + // CASCADE - a delete starting from either side must not infinite-loop + // bouncing back and forth between the two tables. + var a = try Table.open(&db, allocator, "a"); + defer a.deinit(); + var b = try Table.open(&db, allocator, "b"); + defer b.deinit(); + + try a.put(.{ .int = 1 }, &.{}); + try b.put(.{ .int = 1 }, &.{.{ .name = "a_id", .value = .{ .int = 1 } }}); + try a.createForeignKey("b_id", "b", pkField, .cascade); + try a.put(.{ .int = 1 }, &.{.{ .name = "b_id", .value = .{ .int = 1 } }}); + try b.createForeignKey("a_id", "a", pkField, .cascade); + + try a.delete(.{ .int = 1 }); + + try std.testing.expect((try a.get(.{ .int = 1 })) == null); + try std.testing.expect((try b.get(.{ .int = 1 })) == null); +} + +test "ON DELETE CASCADE on a self-referencing foreign key removes an entire subtree" { + 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.createForeignKey("manager_id", "employees", pkField, .cascade); + + // 1 (CEO) -> 2 (VP) -> 3 and 4 (reports of the VP). + try employees.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "CEO" } }}); + try employees.put(.{ .int = 2 }, &.{ + .{ .name = "name", .value = .{ .text = "VP" } }, + .{ .name = "manager_id", .value = .{ .int = 1 } }, + }); + try employees.put(.{ .int = 3 }, &.{ + .{ .name = "name", .value = .{ .text = "IC-A" } }, + .{ .name = "manager_id", .value = .{ .int = 2 } }, + }); + try employees.put(.{ .int = 4 }, &.{ + .{ .name = "name", .value = .{ .text = "IC-B" } }, + .{ .name = "manager_id", .value = .{ .int = 2 } }, + }); + + // Deleting the VP must cascade to both reports, but must not touch the + // CEO (an ancestor, not a dependent). + try employees.delete(.{ .int = 2 }); + + var ceo = (try employees.get(.{ .int = 1 })).?; + ceo.deinit(); + try std.testing.expect((try employees.get(.{ .int = 2 })) == null); + try std.testing.expect((try employees.get(.{ .int = 3 })) == null); + try std.testing.expect((try employees.get(.{ .int = 4 })) == null); +} + +test "deleteWhere cascades every match, not just the first" { + 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 = "region", .value = .{ .text = "eu" } }}); + try customers.put(.{ .int = 2 }, &.{.{ .name = "region", .value = .{ .text = "eu" } }}); + try customers.put(.{ .int = 3 }, &.{.{ .name = "region", .value = .{ .text = "us" } }}); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.createForeignKey("customer_id", "customers", pkField, .cascade); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + try orders.put(.{ .int = 200 }, &.{.{ .name = "customer_id", .value = .{ .int = 2 } }}); + try orders.put(.{ .int = 300 }, &.{.{ .name = "customer_id", .value = .{ .int = 3 } }}); + + const deleted = try customers.deleteWhere(.{ .eq = .{ .field = "region", .value = .{ .text = "eu" } } }); + try std.testing.expectEqual(@as(usize, 2), deleted); + + try std.testing.expect((try customers.get(.{ .int = 1 })) == null); + try std.testing.expect((try customers.get(.{ .int = 2 })) == null); + try std.testing.expect((try orders.get(.{ .int = 100 })) == null); + try std.testing.expect((try orders.get(.{ .int = 200 })) == null); + + var stillCustomer = (try customers.get(.{ .int = 3 })).?; + stillCustomer.deinit(); + var stillOrder = (try orders.get(.{ .int = 300 })).?; + stillOrder.deinit(); +} + +test "dropForeignKey lifts a CASCADE constraint the same as a RESTRICT one" { + 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 }, &.{}); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.createForeignKey("customer_id", "customers", pkField, .cascade); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + try orders.dropForeignKey("customer_id"); + try customers.delete(.{ .int = 1 }); + + try std.testing.expect((try customers.get(.{ .int = 1 })) == null); + // No FK left at all, so the order is untouched by the parent's delete - + // not restricted, not cascaded, simply irrelevant now. + var stillOrder = (try orders.get(.{ .int = 100 })).?; + stillOrder.deinit(); +}