feat: add createCheckConstraint and dropCheckConstraint to query

This commit is contained in:
dualfroz committed 2026-09-08 20:09:28 +02:00
1 parent 7de513e158
commit b20d2b95ce
1 file changed
+159
+159
View File
@@ -476,3 +476,162 @@ pub const Table = struct {
return self.checks.has(name);
}
/// Installs a named CHECK constraint - SQL's `CHECK (source)` - built on
/// the exact same predicate language `table select`/`table join` already
/// use (`expr.zig`'s recursive-descent parser over `Predicate`). `source`
/// is parsed once up front so a typo is rejected before anything is
/// persisted (`error.InvalidCheckExpression` wraps any `expr.ParseError`,
/// since a caller that only sees this constraint's name/source has no use
/// for which specific parse error fired), then evaluated against every
/// existing row - the same "refuse before mutating, existing data always
/// wins" shape `createUniqueIndex`/`createForeignKey` use, so installing
/// a CHECK that current data already violates fails outright rather than
/// silently starting to enforce only from the next write on.
pub fn createCheckConstraint(self: *Table, name: []const u8, source: []const u8) !void {
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
if (self.checks.has(name)) return;
var parsed = exprmod.parse(self.allocator, source) catch return error.InvalidCheckExpression;
defer parsed.deinit();
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
for (rows) |*r| {
if (!parsed.predicate.evaluate(r)) return error.CheckViolation;
}
try self.checks.add(name, source);
try self.checks.save(self.db, self.name);
}
pub fn dropCheckConstraint(self: *Table, name: []const u8) !void {
if (!self.checks.remove(name)) return;
try self.checks.save(self.db, self.name);
}
/// Creates a multi-column secondary index named `name` over `fields`, in
/// order - order matters, since only a leading ("leftmost") prefix of it
/// can be used to narrow a query (see `findCompositeMatch`). Rows
/// missing any of the fields are skipped entirely, both here and when
/// staying in sync on `put`/`delete`, since a composite entry with a
/// missing column would not be a well-formed tuple.
pub fn createCompositeIndex(self: *Table, name: []const u8, fields: []const []const u8) !void {
if (!idx.validIdentifier(name)) return error.InvalidIdentifier;
if (fields.len < 2 or fields.len > maxCompositeFields) return error.InvalidCompositeIndex;
for (fields) |f| if (!idx.validIdentifier(f)) return error.InvalidIdentifier;
if (self.composite.has(name)) return;
const rows = try self.scanAll();
defer freeRows(self.allocator, rows);
var values: [maxCompositeFields]Value = undefined;
for (rows) |*r| {
const pk = r.get(pkField) orelse continue;
var complete = true;
for (fields, 0..) |f, i| {
values[i] = r.get(f) orelse {
complete = false;
break;
};
}
if (!complete) continue;
try idx.addCompositeEntry(self.db, self.allocator, self.name, name, values[0..fields.len], pk);
}
try self.composite.add(name, fields);
try self.composite.save(self.db, self.name);
}
pub fn dropCompositeIndex(self: *Table, name: []const u8) !void {
if (!self.composite.remove(name)) return;
try idx.dropAllCompositeEntries(self.db, self.allocator, self.name, name);
try self.composite.save(self.db, self.name);
}
pub fn hasCompositeIndex(self: *const Table, name: []const u8) bool {
return self.composite.has(name);
}
/// Fully replaces the row stored at `pk` with `fields`, syncing every
/// registered secondary index against whatever the row held before.
pub fn put(self: *Table, 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 and any
// index or row mutation - a row that fails a CHECK never gets far
// enough to matter whether it would also have violated UNIQUE, same
// "cheapest, purely-local check first" ordering a real database
// uses. Re-parsed from source on every put (no cached Predicate is
// kept on Table), the same cost `table select`/`table join` already
// pay for their own expr text on every call.
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 constraint check runs before any index or row mutation
// below, so a violation leaves everything untouched - the same
// all-or-nothing shape callers already get from a plain `put`
// either succeeding outright or failing outright. A field that
// did not change value from the row's own previous value is
// exempt (it already legitimately owns that value), which is why
// updating unrelated fields on a row that already holds a unique
// value never spuriously conflicts with itself.
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 owners = try idx.scanEq(self.db, self.allocator, self.name, field, newValue);
defer {
for (owners) |v| v.free(self.allocator);
self.allocator.free(owners);
}
for (owners) |ownerPk| {
if (!Value.eql(ownerPk, pk)) return error.UniqueViolation;
}
}
// FOREIGN KEY check, same "before any mutation" placement as UNIQUE
// above. Unlike UNIQUE, an unchanged value is NOT exempt: nothing in
// this codebase stops the referenced row from having been deleted
// out from under an already-valid reference since the last write
// (no catalog of dependent tables exists to block that delete - see
// `Table.createForeignKey`'s doc comment), so every put re-checks
// every constrained field's current target rather than trusting a
// value that merely did not change this time.
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 - must run before any
// index or row mutation below, same all-or-nothing placement as
// every other constraint check in this function.
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 idx.removeEntry(self.db, self.allocator, self.name, indexedField, oldValue.?, pk);
}
if (newValue) |nv| {
if (oldValue == null or !Value.eql(oldValue.?, nv)) {
try idx.addEntry(self.db, self.allocator, self.name, indexedField, nv, pk);
}
}
}