Stability: Internal — see Stability Tiers.
The real invariant Quereus needs in DEFAULT / CHECK / GENERATED clauses is not
"the source expression is deterministic" — it is "the captured artifact at the
vtab.update() frontier is fully resolved and replayable." That invariant is
satisfied by construction: defaults and stored generated columns are evaluated
per row before reaching the module, immediate row CHECKs fire at write time so
only passing rows reach vtab.update(), and deferred CHECKs evaluate once at
commit (their outcome decides commit-vs-rollback for the entire transaction,
so replay-via-module-layer cannot disagree with the commit outcome).
The prohibition on non-deterministic expressions in DDL is therefore a stricter-than-necessary proxy for the replay contract, not a correctness requirement. Quereus still defaults to strict rejection, with a single opt-in to lift the gate.
Deterministic does not imply synchronous. The validators below check
physical.deterministiconly. A scalar subquery over a table is deterministic within a statement, so it passes every gate — and its emitted evaluator returns aPromise. No runtime site may skip anawaiton the strength of having validated determinism; doing so writes the Promise object itself into the row. (This was a live defect in the UPDATE generated-column recompute; regression coverage lives intest/logic/41-generated-column-extras.sqllogic§ 6–8.)
| Option | Type | Default | Aliases |
|---|---|---|---|
nondeterministic_schema |
boolean | false |
allow_nondeterministic_schema_expressions |
Set programmatically or via PRAGMA:
pragma nondeterministic_schema = true;
pragma nondeterministic_schema;
-- → [{"name":"nondeterministic_schema","value":true}]db.setOption('nondeterministic_schema', true);When true, Quereus permits non-deterministic expressions in DEFAULT, CHECK,
and GENERATED ALWAYS AS clauses. Capture still happens at the resolved-row
frontier: the row stored in the table (and the literal SQL produced by
buildInsertStatement / buildUpdateStatement / buildDeleteStatement in
util/mutation-statement.ts) contains the concrete value the engine
evaluated for that row.
The option is not baked into any persisted schema; toggling it affects validation of subsequent DDL/DML only — already-created tables keep whatever expressions they were created with.
Rejected in Constraints and Defaults:
random(),randomblob()- Random value generationdate('now'),time('now'),datetime('now'),julianday('now')- Current time functions- User-defined functions marked as non-deterministic
- Any expression containing non-deterministic sub-expressions
- DML in expression position (
(insert/update/delete … returning …)inside a CHECK / DEFAULT / assertion expression). DML is non-deterministic via the side-effect axis — theDmlExecutorNodesetsdeterministic: false, which propagates through the AND-of-children physical-properties chain and is rejected by the determinism enforcer.
Allowed in Constraints and Defaults:
- Constant literals:
42,'hello',true - Deterministic built-in functions:
upper(),lower(),abs(),round() - Column references:
NEW.price,OLD.quantity - Mutation context variables:
context.timestamp,context.user_id - User-defined functions marked as deterministic (default)
Pass the value in via mutation context instead of calling the function directly:
-- ❌ REJECTED: Non-deterministic default
create table orders (
id integer primary key,
created_at text default datetime('now') -- ERROR
);
-- ✅ ACCEPTED: Use mutation context
create table orders (
id integer primary key,
created_at text default timestamp
) with context (
timestamp text
);
-- Pass the timestamp when inserting
insert into orders (id)
with context timestamp = datetime('now')
values (1);Determinism is tracked through the PhysicalProperties system:
interface PhysicalProperties {
deterministic: boolean; // Same inputs → same outputs
readonly: boolean; // No side effects
idempotent: boolean; // Safe to call multiple times
constant: boolean; // Directly produces constant result
}Propagation Rules:
- A function node marks
deterministic: falseunless the registry setsFunctionFlags.DETERMINISTIC - Properties propagate bottom-up through the expression tree
- Parent nodes inherit the most restrictive properties from children
User-Defined Functions:
// Non-deterministic UDF
db.createScalarFunction("my_random",
{ numArgs: 0, deterministic: false },
() => Math.random()
);
// Deterministic UDF (default)
db.createScalarFunction("my_upper",
{ numArgs: 1, deterministic: true }, // or omit (defaults to true)
(text) => String(text).toUpperCase()
);All determinism rejection sites described below are skipped when
nondeterministic_schema = true. The bind-parameter / column-reference
pre-walks remain active in both modes (those are scope checks, not
determinism checks).
CREATE TABLE:
- DEFAULT expressions are rejected if they reference bind parameters
(
?,:name) or a bare table column; both are detected via an AST pre-walk before expression building. Anew.<column>reference is the exception — it explicitly reads a sibling value the INSERT supplies, so it passes the pre-walk and its build/determinism check is deferred to INSERT time (no row scope at CREATE TABLE), alongside the deferrals for mutation-context identifiers and self-referencing subqueries. - DEFAULT expressions are then built and rejected if their physical
deterministicproperty is false (e.g.random()). - CHECK constraints are walked at DDL time: any function call is rejected
unless the registry marks it
DETERMINISTIC, and bind parameters (?,:name) are rejected too. Column references inside CHECK predicates are validated at INSERT/UPDATE time, when the row scope exists.
ALTER TABLE … ALTER COLUMN … SET DEFAULT routes the new default through the
same validator (SchemaManager.validateAlterColumnDefault): bind
parameters / bare columns / non-determinism are rejected at ALTER time, and a
new.<column> default is accepted with the build/determinism check deferred to
INSERT time. ALTER TABLE ADD COLUMN routes its default through the same shared
validator (SchemaManager.validateAddColumnDefault, at plan-build time), so a
non-foldable but deterministic default (including new.<column>) is accepted.
A literal / NULL default is bulk-written to every existing row by the module's
addColumn (the fast path). A non-foldable default is backfilled per existing
row: the planner compiles it against the table's existing columns as the
"supplied" row (the same buildRowDefaultScope the single-source INSERT and
view-write key default use) and hangs the scalar on the AlterTableNode; the
emitter installs a row slot over each existing row and passes a per-row evaluator
to module.alterTable, so new.<column> resolves to the existing row's sibling.
Each module applies the evaluator while appending the column, staging locally
(memory builds a new tree, the store accumulates a batch) and publishing only after
every row migrates, and enforces the column's NOT NULL on the produced value before
commit.
ALTER TABLE ADD COLUMN … GENERATED ALWAYS AS (…) takes that same per-row route:
buildAddColumnBackfill sources its scalar from the generated clause when there is
one, so a generated column added to a populated table is computed for the existing
rows. Three differences from the DEFAULT arm, all of them forced by a generated column
having no stored defaultValue:
- It is never folded to a bulk-written literal — even
generated always as (2)is evaluated per row, since there is nodefaultValuefor the module to write. - Determinism is checked with
validateDeterministicGenerated(message: GENERATED ALWAYS AS for column …) rather than the DEFAULT validator, and the sharedvalidateAddColumnDefaultpre-check is skipped — it rejects bare column references, which a generated expression is written with by definition. - Unresolvable and self-referencing names are pre-flighted by
validateAddColumnGeneratedRefs(schema/table.ts) before the compile, so they get the same two messagesCREATE TABLEgives (Column 'x' referenced by generated column …, Cyclic dependency in generated columns …) instead of a genericColumn not found. The emitter's ownwithGeneratedColumnGraphrebuild still runs, so the pre-flight only moves those rejections earlier.
An evaluator's presence — not the kind of DEFAULT written — is what each module's "NOT NULL needs a value source" gate keys on, so a mandatory generated column is accepted on a non-empty table and filled per row.
CHECK enforcement splits by value-source kind:
- Literal / NULL default — new CHECK constraints are validated against the
backfilled rows by a post-
alterTablescan, reverting the column add on a violation. - Per-row source (non-foldable default or
GENERATED ALWAYS AS) — each column-level CHECK is compiled at plan-build time (against the existing columns plus the new column) and evaluated inside the per-row backfill hook against[...existingRow, backfilledValue], mirroring the per-row NOT NULL path. A violating row throws mid-loop, so the module's staged tree/batch is discarded before publication and the catalog is never mutated — no separate revert needed. The post-scan is skipped here (it would read a stale pre-backfill snapshot). Truthiness matches write-time CHECK semantics (fails onfalse/0, passes on truthy/NULL), and the new column's declared collation is carried into the predicate so comparisons resolve the same collation as at write time.
Where the inline constraints end up. All three kinds declarable inline on the
added column — UNIQUE, CHECK, FOREIGN KEY — are synthesized into the equivalent
table-level AST.TableConstraint over the new column (the three
extractColumnLevel* helpers in schema/constraint-builder.ts) and handed to
module.alterTable({ type: 'addConstraint', constraint }), one call each, in the
order UNIQUE → CHECK → FK. That is the same path ALTER TABLE ADD CONSTRAINT
takes, so the module owns the constraint exactly as it owns one written in CREATE TABLE. Ownership is what makes it durable: every later structural ALTER (DROP COLUMN, RENAME COLUMN, …) asks the module for the new table schema and installs
that answer in the catalog verbatim, so a constraint merged only into the engine's
catalog copy is dropped on the floor by the next one — silently, with bad data
accepted afterwards. Consequences of routing through the module:
- Existing-row validation is the module's (
addConstraintre-validates for UNIQUE and FK; CHECK is a schema-only append there, which is why the engine keeps the literal-default CHECK scan above). The memory and store modules both call the sharedvalidateForeignKeyOverExistingRows, so the ADD COLUMN and ADD CONSTRAINT paths cannot drift. FK validation is MATCH SIMPLE (a fully-non-NULL backfilled value with no matching parent aborts; NULL satisfies) and pragma-gated (pragma foreign_keys = falseskips the scan and defers enforcement to later writes). It is correct for a self-referential FK and for the parent-absent case, both of which make every fully-non-NULL backfilled row an orphan. - The engine still rejects a conflicting child/parent collation on the FK itself,
before the module call, mirroring
runAddConstraintViaModule— a pure schema check, so a rejected ALTER never reaches the module's persistence side effects. - An unnamed constraint is auto-named by the same convention the
CREATE TABLEspelling uses:_check_<column>,_fk_<table>_<column>. (The CHECK name is set by the extractor, because the module's table-levelADD CONSTRAINTconvention would otherwise name itcheck_<n>.) - Any failure from the materialization onward goes through
revertAddColumn: each CHECK / FK the module already accepted is handed back viadropConstraint(newest first), then the column is dropped, the batched events are un-remapped, and the original catalog entry is restored — so a violation leaves the table exactly as it was. An inline UNIQUE needs no explicit hand-back; both modules prune a UNIQUE over a dropped column.
Why validation runs against a column-only schema. The optimizer trusts a DECLARED constraint as a proven invariant, which makes each existing-row validator fold away its own work if the new constraint is already live during the pass:
- The FK validator issues a
NOT EXISTScorrelated subquery (the same formADD CONSTRAINTuses). The decorrelator may materialize it as an anti-join, whichruleAntiJoinFkEmptyfolds toEmptyRelationunder the inclusion dependencychild.fk ⊆ parent.pk.- The literal-default CHECK scan issues
select 1 from <t> where not (<check>). A declared CHECK<p>seeds a domain constraint on the scan, soruleFilterContradictionfoldswhere not (<p>)toEmptyRelation(the domain<p>and predicatenot <p>are jointly unsatisfiable).Either fold makes validation trust the very invariant it is checking and silently admit a violating row. So ADD COLUMN registers the new column with only the pre-existing (already-proven) constraints — a
columnOnlySchemaomitting every new constraint — live for the whole validation window; each module holds its new constraint in its own cached schema until that constraint's validation passes, and the catalog learns of them only from the final schema published after the last one lands. The live schema the planner reads during validation therefore declares nothing new to fold against, so the validators read the freshly-backfilled column directly and surface real violations. This mirrorsADD CONSTRAINT, which likewise validates before swapping the constraint into the live schema.
INSERT/UPDATE:
- DEFAULT expressions validated when building row expansion
- CHECK constraints validated when building constraint checks (full column-scope resolution happens here)
GENERATED ALWAYS ASexpressions validated when building the generated column projection (INSERT), assignment chain (UPDATE), or ADD COLUMN backfill (ALTER TABLE ADD COLUMN, at plan-build — see above)
ALTER TABLE ADD CONSTRAINT:
- Validation deferred to first INSERT/UPDATE (constraints may reference NEW/OLD)
- Runtime Documentation - Execution model and context system
- Module Authoring Guide - Module-side mutation contract
- Transactions - Commit, rollback, and the replay contract