diff --git a/src/query.zig b/src/query.zig index a49496f..978cd99 100644 --- a/src/query.zig +++ b/src/query.zig @@ -3017,3 +3017,160 @@ test "unique constraint survives a table reopen (persisted across process restar try table.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@x.com" } }}); } + var reopened = try Table.open(&db, allocator, "users"); + defer reopened.deinit(); + try std.testing.expect(reopened.hasUniqueIndex("email")); + try std.testing.expectError(error.UniqueViolation, reopened.put(.{ .int = 2 }, &.{ + .{ .name = "email", .value = .{ .text = "a@x.com" } }, + })); +} + +test "createUniqueIndex rejects a bad field identifier" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try std.testing.expectError(error.InvalidIdentifier, table.createUniqueIndex("bad\x00field")); +} + +test "rows missing the unique field entirely never collide with each other" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{.{ .name = "age", .value = .{ .int = 20 } }}); + try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 21 } }}); + + try table.createUniqueIndex("email"); + try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 22 } }}); + const rows = try table.scanAll(); + defer freeRows(allocator, rows); + try std.testing.expectEqual(@as(usize, 3), rows.len); +} + +test "createForeignKey against the primary key rejects a dangling reference and accepts a real 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 }, &.{.{ .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 std.testing.expect(orders.hasForeignKey("customer_id")); + + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + try std.testing.expectError(error.ForeignKeyViolation, orders.put(.{ .int = 101 }, &.{ + .{ .name = "customer_id", .value = .{ .int = 999 } }, + })); +} + +test "createForeignKey against a UNIQUE non-pk field works, and refuses a target with no unique backing" { + 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.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "SKU-1" } }}); + + var lineItems = try Table.open(&db, allocator, "line_items"); + defer lineItems.deinit(); + + // "sku" has no unique constraint on products yet. + try std.testing.expectError(error.ForeignKeyRequiresUniqueTarget, lineItems.createForeignKey("product_sku", "products", "sku", .restrict)); + + try products.createUniqueIndex("sku"); + try lineItems.createForeignKey("product_sku", "products", "sku", .restrict); + + try lineItems.put(.{ .int = 1 }, &.{.{ .name = "product_sku", .value = .{ .text = "SKU-1" } }}); + try std.testing.expectError(error.ForeignKeyViolation, lineItems.put(.{ .int = 2 }, &.{ + .{ .name = "product_sku", .value = .{ .text = "NOPE" } }, + })); +} + +test "createForeignKey refuses to install over a child row that already dangles" { + 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(); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.put(.{ .int = 1 }, &.{.{ .name = "customer_id", .value = .{ .int = 42 } }}); + + try std.testing.expectError(error.ForeignKeyViolation, orders.createForeignKey("customer_id", "customers", pkField, .restrict)); + try std.testing.expect(!orders.hasForeignKey("customer_id")); +} + +test "a row with no value for the foreign key field is exempt from the constraint" { + 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(); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.createForeignKey("customer_id", "customers", pkField, .restrict); + + // No "customer_id" field at all - a nullable foreign key, not a violation. + try orders.put(.{ .int = 1 }, &.{.{ .name = "note", .value = .{ .text = "guest checkout" } }}); + var row = (try orders.get(.{ .int = 1 })).?; + defer row.deinit(); + try std.testing.expect(row.get("customer_id") == null); +} + +test "dropForeignKey lifts the constraint" { + 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(); + + var orders = try Table.open(&db, allocator, "orders"); + defer orders.deinit(); + try orders.createForeignKey("customer_id", "customers", pkField, .restrict); + + try std.testing.expectError(error.ForeignKeyViolation, orders.put(.{ .int = 1 }, &.{ + .{ .name = "customer_id", .value = .{ .int = 42 } }, + })); + + try orders.dropForeignKey("customer_id"); + try std.testing.expect(!orders.hasForeignKey("customer_id")); + try orders.put(.{ .int = 1 }, &.{.{ .name = "customer_id", .value = .{ .int = 42 } }}); +} + +test "deleting a parent row is restricted while a child still references it via the primary key" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); +