From b20d2b95ceef0bcdec7108b10d18a83d11ab8910 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 20:09:28 +0200 Subject: [PATCH] feat: add createCheckConstraint and dropCheckConstraint to query --- src/query.zig | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/query.zig b/src/query.zig index 6d684b1..0d7d6bf 100644 --- a/src/query.zig +++ b/src/query.zig @@ -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); + } + } + } +