792 lines
32 KiB
Zig
792 lines
32 KiB
Zig
const std = @import("std");
|
|
const txnmod = @import("txn.zig");
|
|
const dbmod = @import("db.zig");
|
|
const rowmod = @import("row.zig");
|
|
const idx = @import("index.zig");
|
|
const querymod = @import("query.zig");
|
|
const exprmod = @import("expr.zig");
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
const Transaction = txnmod.Transaction;
|
|
const Db = dbmod.Db;
|
|
const Value = rowmod.Value;
|
|
const Row = rowmod.Row;
|
|
const Field = rowmod.Field;
|
|
const IndexSet = idx.IndexSet;
|
|
const UniqueSet = idx.UniqueSet;
|
|
const ForeignKeySet = idx.ForeignKeySet;
|
|
const CheckConstraintSet = idx.CheckConstraintSet;
|
|
|
|
/// Same reserved primary-key field name query.zig's Table uses, so rows
|
|
/// written through a TableTxn and rows written through a plain Table are
|
|
/// wire-compatible: both land in the same "R\x00table\x00pk" keyspace and
|
|
/// decode identically.
|
|
pub const pkField = querymod.pkField;
|
|
|
|
/// A Table view bound to one in-flight Transaction. Where query.zig's Table
|
|
/// writes each row and its index entries directly to the Db (each `put`/
|
|
/// `delete` call is independently durable the moment it returns), TableTxn
|
|
/// stages every row write and every index-entry write into the Transaction's
|
|
/// write set instead. Nothing is visible to other readers, and nothing can
|
|
/// conflict with a concurrent writer, until the caller calls `Transaction.
|
|
/// commit()` - at which point every staged row and index mutation across
|
|
/// every TableTxn.put/delete call made against this Transaction becomes
|
|
/// visible atomically in a single WriteBatch, or none of it does (OCC
|
|
/// conflict -> error.Conflict, no partial application).
|
|
///
|
|
/// Index membership itself (which fields are indexed) is read directly from
|
|
/// the Db when a TableTxn is opened, not through the Transaction - creating
|
|
/// or dropping an index is a schema change made through query.zig's Table,
|
|
/// not through TableTxn. A TableTxn only keeps *existing* indexes in sync
|
|
/// for the rows it writes.
|
|
pub const TableTxn = struct {
|
|
txn: *Transaction,
|
|
allocator: Allocator,
|
|
name: []u8,
|
|
indexes: IndexSet,
|
|
unique: UniqueSet,
|
|
foreignKeys: ForeignKeySet,
|
|
checks: CheckConstraintSet,
|
|
|
|
pub fn open(txn: *Transaction, allocator: Allocator, name: []const u8) !TableTxn {
|
|
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
|
|
return .{
|
|
.txn = txn,
|
|
.allocator = allocator,
|
|
.name = try allocator.dupe(u8, name),
|
|
.indexes = try IndexSet.load(allocator, txn.db, name),
|
|
.unique = try UniqueSet.load(allocator, txn.db, name),
|
|
.foreignKeys = try ForeignKeySet.load(allocator, txn.db, name),
|
|
.checks = try CheckConstraintSet.load(allocator, txn.db, name),
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *TableTxn) void {
|
|
self.allocator.free(self.name);
|
|
self.indexes.deinit();
|
|
self.unique.deinit();
|
|
self.foreignKeys.deinit();
|
|
self.checks.deinit();
|
|
}
|
|
|
|
pub fn hasIndex(self: *const TableTxn, field: []const u8) bool {
|
|
return self.indexes.has(field);
|
|
}
|
|
|
|
pub fn hasUniqueIndex(self: *const TableTxn, field: []const u8) bool {
|
|
return self.unique.has(field);
|
|
}
|
|
|
|
pub fn hasForeignKey(self: *const TableTxn, field: []const u8) bool {
|
|
return self.foreignKeys.has(field);
|
|
}
|
|
|
|
pub fn hasCheckConstraint(self: *const TableTxn, name: []const u8) bool {
|
|
return self.checks.has(name);
|
|
}
|
|
|
|
/// Whether `value` names an existing row of `spec.refTable` under
|
|
/// `spec.refField`, mirroring `query.zig`'s `Table.foreignKeyTargetExists`
|
|
/// with one difference: both branches go through the bound `Transaction`
|
|
/// instead of a plain `Db` call, so a parent row (or a unique-field FK
|
|
/// target) staged earlier in the SAME uncommitted transaction already
|
|
/// satisfies a child's FK check before either is durable -
|
|
/// read-your-writes across tables, not just within one, via
|
|
/// `Transaction.get` for the `pkField` case and `Transaction.scanPrefix`
|
|
/// (see its doc comment) for the non-pk case.
|
|
fn foreignKeyTargetExists(self: *TableTxn, refTableName: []const u8, refField: []const u8, value: Value) !bool {
|
|
if (std.mem.eql(u8, refField, pkField)) {
|
|
const key = try idx.rowKey(self.allocator, refTableName, value);
|
|
defer self.allocator.free(key);
|
|
const raw = try self.txn.get(self.allocator, key) orelse return false;
|
|
self.allocator.free(raw);
|
|
return true;
|
|
}
|
|
const prefix = try idx.indexValueBound(self.allocator, refTableName, refField, value);
|
|
defer self.allocator.free(prefix);
|
|
const entries = try self.txn.scanPrefix(self.allocator, prefix);
|
|
defer txnmod.Transaction.freeEntries(self.allocator, entries);
|
|
return entries.len > 0;
|
|
}
|
|
|
|
/// Reads through the bound Transaction, so this sees the Transaction's
|
|
/// own uncommitted writes (read-your-writes) and registers the row key
|
|
/// as read for OCC conflict detection at commit time.
|
|
pub fn get(self: *TableTxn, pk: Value) !?Row {
|
|
const key = try idx.rowKey(self.allocator, self.name, pk);
|
|
defer self.allocator.free(key);
|
|
const raw = try self.txn.get(self.allocator, key) orelse return null;
|
|
defer self.allocator.free(raw);
|
|
return try Row.decode(self.allocator, raw);
|
|
}
|
|
|
|
/// Stages a full-row replace of `pk` plus every index-entry add/remove
|
|
/// needed to keep currently-registered indexes consistent with the new
|
|
/// row, all as buffered Transaction writes. None of it is visible or
|
|
/// durable until commit.
|
|
pub fn put(self: *TableTxn, pk: Value, fields: []const Field) !void {
|
|
var old = try self.get(pk);
|
|
defer if (old) |*o| o.deinit();
|
|
|
|
var newRow = Row.init(self.allocator);
|
|
defer newRow.deinit();
|
|
try newRow.set(pkField, pk);
|
|
for (fields) |f| try newRow.set(f.name, f.value);
|
|
|
|
const encoded = try newRow.encode(self.allocator);
|
|
defer self.allocator.free(encoded);
|
|
const key = try idx.rowKey(self.allocator, self.name, pk);
|
|
defer self.allocator.free(key);
|
|
|
|
// CHECK constraints run first, ahead of UNIQUE/FOREIGN KEY - same
|
|
// ordering and same "reparse on every put" cost as `query.zig`'s
|
|
// `Table.put`. Purely local to `newRow`, so unlike UNIQUE/FOREIGN
|
|
// KEY below this needs no `Transaction.scanPrefix` read-your-writes
|
|
// handling at all.
|
|
for (self.checks.specs.items) |spec| {
|
|
var parsed = exprmod.parse(self.allocator, spec.source) catch return error.InvalidCheckExpression;
|
|
defer parsed.deinit();
|
|
if (!parsed.predicate.evaluate(&newRow)) return error.CheckViolation;
|
|
}
|
|
|
|
// UNIQUE and FOREIGN KEY checks run before any staged mutation
|
|
// below, same all-or-nothing placement as `query.zig`'s `Table.put`:
|
|
// a violation leaves the transaction's write set exactly as it was
|
|
// before this call, not staged with a partial row. Both checks go
|
|
// through `Transaction.scanPrefix`, so a UNIQUE value or FK target
|
|
// staged by an EARLIER `put` call in this same still-open
|
|
// transaction (a different row, possibly a different `TableTxn`
|
|
// bound to the same `Transaction`) is caught here already, not left
|
|
// to surface as `error.Conflict` at `commit()`.
|
|
for (self.unique.fields.items) |field| {
|
|
const newValue = newRow.get(field) orelse continue;
|
|
const oldValue = if (old) |*o| o.get(field) else null;
|
|
if (oldValue != null and Value.eql(oldValue.?, newValue)) continue;
|
|
const prefix = try idx.indexValueBound(self.allocator, self.name, field, newValue);
|
|
defer self.allocator.free(prefix);
|
|
const entries = try self.txn.scanPrefix(self.allocator, prefix);
|
|
defer txnmod.Transaction.freeEntries(self.allocator, entries);
|
|
for (entries) |e| {
|
|
const ownerPk = try rowmod.decodeValue(self.allocator, e.value);
|
|
defer ownerPk.free(self.allocator);
|
|
if (!Value.eql(ownerPk, pk)) return error.UniqueViolation;
|
|
}
|
|
}
|
|
|
|
for (self.foreignKeys.specs.items) |spec| {
|
|
const newValue = newRow.get(spec.field) orelse continue;
|
|
if (newValue == .none) continue;
|
|
if (!try self.foreignKeyTargetExists(spec.refTable, spec.refField, newValue)) return error.ForeignKeyViolation;
|
|
}
|
|
|
|
// ON DELETE RESTRICT's update-side counterpart, see
|
|
// `updateRestrictedByForeignKey`'s doc comment - same all-or-nothing
|
|
// placement as every other constraint check above.
|
|
if (try self.updateRestrictedByForeignKey(pk, if (old) |*o| o else null, &newRow)) return error.ForeignKeyInUse;
|
|
|
|
for (self.indexes.fields.items) |indexedField| {
|
|
const newValue = newRow.get(indexedField);
|
|
const oldValue = if (old) |*o| o.get(indexedField) else null;
|
|
if (oldValue != null and (newValue == null or !Value.eql(oldValue.?, newValue.?))) {
|
|
try self.stageIndexRemove(indexedField, oldValue.?, pk);
|
|
}
|
|
if (newValue) |nv| {
|
|
if (oldValue == null or !Value.eql(oldValue.?, nv)) {
|
|
try self.stageIndexAdd(indexedField, nv, pk);
|
|
}
|
|
}
|
|
}
|
|
|
|
try self.txn.put(key, encoded);
|
|
}
|
|
|
|
/// Mirrors `query.zig`'s `Table.deletionRestrictedByForeignKey` (SQL's
|
|
/// `ON DELETE RESTRICT`), reading through `self.txn.scanPrefix` instead
|
|
/// of a plain `Db` scan - the one thing this version can do that the
|
|
/// plain `Table` path cannot: a child row staged earlier in this SAME
|
|
/// uncommitted transaction (via a sibling `TableTxn` on the child
|
|
/// table, sharing this `Transaction`) already counts as a live
|
|
/// reference, not just whatever is durably committed. `referencedBy`
|
|
/// itself is still loaded fresh from `self.txn.db` rather than cached
|
|
/// (schema metadata - like `indexes`/`unique`/`foreignKeys` above - is
|
|
/// only ever mutated through `query.zig`'s `Table`, never through a
|
|
/// `TableTxn`, but unlike those, `ReferencedBySet` is written by some
|
|
/// OTHER table's `createForeignKey` call, so a `TableTxn` opened before
|
|
/// that ran cannot rely on whatever it loaded at `open()` time either -
|
|
/// see `Table.createForeignKey`'s doc comment for the same reasoning).
|
|
fn deletionRestrictedByForeignKey(self: *TableTxn, pk: Value, old: *const Row) !bool {
|
|
var referencedBy = try idx.ReferencedBySet.load(self.allocator, self.txn.db, self.name);
|
|
defer referencedBy.deinit();
|
|
|
|
for (referencedBy.specs.items) |spec| {
|
|
const value = old.get(spec.refField) orelse continue;
|
|
if (value == .none) continue;
|
|
|
|
const prefix = try idx.rowPrefix(self.allocator, spec.childTable);
|
|
defer self.allocator.free(prefix);
|
|
const entries = try self.txn.scanPrefix(self.allocator, prefix);
|
|
defer Transaction.freeEntries(self.allocator, entries);
|
|
|
|
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
|
|
for (entries) |entry| {
|
|
var r = try Row.decode(self.allocator, entry.value);
|
|
defer r.deinit();
|
|
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, value)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// `TableTxn.put`'s counterpart to `deletionRestrictedByForeignKey`,
|
|
/// mirroring `query.zig`'s `Table.updateRestrictedByForeignKey` (see its
|
|
/// doc comment for why an update needs the same RESTRICT as a delete)
|
|
/// but reading through `self.txn.scanPrefix` like the delete-restrict
|
|
/// version above it, so a child row staged earlier in this same
|
|
/// uncommitted transaction already counts as a live reference. `old` may
|
|
/// be null (a fresh insert has nothing to pull out from under anyone),
|
|
/// and a `refField` whose new value equals its old value is exempt, the
|
|
/// same exemption `put`'s own UNIQUE check above makes.
|
|
fn updateRestrictedByForeignKey(self: *TableTxn, pk: Value, old: ?*const Row, newRow: *const Row) !bool {
|
|
var referencedBy = try idx.ReferencedBySet.load(self.allocator, self.txn.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;
|
|
|
|
const prefix = try idx.rowPrefix(self.allocator, spec.childTable);
|
|
defer self.allocator.free(prefix);
|
|
const entries = try self.txn.scanPrefix(self.allocator, prefix);
|
|
defer Transaction.freeEntries(self.allocator, entries);
|
|
|
|
const selfReferencing = std.mem.eql(u8, spec.childTable, self.name);
|
|
for (entries) |entry| {
|
|
var r = try Row.decode(self.allocator, entry.value);
|
|
defer r.deinit();
|
|
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;
|
|
}
|
|
|
|
pub fn delete(self: *TableTxn, pk: Value) !void {
|
|
var old = try self.get(pk) orelse return;
|
|
defer old.deinit();
|
|
|
|
if (try self.deletionRestrictedByForeignKey(pk, &old)) return error.ForeignKeyInUse;
|
|
|
|
for (self.indexes.fields.items) |indexedField| {
|
|
if (old.get(indexedField)) |v| {
|
|
try self.stageIndexRemove(indexedField, v, pk);
|
|
}
|
|
}
|
|
|
|
const key = try idx.rowKey(self.allocator, self.name, pk);
|
|
defer self.allocator.free(key);
|
|
try self.txn.delete(key);
|
|
}
|
|
|
|
fn stageIndexAdd(self: *TableTxn, field: []const u8, value: Value, pk: Value) !void {
|
|
const key = try idx.indexEntryKey(self.allocator, self.name, field, value, pk);
|
|
defer self.allocator.free(key);
|
|
const pkBytes = try rowmod.encodeValue(self.allocator, pk);
|
|
defer self.allocator.free(pkBytes);
|
|
try self.txn.put(key, pkBytes);
|
|
}
|
|
|
|
fn stageIndexRemove(self: *TableTxn, field: []const u8, value: Value, pk: Value) !void {
|
|
const key = try idx.indexEntryKey(self.allocator, self.name, field, value, pk);
|
|
defer self.allocator.free(key);
|
|
try self.txn.delete(key);
|
|
}
|
|
};
|
|
|
|
fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db {
|
|
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
|
|
defer allocator.free(dbPath);
|
|
return Db.open(allocator, dbPath, .{});
|
|
}
|
|
|
|
test "TableTxn commits multiple rows atomically" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
|
|
var t = try TableTxn.open(&txn, allocator, "accounts");
|
|
defer t.deinit();
|
|
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = 100 } }});
|
|
try t.put(.{ .int = 2 }, &.{.{ .name = "balance", .value = .{ .int = 50 } }});
|
|
|
|
// Nothing visible outside the transaction yet.
|
|
const probeKey = try idx.rowKey(allocator, "accounts", .{ .int = 1 });
|
|
defer allocator.free(probeKey);
|
|
try std.testing.expect((try db.get(allocator, probeKey)) == null);
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer plain.deinit();
|
|
var row1 = (try plain.get(.{ .int = 1 })).?;
|
|
defer row1.deinit();
|
|
var row2 = (try plain.get(.{ .int = 2 })).?;
|
|
defer row2.deinit();
|
|
try std.testing.expectEqual(@as(i64, 100), row1.get("balance").?.int);
|
|
try std.testing.expectEqual(@as(i64, 50), row2.get("balance").?.int);
|
|
}
|
|
|
|
test "TableTxn transfer is all-or-nothing on conflict" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer setup.deinit();
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = 100 } }});
|
|
try setup.put(.{ .int = 2 }, &.{.{ .name = "balance", .value = .{ .int = 50 } }});
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "accounts");
|
|
defer t.deinit();
|
|
|
|
// Move 30 from account 1 to account 2, staged but not yet committed.
|
|
var from = (try t.get(.{ .int = 1 })).?;
|
|
defer from.deinit();
|
|
var to = (try t.get(.{ .int = 2 })).?;
|
|
defer to.deinit();
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = from.get("balance").?.int - 30 } }});
|
|
try t.put(.{ .int = 2 }, &.{.{ .name = "balance", .value = .{ .int = to.get("balance").?.int + 30 } }});
|
|
|
|
// An external writer touches account 1 before the transfer commits.
|
|
{
|
|
var external = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer external.deinit();
|
|
try external.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = 999 } }});
|
|
}
|
|
|
|
try std.testing.expectError(error.Conflict, txn.commit());
|
|
|
|
// Neither leg of the transfer applied: account 2 must be untouched,
|
|
// and account 1 must show the external writer's value, not the stale
|
|
// transfer's.
|
|
var verify = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer verify.deinit();
|
|
var row1 = (try verify.get(.{ .int = 1 })).?;
|
|
defer row1.deinit();
|
|
var row2 = (try verify.get(.{ .int = 2 })).?;
|
|
defer row2.deinit();
|
|
try std.testing.expectEqual(@as(i64, 999), row1.get("balance").?.int);
|
|
try std.testing.expectEqual(@as(i64, 50), row2.get("balance").?.int);
|
|
}
|
|
|
|
test "TableTxn keeps a pre-existing index in sync across commit" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "users");
|
|
defer setup.deinit();
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
|
|
try setup.createIndex("city");
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
try std.testing.expect(t.hasIndex("city"));
|
|
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }});
|
|
try t.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "users");
|
|
defer plain.deinit();
|
|
const warsaw = try plain.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
|
|
defer querymod.freeRows(allocator, warsaw);
|
|
try std.testing.expectEqual(@as(usize, 1), warsaw.len);
|
|
try std.testing.expectEqual(@as(i64, 2), warsaw[0].get(pkField).?.int);
|
|
|
|
const krakow = try plain.select(.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } });
|
|
defer querymod.freeRows(allocator, krakow);
|
|
try std.testing.expectEqual(@as(usize, 1), krakow.len);
|
|
try std.testing.expectEqual(@as(i64, 1), krakow[0].get(pkField).?.int);
|
|
}
|
|
|
|
test "TableTxn delete removes row and index entries only on commit" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "users");
|
|
defer setup.deinit();
|
|
try setup.createIndex("city");
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }});
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
try t.delete(.{ .int = 1 });
|
|
|
|
// Not yet committed: still visible outside the transaction.
|
|
{
|
|
var outside = try querymod.Table.open(&db, allocator, "users");
|
|
defer outside.deinit();
|
|
var stillThere = (try outside.get(.{ .int = 1 })).?;
|
|
stillThere.deinit();
|
|
}
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "users");
|
|
defer plain.deinit();
|
|
try std.testing.expect((try plain.get(.{ .int = 1 })) == null);
|
|
const warsaw = try plain.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } });
|
|
defer querymod.freeRows(allocator, warsaw);
|
|
try std.testing.expectEqual(@as(usize, 0), warsaw.len);
|
|
}
|
|
|
|
test "TableTxn rejects invalid table identifier" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
try std.testing.expectError(error.InvalidIdentifier, TableTxn.open(&txn, allocator, ""));
|
|
}
|
|
|
|
test "TableTxn read-your-writes sees staged rows before commit" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "ada" } }});
|
|
var seen = (try t.get(.{ .int = 1 })).?;
|
|
defer seen.deinit();
|
|
try std.testing.expectEqualStrings("ada", seen.get("name").?.text);
|
|
|
|
try t.delete(.{ .int = 1 });
|
|
try std.testing.expect((try t.get(.{ .int = 1 })) == null);
|
|
}
|
|
|
|
test "TableTxn put enforces UNIQUE constraint against committed data" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "users");
|
|
defer setup.deinit();
|
|
try setup.createUniqueIndex("email");
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@example.com" } }});
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
try std.testing.expect(t.hasUniqueIndex("email"));
|
|
|
|
try std.testing.expectError(error.UniqueViolation, t.put(.{ .int = 2 }, &.{
|
|
.{ .name = "email", .value = .{ .text = "a@example.com" } },
|
|
}));
|
|
|
|
// The rejected put staged nothing: committing now applies zero rows.
|
|
try txn.commit();
|
|
var plain = try querymod.Table.open(&db, allocator, "users");
|
|
defer plain.deinit();
|
|
try std.testing.expect((try plain.get(.{ .int = 2 })) == null);
|
|
}
|
|
|
|
test "TableTxn put UNIQUE check exempts a row keeping its own previous value" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "users");
|
|
defer setup.deinit();
|
|
try setup.createUniqueIndex("email");
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@example.com" } }});
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
|
|
// Updating an unrelated field on row 1 keeps its own email unchanged -
|
|
// must not spuriously conflict with itself.
|
|
try t.put(.{ .int = 1 }, &.{
|
|
.{ .name = "email", .value = .{ .text = "a@example.com" } },
|
|
.{ .name = "name", .value = .{ .text = "ada" } },
|
|
});
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "users");
|
|
defer plain.deinit();
|
|
var row = (try plain.get(.{ .int = 1 })).?;
|
|
defer row.deinit();
|
|
try std.testing.expectEqualStrings("ada", row.get("name").?.text);
|
|
}
|
|
|
|
test "TableTxn put enforces FOREIGN KEY against committed data and rejects a dangling reference" {
|
|
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 querymod.Table.open(&db, allocator, "customers");
|
|
defer customers.deinit();
|
|
try customers.put(.{ .int = 1 }, &.{});
|
|
|
|
var orders = try querymod.Table.open(&db, allocator, "orders");
|
|
defer orders.deinit();
|
|
try orders.createForeignKey("customer_id", "customers", pkField, .restrict);
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "orders");
|
|
defer t.deinit();
|
|
try std.testing.expect(t.hasForeignKey("customer_id"));
|
|
|
|
// customer 1 exists (committed) -> allowed.
|
|
try t.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }});
|
|
// customer 999 does not exist anywhere -> rejected.
|
|
try std.testing.expectError(error.ForeignKeyViolation, t.put(.{ .int = 101 }, &.{
|
|
.{ .name = "customer_id", .value = .{ .int = 999 } },
|
|
}));
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "orders");
|
|
defer plain.deinit();
|
|
var row = (try plain.get(.{ .int = 100 })).?;
|
|
defer row.deinit();
|
|
try std.testing.expect((try plain.get(.{ .int = 101 })) == null);
|
|
}
|
|
|
|
test "TableTxn put FOREIGN KEY on pkField sees a parent staged earlier in the same uncommitted transaction" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
// Self-referencing table (an org chart: manager_id -> employees.pk),
|
|
// installed while empty so no existing-row check gets in the way.
|
|
var employees = try querymod.Table.open(&db, allocator, "employees");
|
|
defer employees.deinit();
|
|
try employees.createForeignKey("manager_id", "employees", pkField, .restrict);
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "employees");
|
|
defer t.deinit();
|
|
|
|
// Neither row is committed yet. The manager (pk=1) is only staged in
|
|
// this same transaction's write set, but the FK check on the report
|
|
// (pk=2) must see it via `Transaction.get`'s read-your-writes, not
|
|
// just via the underlying Db.
|
|
try t.put(.{ .int = 1 }, &.{});
|
|
try t.put(.{ .int = 2 }, &.{.{ .name = "manager_id", .value = .{ .int = 1 } }});
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "employees");
|
|
defer plain.deinit();
|
|
var report = (try plain.get(.{ .int = 2 })).?;
|
|
defer report.deinit();
|
|
try std.testing.expectEqual(@as(i64, 1), report.get("manager_id").?.int);
|
|
}
|
|
|
|
test "TableTxn put FOREIGN KEY on a non-pk unique field sees a target staged earlier in the same uncommitted transaction" {
|
|
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 querymod.Table.open(&db, allocator, "products");
|
|
defer products.deinit();
|
|
try products.createUniqueIndex("sku");
|
|
|
|
var lineItems = try querymod.Table.open(&db, allocator, "line_items");
|
|
defer lineItems.deinit();
|
|
try lineItems.createForeignKey("product_sku", "products", "sku", .restrict);
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
|
|
// Stage a brand-new product in the same transaction as the line item
|
|
// that references it. `foreignKeyTargetExists`'s non-pk branch now
|
|
// goes through `Transaction.scanPrefix`, which sees the product's
|
|
// staged index-entry write even though neither row is committed yet -
|
|
// same read-your-writes guarantee the pkField branch above already had.
|
|
var products = try TableTxn.open(&txn, allocator, "products");
|
|
defer products.deinit();
|
|
try products.put(.{ .int = 1 }, &.{.{ .name = "sku", .value = .{ .text = "WIDGET-1" } }});
|
|
|
|
var lineItems = try TableTxn.open(&txn, allocator, "line_items");
|
|
defer lineItems.deinit();
|
|
try lineItems.put(.{ .int = 1 }, &.{
|
|
.{ .name = "product_sku", .value = .{ .text = "WIDGET-1" } },
|
|
});
|
|
|
|
// A dangling reference in the same transaction is still rejected.
|
|
try std.testing.expectError(error.ForeignKeyViolation, lineItems.put(.{ .int = 2 }, &.{
|
|
.{ .name = "product_sku", .value = .{ .text = "NO-SUCH-SKU" } },
|
|
}));
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "line_items");
|
|
defer plain.deinit();
|
|
var row = (try plain.get(.{ .int = 1 })).?;
|
|
defer row.deinit();
|
|
try std.testing.expectEqualStrings("WIDGET-1", row.get("product_sku").?.text);
|
|
}
|
|
|
|
test "TableTxn put UNIQUE constraint catches a conflict staged earlier in the same uncommitted transaction" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "users");
|
|
defer setup.deinit();
|
|
try setup.createUniqueIndex("email");
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "users");
|
|
defer t.deinit();
|
|
|
|
// First row claims the email; nothing committed yet.
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "email", .value = .{ .text = "a@example.com" } }});
|
|
|
|
// A second, different row in the SAME still-open transaction tries to
|
|
// claim the same email. Before `Transaction.scanPrefix`, the UNIQUE
|
|
// check ran `idx.scanEq` straight against `Db` and never saw the first
|
|
// row's staged index entry, so this put would have wrongly succeeded
|
|
// and only failed later as a generic OCC conflict at commit (or not at
|
|
// all, since the two rows touch disjoint keys and would not conflict).
|
|
try std.testing.expectError(error.UniqueViolation, t.put(.{ .int = 2 }, &.{
|
|
.{ .name = "email", .value = .{ .text = "a@example.com" } },
|
|
}));
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "users");
|
|
defer plain.deinit();
|
|
try std.testing.expect((try plain.get(.{ .int = 2 })) == null);
|
|
var row1 = (try plain.get(.{ .int = 1 })).?;
|
|
defer row1.deinit();
|
|
try std.testing.expectEqualStrings("a@example.com", row1.get("email").?.text);
|
|
}
|
|
|
|
test "TableTxn put enforces a CHECK constraint installed through query.zig's Table" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "orders");
|
|
defer setup.deinit();
|
|
try setup.createCheckConstraint("qty_positive", "qty gt i:0");
|
|
}
|
|
|
|
var txn = Transaction.begin(&db);
|
|
defer txn.deinit();
|
|
var t = try TableTxn.open(&txn, allocator, "orders");
|
|
defer t.deinit();
|
|
try std.testing.expect(t.hasCheckConstraint("qty_positive"));
|
|
|
|
try t.put(.{ .int = 1 }, &.{.{ .name = "qty", .value = .{ .int = 5 } }});
|
|
try std.testing.expectError(error.CheckViolation, t.put(.{ .int = 2 }, &.{
|
|
.{ .name = "qty", .value = .{ .int = 0 } },
|
|
}));
|
|
|
|
try txn.commit();
|
|
|
|
var plain = try querymod.Table.open(&db, allocator, "orders");
|
|
defer plain.deinit();
|
|
var row1 = (try plain.get(.{ .int = 1 })).?;
|
|
defer row1.deinit();
|
|
try std.testing.expect((try plain.get(.{ .int = 2 })) == null);
|
|
}
|
|
|
|
test "TableTxn `txn <script>` shape: a CHECK violation on the second put leaves the whole transaction uncommitted" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "orders");
|
|
defer setup.deinit();
|
|
try setup.createCheckConstraint("qty_positive", "qty gt i:0");
|
|
}
|
|
|