feat: extend query

This commit is contained in:
dualfroz committed 2026-09-09 17:55:42 +02:00
1 parent bda37d1ecf
commit 7fb27cc9dc
1 file changed
+209
+209
View File
@@ -3657,3 +3657,212 @@ test "ON DELETE CASCADE keeps secondary indexes on the cascaded rows in sync" {
.{ .name = "status", .value = .{ .text = "open" } }, .{ .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();
}