Stability: Stable — see Stability Tiers.
Part of the Quereus SQL Reference — see Topic documents for the full map.
Stability: Beta — see Stability Tiers.
Quereus keeps traditional DDL fully intact. Declarative schema is an optional alternative for describing the desired end‑state in a single, order‑independent block. Modules continue to use DDL‑based interfaces; declarative workflows operate entirely in the engine and produce DDL.
Concepts:
- Schema: Named logical grouping of objects; may span multiple modules.
- Catalog: The set of objects owned by a module; may span multiple schemas.
- Diff: JSON representation of changes needed to align actual state with declared schema.
- Apply: Automatic execution of migration DDL statements.
Key Statements:
declare schema– Describes desired end‑state and stores declaration with optional seed data.diff schema– Compares declared schema with current state and returns JSON diff.apply schema– Executes the generated migration DDL, optionally applying seed data.explain schema– Returns the schema content hash for versioning.
declare schema schema_name
[version 'major.minor.patch']
[using (default_vtab_module = 'memory', default_vtab_args = '{}')]
{
-- Tables: use {...} or (...) for column definitions
table users {
id integer primary key,
email text not null unique,
name text not null,
created_at text not null default (datetime('now'))
}
-- Or with explicit USING clause
table sessions (
id text primary key,
user_id integer not null,
expires_at integer
) using memory;
table roles {
id integer primary key,
name text not null unique
}
table user_roles (
user_id integer not null,
role_id integer not null,
constraint pk_user_roles primary key (user_id, role_id),
constraint fk_user foreign key (user_id) references users(id),
constraint fk_role foreign key (role_id) references roles(id)
);
-- Indexes (optional `unique`, optional partial `where`, optional `with tags`)
index users_email on users(email);
unique index users_active_email on users(email) where created_at is not null;
-- Views
view v_user_roles as
select u.id as user_id, u.email, r.name as role
from users u join user_roles ur on u.id = ur.user_id
join roles r on ur.role_id = r.id;
-- Seed data: ( (row1_values), (row2_values), ... )
seed roles (
(1, 'admin'),
(2, 'viewer')
)
-- Or with explicit column names
seed users values (id, email, name) values
(1, 'admin@example.com', 'Admin'),
(2, 'viewer@example.com', 'Viewer');
-- Assertions: enforced at commit time
assertion positive_balance check (not exists (select 1 from users where balance < 0))
-- Future: domains, collations, and imports
-- domain email_address as text check (like(value, '%@%'));
-- collation nocase = nocase();
-- import schema auth from 'https://example.com/auth-schema.sql' cache 'auth@1' version '^2';
}Items in a declaration block need no separator, so the leading keyword of an item
sits exactly where the previous item's body would accept a bare (no-as) alias.
To keep the boundary unambiguous, an item keyword — table, index, unique,
materialized, view, seed, assertion, domain, collation, import —
cannot be used as a bare alias at a position where an item could begin:
declare schema main {
view v1 as select id from t1 materialized -- `materialized` is NOT an alias here
materialized view m2 as select id from t1
}If you want one of those words as an alias inside a declaration block, write it
explicitly (from t1 as materialized), quote it (from t1 "materialized"), or
separate the items with ;. Outside a declaration block nothing is reserved:
select a from t1 materialized still aliases t1 as materialized.
The restriction applies only where an item could actually begin — the item body's
top level. Inside an open ( (a subquery source, a scalar subquery) a bare alias
is unaffected:
declare schema main {
view v1 as select id from t1 where exists (select 1 from t2 materialized)
} -- ^ still an aliasAn item kind the parser doesn't model yet (domain, collation, import) is
skipped up to the next ;, the closing }, or the next item keyword — separate
those items with ; when their body could itself contain an item keyword.
-- Get migration DDL as result rows (one DDL statement per row)
diff schema main;
-- Returns rows like:
-- {"ddl": "create table users (...)"}
-- {"ddl": "drop table old_table"}
-- Returns no rows if schema is already aligned
-- Execute DDL yourself with custom migration logic
-- TypeScript example:
-- for await (const {ddl} of db.eval('diff schema main')) {
-- console.log('Executing:', ddl);
-- await db.exec(ddl);
-- // Insert custom backfill/transform logic here
-- }
-- Or use apply to execute automatically (no result rows)
apply schema main;
-- Apply with seed data (clears and repopulates)
apply schema main with seed;
-- Get schema hash for versioning
explain schema main;
-- Returns: {"info": "hash:a1b2c3d4e5f6"}
-- Future: versioned apply with options
apply schema main to version '1.0.0' options (
dry_run = false,
validate_only = false,
allow_destructive = false,
rename_policy = 'require-hint'
);Order Independence:
- Tables, indexes, and views can be declared in any order within the
{...}block. - Forward references are allowed (e.g., foreign keys to tables declared later).
Each declared name appears once. A repeated name used to last-writer-wins silently, so the first declaration never reached the migration. diff schema / apply schema now reject it up front, naming the object:
table,view, andmaterialized viewshare one namespace — the engine enforces it imperatively too (create viewrefuses a name a table holds, and vice versa), so a cross-kind clash could only ever half-apply and then fail mid-migration. Both the same-kind and the cross-kind case are errors:-- error: Table 't1' is declared more than once in schema 'main' -- error: 'dual' is declared as both a table and a view in schema 'main'
indexhas its own namespace (unique per schema — see §6.3), as doesassertion. An index or assertion may share a table's name; only a duplicate within its own namespace is rejected.- A repeated
seedblock for one table is rejected when the declaration is stored, not at diff time — two blocks for one table have no defined meaning, and the second used to discard the first's rows:-- error: Seed data for table 't1' is declared more than once in schema 'main' - Names compare case-insensitively, so
table T1andtable t1collide.
Flexible Syntax:
- Column definitions accept brace syntax
{...}or traditional parentheses(...). - Identifiers are only quoted when they are reserved keywords or contain special characters.
Schema Diffing:
- Compares the declared schema against the current database catalog.
- Generates a JSON diff showing tables/views/indexes to create, drop, or alter.
- Produces canonical DDL statements for all required changes.
Migration Application:
apply schemaexecutes the migration DDL automatically.- Migrations are applied in safe order: drops first, then creates, then alters.
- Seed data application:
with seedclears existing data and inserts declared seed rows.
Versioning and Hashing:
- Schema declarations can include semantic versions.
explain schemacomputes a SHA-256 hash of the canonical schema representation.- Enables tracking schema changes and ensuring consistency across environments.
Safety:
-
Seed data application is destructive (clears table before inserting).
-
allow_destructiveis enforced for one case today: a backing-module change on a maintained table (materialized view … using <module>orcreate table … maintained as … using <module>). Such a move physically relocates the table to a different store with no in-place primitive, so it is realized as aDROP TABLE+create materialized view … using <newmodule>that mints a new incarnation (changing row identity for a replicated/synced table).apply schemaaborts — before any DDL runs — unless re-run withoptions (allow_destructive = true):-- Re-declared with a moved backing module; refused without the ack: apply schema main; -- Error: backing-module change on maintained table(s) 'mv' is destructive -- (drop + recreate, new incarnation). Re-run with options -- (allow_destructive = true) to migrate the backing. -- Acknowledged — drops + recreates, re-materializing the body into the new module: apply schema main options (allow_destructive = true);
diff schemasurfaces theDROP TABLE/create materialized viewDDL unconditionally (it is a read-only preview, never gated). Other drops are not yet gated — a generalallow_destructivegate over all destructive schema changes remains future work. -
Rename hints prevent accidental drops during renames — see "Rename detection" below.
Rename detection (rename_policy):
apply schema understands rename hints carried via the reserved quereus.id and quereus.previous_name tags (see §2.6.3). The rename_policy option in OPTIONS (...) controls how strictly the differ behaves when names change:
| Value | Behavior |
|---|---|
'allow' (default) |
Use hints when present; without hints, fall through to drop+create. |
'require-hint' |
Reject any name change that lacks a hint — if drops and creates of the same kind both remain after rename matching, error rather than executing destructive DDL. |
'deny' |
Ignore hints entirely. Any name mismatch becomes drop+create. Escape hatch for opting back into the legacy behavior. |
A rename detected via quereus.id is authoritative: when both id and previous_name would resolve, id wins. A conflict — declared name and the hint resolving to two distinct existing actuals — is always an error regardless of policy.
Renames apply to tables, views, indexes, named constraints (CHECK / UNIQUE / FOREIGN KEY, either table-level CONSTRAINT <name> ... or a column-level constraint carrying a name), and columns. Tables and columns rename via the ALTER TABLE ... RENAME / RENAME COLUMN primitives, which propagate references through dependent CHECK expressions, FK targets, partial-index predicates, column DEFAULT / GENERATED ALWAYS AS expressions, view bodies, materialized-view bodies, and assertion CHECK expressions. Named constraints rename via the ALTER TABLE ... RENAME CONSTRAINT primitive. The differ also detects constraint drops (a named constraint present in the catalog but absent from the declaration → DROP CONSTRAINT) and adds (a declared named constraint absent from the catalog → ADD CONSTRAINT; CHECK applies in place, UNIQUE / FK adds depend on module support — see §2.7). Only user-named constraints participate — engine-synthesized names (the _check_* / _fk_* / _uc_* auto-names for unnamed constraints) and UNIQUE constraints derived from a CREATE UNIQUE INDEX are excluded (the latter are managed through their index). View and index renames still fall back to drop+recreate when no rename primitive exists.
Notes:
- Keywords
schema,version, andseedare contextual and don't conflict with column names or function calls likeschema(). - DDL remains the primary interface; declarative schema is a convenience layer that generates DDL.
- Modules are unaware of declarative schemas; they receive standard DDL commands.
The create table statement defines a new table structure. Note that all tables are "without rowid" implicitly.
Syntax:
create table [if not exists] table_name (
column_definition [, column_definition...]
[, table_constraint...]
)
[using module_name [(module_args...)]]
[with tags (key = value [, ...])]Column Definition:
column_name [data_type] [column_constraint...] [with tags (key = value [, ...])]Column Constraints:
[constraint name]
{ primary key [asc | desc] [conflict_clause] [autoincrement]
| not null [conflict_clause]
| unique [conflict_clause]
| check [on {insert | update | delete}[,...]] (expr)
| default value
| collate collation_name
| references foreign_table [(column[,...])] [ref_actions]
| generated always as (expr) [stored | virtual] }
[with tags (key = value [, ...])]Table Constraints:
[constraint name]
{ primary key ([column [asc | desc][,...]]) [conflict_clause]
| unique (column[,...]) [conflict_clause]
| check [on {insert | update | delete}[,...]] (expr)
| foreign key (column[,...]) references foreign_table [(column[,...])] [ref_actions] }
[with tags (key = value [, ...])]Conflict Clause:
on conflict { rollback | abort | fail | ignore | replace }Options:
- If an empty key column list is provided, the table may have 0 or 1 rows.
if not exists: Creates the table only if it doesn't already existcolumn_definition: Defines a column with optional constraintstable_constraint: Defines a table-level constraintusing module_name: Specifies a virtual table module
Examples:
-- Basic table with constraints
create table employees (
id integer primary key,
name text not null,
email text unique collate nocase,
department text default 'General',
salary real check (salary >= 0),
hire_date text,
manager_id integer references employees(id)
);
-- Table with composite key and multiple constraints
create table order_items (
order_id integer,
product_id integer,
quantity integer not null check on insert (quantity > 0),
price real not null check (price >= 0),
discount real default 0 check (discount >= 0 and discount <= 1),
primary key (order_id, product_id),
foreign key (order_id) references orders(id),
foreign key (product_id) references products(id)
);
-- Memory-backed virtual table
create table cache (
key text primary key,
value blob,
expires_at integer
) using memory;
-- Table with generated (computed) columns
create table products (
id integer primary key,
base_price integer not null,
tax_rate real not null default 0.1,
total_price real generated always as (base_price * (1 + tax_rate)) stored,
label text generated always as ('Product #' || id) virtual
);Generated Columns:
Generated columns are computed from an expression over other columns in the same row:
STORED: The value is computed at INSERT/UPDATE time and persisted. Reads return the stored value directly.VIRTUAL: Semantically computed on read (currently stored identically to STORED; storage optimization is planned).- If neither
STOREDnorVIRTUALis specified,VIRTUALis the default. - Generated column expressions must be deterministic. They may reference any column of the same table, including other generated columns; their dependency graph must be acyclic and self-references are rejected at
CREATE TABLE/ALTER TABLE ADD COLUMNtime. - Cannot have both
DEFAULTandGENERATED ALWAYS ASon the same column. - Cannot INSERT into or UPDATE a generated column directly.
ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS (...)backfills the existing rows: each one's value is computed from that row, exactly asCREATE TABLEwith the same declaration and a subsequent INSERT would produce it. This holds for theSTORED,VIRTUALand unspecified spellings alike, and for an expression that reads another generated column (already materialized in the stored row). Determinism is checked atALTERtime, before any row is touched —ADD COLUMN g INTEGER GENERATED ALWAYS AS (random())is rejected there rather than at the next INSERT (unlessnondeterministic_schemais on). If the new column isNOT NULLand the expression yields NULL for some existing row, or an inlineCHECKon it fails for some existing row, the wholeALTERis rejected and the table is left exactly as it was.ALTER TABLE ... DROP COLUMNof a column referenced by another generated column's expression is rejected; drop the referencing generated column first. (A column that a foreign key in another table points at is rejected the same way — see §7.6.)
CHECK Constraints:
check (expr)is enforced on INSERT and UPDATE by default;check on {insert | update | delete}[,...]restricts the operations. Unqualified columns name the NEW row (the OLD row for DELETE-only checks);old.<col>/new.<col>reference either row image explicitly. Both spellings are equivalent as far as ALTER goes:RENAME COLUMNrewritesnew.<col>/old.<col>references along with the unqualified ones, andDROP COLUMNrefuses over them (see below).newandoldare not reserved words, so a CHECK subquery reading a real table named"new"is left alone by both.- Comparisons inside a CHECK resolve declared column collations (and explicit
COLLATEwrappers), exactly like the same expression in a query —check (c = 'abc')over atext collate nocasecolumn accepts any case-variant. Resolution follows the engine's symmetric provenance lattice (explicitCOLLATE> declared column collation > defaulted collation > BINARY; seedocs/types.md§ Comparison collation resolution), socheck (b = c)andcheck (c = b)behave identically. - A CHECK containing a subquery is automatically deferred to transaction commit; the deferred evaluation runs the same compiled predicate, so collation semantics are identical to the immediate path.
ALTER TABLE ... DROP COLUMNof a column a CHECK expression names is rejected — there is no narrowed form of an arbitrary expression, only deleting it silently or refusing. Drop the constraint first (ALTER TABLE ... DROP CONSTRAINT <name>); an unnamed table-level CHECK has no name to address, so a column it names can only be removed by rebuilding the table. The same holds for a live assertion whose CHECK body names the column (DROP ASSERTION <name>first) — there the refusal also prevents one broken body from blocking writes to every table in the database. Detection is scope-aware, so a like-named column reached only through a subquery over another table does not block the drop. See sql-alter.md § 2.7 under DROP COLUMN for the structural-vs-expression rule these restrictions come from.
Default Values:
A column DEFAULT supplies the value when an INSERT omits the column; an explicitly supplied value always wins. The expression must be deterministic and may not reference bind parameters.
- A default may read a sibling the INSERT supplies via
new.<column>— e.g.slug text default (lower(new.title))ortotal integer default (new.subtotal + tax). Only INSERT-supplied columns are visible, so a default never depends on another column's default (which would impose an evaluation-order race); referencing an omitted column raises a resolution error. The samenew.<column>surface also resolves at the shared-key view-write envelope (an anchor key default reading a supplied sibling — see vu-mutation-context.md § Mutation context). - A bare (unqualified) column reference is rejected at
CREATE TABLE— usenew.<column>to read a supplied value, orGENERATED ALWAYS ASto compute from any sibling. (With awith context (...)clause an unqualified identifier may instead resolve to a mutation-context variable.) - A
new.<column>default survives ALTER exactly as a CHECK's does:RENAME COLUMNrewrites the reference (so the table keeps accepting rows), andDROP COLUMNrefuses over it, naming the column whose default is in the way.newis not a reserved word, so a default whose subquery reads a real table named"new"is left alone by both. Both spellings of the ALTER — the inlinedefault (…)andALTER COLUMN … SET DEFAULT— write the same catalog field and behave identically here. mutation_ordinal()(the 1-based per-row ordinal) and mutation-context variables are also available in default position. See vu-mutation-context.md § Mutation context.ALTER TABLE … ALTER COLUMN … SET DEFAULTroutes the new default through the same validatorCREATE TABLEuses: bind parameters / bare columns / non-deterministic expressions are rejected atALTERtime, and anew.<column>default is accepted (its build is deferred to INSERT time, exactly as onCREATE TABLE).DROP DEFAULTclears the default.ALTER TABLE … ADD COLUMN … DEFAULT (…)accepts the same default expressions (the shared validator rejects bind parameters / bare columns / non-determinism). Existing rows are backfilled per row:new.<column>resolves to the existing row's sibling (e.g.add column doubled integer default (new.base * 2)sets each existing row'sdoubledfrom its ownbase), while a literal default is bulk-written. Future inserts derive the column from the INSERT-supplied sibling, so an insert that omits that sibling raises the same resolution error as the single-source path. AnADD COLUMN NOT NULLwhose per-row backfill yields NULL for any existing row is rejected and the column is not added. DEFAULT is not the only backfilled kind:ADD COLUMN … GENERATED ALWAYS AS (…)takes the same per-row route (see Generated Columns), except that it never bulk-writes — even a constant generated expression is evaluated per row, since a generated column has no DEFAULT for the module to write.
ADD COLUMN over a non-empty table needs a value for the rows that already exist.
ALTER TABLE … ADD COLUMN is rejected — before any schema or data is touched — when the table already holds rows, the new column is NOT NULL, and nothing supplies a value for those rows. Three points are easy to trip over:
- NOT NULL is resolved, not read off the statement text. A column is NOT NULL either because it says
not nullor because the session optiondefault_column_nullability(see usage.md § Database Options) makes it so; that option ships asnot_null, soadd column extra textandadd column extra text default nullare both mandatory columns unless you writenullexplicitly. - A DEFAULT that is literally NULL supplies no value.
default null(anddefault (null)) counts as "no DEFAULT" for this rule, because filling the existing rows with NULL is exactly what the column forbids. Writeadd column extra text null default nullif you want the column to be optional. - A per-row source does count. A non-foldable expression default (
default (new.other)) or agenerated always as (…)expression fills each existing row from that row; the ALTER proceeds, and NOT NULL is then enforced against each computed value (a row yielding NULL rejects the whole ALTER).
On an empty table any of these is accepted — there is nothing to backfill — and NOT NULL enforces from the first write, matching SQLite. Both shipped storage modules (memory and the persistent store) give the same answer for the same statement. A storage module that carries pre-existing rows forward and enforces NOT NULL at write time can opt out of this engine-level rejection with the delegatesNotNullBackfill capability, in which case the module decides.
Quereus supports database-wide integrity assertions evaluated at COMMIT time.
Syntax:
create assertion [schema_name.]assertion_name check (condition_expression);
drop assertion [if exists] [schema_name.]assertion_name;Behavior:
- An assertion belongs to a schema. An unqualified name means the current schema, matching every other DDL statement; names are unique per schema, so two schemas may each hold an assertion of the same name and both enforce independently.
- The stored CHECK body resolves its unqualified table names against the assertion's own schema first, independent of the session's search path — the same home-schema rule stored view and materialized-view bodies follow (see
sql-select.md§ 2.1.1). - The CHECK body is validated at create time, at exactly the strictness a
CREATE VIEWbody is validated: the derived violation SQL is planned under the assertion's home-schema path, so a body naming a missing table, a missing column, or an unknown function fails theCREATE ASSERTION(Cannot create assertion 'a1': Table 't' not found …). Anything the planner accepts for a view body is accepted here too — a type mismatch such asx + 'abc'is not a planner error and still creates. This matters because enforcement recompiles every live assertion on any commit that touched any table, so an assertion that cannot be planned would otherwise block writes to the whole database at some unrelated later statement. - For the same reason,
DROP TABLE/DROP VIEW/DROP MATERIALIZED VIEWis refused while a live assertion's body still names the object:cannot drop table 'main.t': assertion 'a1' still refers to it — drop or redefine the assertion first.IF EXISTSdoes not weaken this (it governs absence, not dependency). The refusal is a clean no-op;drop assertion a1then lets the drop through. "Refers to" is decided by the same walkALTER TABLE … RENAMEuses to rewrite dependent bodies, and is scoped to the dropped object's own schema — an assertion in another schema naming<schema>.<table>explicitly is not caught, the cross-schema gap views and materialized views already share. - Assertions are enforced at COMMIT. Any row produced by the stored violation query indicates a violation and the COMMIT fails with a constraint error (transaction rolled back). The error names the assertion, schema-qualified outside
main(Integrity assertion failed: sales.a1). - The
check (expr)is stored as a violation SQL:select 1 where not (expr). ALTER TABLE ... RENAME/RENAME COLUMNrewrites the stored body and regenerates that violation SQL, so the assertion keeps enforcing the same rule against the renamed object — the same in-place propagation view and materialized-view bodies get (see sql-alter.md § 2.7 for the scope limits, which are shared with views).- Efficiency: The optimizer classifies each table reference instance in the violation query as row-specific (unique key fully covered) or global. If any changed base is global, run the violation SQL once. Otherwise, for row-specific references, the engine executes per changed primary key using prepared parameters (
pk0,pk1, ... for composite keys), early-exiting on the first violation.
Diagnostics:
- Use
explain_assertion(name)to introspect classification and prepared parameterization. The argument may be'schema.name'(schema-scoped) or a bare name (first match across schemas). assertion_info()lists all assertions with theirschema_name.
Examples:
-- Global-style assertion (aggregate)
create table t2 (id integer primary key) using memory;
create assertion a_global check ((select count(*) from t2) = (select count(*) from t2));
select exists(
select 1 from explain_assertion('a_global') where classification = 'global'
) as ok;
-- Row-specific assertion: PK equality reduces to row-specific
create table t1 (id integer primary key) using memory;
create assertion a_row check (exists (select 1 from t1 where id = 1));
select prepared_pk_params from explain_assertion('a_row') where classification = 'row' limit 1;Quereus supports table-level mutation context variables that provide per-operation parameters for default values and constraints. The primary use case is implementing application-specific security, rights management, and audit mechanisms using signatures, digests, and cryptographic verification.
Syntax:
create table table_name (
column_definitions...
) using module_name
with context (
variable_name data_type [null],
...
)DML Syntax:
insert into table_name [(columns...)]
with context variable = expression, ...
values (...) | select_statement
update table_name
with context variable = expression, ...
set column = value ...
delete from table_name
with context variable = expression, ...
where conditionKey Features:
- Context variables are declared in the table definition alongside columns
- Variables default to NOT NULL unless explicitly marked NULL
- Both unqualified (
varName) and qualified (context.varName) references supported - Context variables can be used in DEFAULT expressions and CHECK constraints
- Context values are evaluated once per statement, not per row
- Context is captured for deferred constraints and evaluated at COMMIT time
Examples:
Multi-Tenant Data Isolation:
-- Enforce tenant isolation at database level
create table tenant_records (
id integer primary key,
tenant_id text,
data text,
constraint tenant_check check (new.tenant_id = context.current_tenant_id)
) using memory
with context (
current_tenant_id text
);
-- Insert restricted to current tenant
insert into tenant_records (id, tenant_id, data)
with context current_tenant_id = 'tenant_abc'
values (1, 'tenant_abc', 'Private data'); -- Passes
-- Attempt to insert for different tenant fails
insert into tenant_records (id, tenant_id, data)
with context current_tenant_id = 'tenant_abc'
values (2, 'tenant_xyz', 'Data'); -- Fails: tenant mismatchAudit Trail with Actor Tracking:
-- Audit log with actor identity
create table audit_log (
id integer primary key,
action text,
user_id text default actor_id,
timestamp text default datetime('now')
) using memory
with context (
actor_id text
);
-- Log action with actor identity
insert into audit_log (id, action)
with context actor_id = 'user123'
values (1, 'DELETE_RECORD');Permission Verification:
-- Prevent unauthorized modifications
create table user_profiles (
user_id integer primary key,
email text,
constraint update_auth check (
context.requester_id = old.user_id or context.is_admin = 1
)
) using memory
with context (
requester_id integer,
is_admin integer
);
-- User can update their own profile
update user_profiles
with context requester_id = 42, is_admin = 0
set email = 'newemail@example.com'
where user_id = 42; -- Passes: requester_id matchesBest Practices:
- Use mutation context for application-specific security and access control
- Implement signature verification, digest validation, and rights checking in constraints
- Store actor identity, timestamps, and cryptographic proofs in defaults
- Use qualified
context.varNamefor clarity when variable names might conflict - Mark optional context variables as NULL
- Combine with user-defined functions for custom verification logic
- Context is required when defaults or constraints reference context variables
Quereus supports arbitrary key-value metadata tags on schema objects via WITH TAGS. Tags are informational only -- the engine does not derive behavior from them. They do not affect schema hashing.
Syntax:
-- Table-level tags
create table Orders (
id integer primary key,
name text not null
) with tags (display_name = 'Customer Orders', audit = true);
-- Column-level tags
create table Products (
id integer primary key with tags (display_name = 'Product ID'),
name text not null with tags (searchable = true)
);
-- Constraint-level tags
create table Employees (
id integer primary key,
email text not null,
constraint uq_email unique (email) with tags (error_message = 'Email must be unique')
);
-- View and index tags
create view ActiveUsers as select * from Users where active = 1
with tags (cacheable = true);
create index idx_name on Products (name) with tags (purpose = 'search optimization');Tag values can be strings, numbers, booleans (true/false), or null. Tag keys are identifiers. TAGS is a contextual keyword and can still be used as a regular identifier. WITH TAGS can appear alongside WITH CONTEXT in any order.
Tags are available on the schema interfaces (TableSchema.tags, ColumnSchema.tags, etc.) and via the programmatic API (SchemaManager.getTableTags(), SchemaManager.setTableTags(), SchemaManager.setColumnTags(), SchemaManager.setConstraintTags()). Tags set at CREATE time can be changed later from SQL with ALTER TABLE … SET TAGS (whole-set replacement; see §2.7).
Reserved namespace quereus.*: keys whose name starts with quereus. are reserved for the engine and validated against a typed registry (src/schema/reserved-tags.ts). The two most common keys, both rename hints, are:
| Key | Used by | Effect |
|---|---|---|
"quereus.id" |
apply schema / diff schema |
Stable identifier — when a declared and actual object share the same quereus.id but have different names, the differ emits a rename instead of a drop+create. Authoritative; wins over previous_name. |
"quereus.previous_name" |
apply schema / diff schema |
One or more comma-separated old names. The differ matches a declared object whose name is missing in the catalog against an actual object whose name appears in this list. |
This is only a subset; other reserved keys include quereus.expose_implicit_index and the quereus.lens.* family (see the registry for the full set). An unrecognized or mis-sited quereus.* key is a hard error — rejected loudly at plan-build on every authoring path (CREATE TABLE / CREATE INDEX … WITH TAGS, ALTER … SET TAGS, statement-level DML WITH TAGS (INSERT/UPDATE/DELETE), and apply schema / diff schema) rather than silently stored — so a typo (quereus.idd) or a view-only key on a physical table fails the statement. Note that no reserved key is currently legal at the DML-statement site — the namespace there is purely a typo guard (since the quereus.update.* retirement, every quereus.* key on a DML statement is rejected, whether the statement targets a base table or a view). Tag keys with dots must use the quoted-identifier form ("quereus.id"). Non-reserved (free-form) keys outside the quereus.* namespace are accepted untouched.
Example — declaring a renamed table and column:
declare schema main {
table customer with tags (
"quereus.id" = 'tbl-customer',
"quereus.previous_name" = 'client'
) {
customer_id integer primary key with tags ("quereus.previous_name" = 'client_id'),
full_name text not null with tags ("quereus.previous_name" = 'name')
}
}Against an existing client(client_id, name), this diffs to ALTER TABLE client RENAME TO customer plus two ALTER TABLE customer RENAME COLUMN ... rather than dropping and recreating.
Moved to SQL Schema Modification — ALTER, with the tag verbs on views, materialized views, and indexes.
Virtual tables are Quereus's primary mechanism for accessing and manipulating data. They provide a table interface to various data sources through specialized modules.
Syntax:
create table [if not exists] table_name [(column_def[, ...])]
using module_name [(module_arguments...)]Examples:
-- Memory table with schema definition
create table users (
id integer primary key,
name text not null,
email text unique,
created_at text default (datetime('now'))
) using memory;
-- JSON table using the json_tree function
create table product_data
using json_tree('{"products":[{"id":1,"name":"Keyboard"},{"id":2,"name":"Mouse"}]}');
-- Create a memory table from a schema string
create table cache
using memory('create table x(key text primary key, value blob, expires integer)');Quereus comes with several built-in virtual table modules:
The memory module provides an in-memory, B+Tree-based storage with support for transactions, indices, and constraints.
Key features:
- Efficient in-memory storage
- Primary key and unique constraints
- Secondary index support via
create index - Transaction and savepoint support
Examples:
-- Create a memory table
create table products (
id integer primary key,
name text not null,
price real check (price >= 0),
category text
) using memory;
-- Create a secondary index
create index idx_products_category on products(category);
-- Insert data
insert into products (name, price, category)
values
('Laptop', 999.99, 'Electronics'),
('Desk Chair', 199.99, 'Furniture');
-- Query with index
select * from products where category = 'Electronics';Quereus provides two modules for working with JSON data:
json_each: Expands a JSON array into rows
-- Create table from JSON array
create table users using json_each('[
{"id":1,"name":"Alice","role":"admin"},
{"id":2,"name":"Bob","role":"user"}
]');
-- Query expanded JSON
select key, value from users where key = 'name';
-- Result: 'name', 'Alice' and 'name', 'Bob'json_tree: Expands a JSON structure recursively
-- Create and query a json_tree table
with json_data as (
select '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}' as json
)
select key, value, fullkey, path
from json_tree(
(select json from json_data)
)
where path like '$.users[%].name';
-- Results in rows with users' namesThe _schema module provides access to schema information:
-- Query schema information
select * from _schema;
-- Returns information about tables, indexes, and viewsVirtual tables that support indexing (like the memory module) can have indexes created using standard SQL syntax.
Syntax:
create [unique] index [if not exists] index_name
on table_name (indexed_column[, ...])Examples:
-- Simple index on a single column
create index idx_users_email on users(email);
-- Composite index on multiple columns
create index idx_orders_customer_date on orders(customer_id, order_date);
-- Unique index
create unique index idx_products_sku on products(sku);
-- Per-column COLLATE (and direction): the index orders/compares this column
-- under the given collation, overriding the table column's collation. A bare
-- `col COLLATE x` is a per-column collation, not an expression index; a genuine
-- expression operand (e.g. `lower(name)`) is still rejected.
create index idx_users_email_ci on users(email collate nocase desc);Index names are unique per schema, not per table. DROP INDEX, ALTER INDEX … TAGS, and sync's index-owner resolution all name an index without naming its table, so a duplicate name would resolve to whichever table happened to be registered first. create index therefore rejects a name already held by an index anywhere in the target schema, naming the existing owner:
create index idx_note on t1 (note);
create index idx_note on t2 (note);
-- error: Index 'idx_note' already exists in schema 'main' on table 't1'-
IF NOT EXISTSdoes not suppress a cross-table collision. It means "skip if this index already exists"; an index of that name on a different table is a different object, and skipping would silently leave the requested index absent from the target table. Only a same-name index on the same table is skipped. -
A UNIQUE constraint's implicit covering structure is not part of that namespace. The auto-built secondary structure backing a plain
UNIQUEconstraint takes the constraint's name (or_uc_<cols>when unnamed), and constraint names are unique per table, so two tables may each declareconstraint uq_email unique (email). Those implicit structures are skipped by the schema-wide check. -
…but the name is taken on the constraint's own table.
create index uq_email on b (email)wherebalready carries auq_emailUNIQUE constraint is rejected as a same-table duplicate —Index uq_email already exists on table b— andcreate index if not existsskips silently, exactly as for an ordinary same-table duplicate. This holds on every backend, including one that keeps the structure entirely internal and never reports it as an index (otherwise the new index and the constraint's structure would collide in the backend's own storage). The name is free again once the constraint is dropped. -
…and symmetrically, a UNIQUE constraint may not take a name an index on its table already holds. Declaring the constraint second is rejected the same way declaring the index second is:
create index foo on t (b); alter table t add constraint foo unique (a); -- error: Cannot add constraint 'foo' to table 't': its backing index 'foo' would collide -- with existing index 'foo' on the same table. Rename the constraint or the index.
This covers every path that declares or renames a UNIQUE constraint:
ALTER TABLE … ADD CONSTRAINT,ALTER TABLE … RENAME CONSTRAINT,ALTER TABLE … ADD COLUMN … unique(including the unnamed_uc_<column>auto-name),ALTER TABLE … RENAME COLUMN(an unnamed constraint's_uc_<cols>name is derived from the covered columns, so renaming one moves the name onto a new one — see the bullet below), and theALTER TABLE … ADD <constraint>statementsapply schema/declare schemaemit.CREATE TABLEcannot reach it — a table is created carrying no indexes. The check runs before the statement reaches the storage module, so it holds on every backend and a rejected statement persists nothing.RENAME COLUMNmoves an unnamed UNIQUE's structure name with the column._uc_<cols>is recomputed from the columns' current names every time it is needed and recorded nowhere, so afteralter table t rename column a to zthe constraint declaredunique (a)is backed by_uc_z. The structure follows silently — it stays hidden fromschema()/index_info()under the new name and keeps enforcing — unless_uc_zis already an index ont, in which case the rename is refused with the same message and the column keeps its old name. Rename or drop that index first. A named UNIQUE is unaffected: its structure takes the constraint's name, which no column rename touches.- Rejected even when the constraint's columns match the index's. The two structures would coincide physically, but accepting it silently reclassifies the user's declared index as a hidden backing structure: it vanishes from
schema()/index_info()and from the persisted schema, and stops being droppable. Reusing an existing index to back a constraint matches on columns, not names —constraint bar unique (a)over an indexfooon(a)is unaffected. - Only UNIQUE. CHECK and FOREIGN KEY constraints build no backing structure and are never rejected for this.
- A
create unique index-derived constraint is exempt.create unique index foo on t (a)deliberately synthesizes a UNIQUE constraint namedfoobeside indexfoo; that index is the user's own object, not an implicit covering structure. - NOTE:
declare schemadoes not pre-check this pairing. A declaration that names a UNIQUE constraint and an index the same on one table is accepted at declare time and only fails when applied — from the constraint side if the index is already there, from the index side against a fresh schema (Index foo already exists on table t). Such a declaration is never appliable either way, so nothing can slip through; if declare-time diagnostics become worth the plumbing, fold constraint names into the duplicate-name check described in the last bullet of this section.
-
DROP INDEXon an implicit covering structure raisesno such index. The structure's lifecycle belongs to its constraint, so it is not droppable by name — exposed viaquereus.expose_implicit_indexor not (exposure makes it addressable for tags, nothing more).DROP INDEX IF EXISTSon such a name is a no-op. Remove the structure withALTER TABLE … DROP CONSTRAINT <constraint>. Because implicit structures are outside the schema-wide namespace, the by-name lookup skips past them and keeps searching: with auq_emailconstraint onaand a realcreate index uq_email on c (email),drop index uq_emaildropsc's index and leavesaenforcing. -
schema()andindex_info()omit a hidden implicit covering structure, on every backend. Not being a user-addressable index (above), it is not listed as one — consistent withcollectSchemaCatalog(the declarative-schema differ's read path), which applies the same rule. An exposed one (quereus.expose_implicit_index = true) keeps appearing in both introspection functions, with its tags, on every backend. Acreate unique index-derived constraint is the user's own index and always appears. -
Rehydration warns instead of failing. Opening a database written before these rules that already contains a collision logs a warning and proceeds — naming both owning tables for a cross-table collision, or naming the constraint that holds the name for a same-table one. By-name resolution of that index stays first-match until one of them is renamed. (The same-table rule cannot be tripped by rehydration itself: the catalog's
CREATE TABLE— constraints included — is imported ahead of everyCREATE INDEX, so the table carries no indexes when its constraints are declared.) -
declare schemarejects duplicates up front. Twoindexdeclarations sharing a name (on any tables) are an error at diff time rather than a silently half-applied declaration — one case of the general rule that each declared name appears once (see §2.0 Declaration Syntax).
The primary key constraint uniquely identifies each record in a table.
Syntax - Column Constraint:
column_name data_type primary key [asc|desc] [conflict_clause] [autoincrement]Syntax - Table Constraint:
primary key (column[, ...]) [conflict_clause]Examples:
-- Single-column primary key
create table users (
id integer primary key autoincrement,
username text not null
);
-- Composite primary key (table constraint)
create table order_items (
order_id integer,
product_id integer,
quantity integer not null,
primary key (order_id, product_id)
);
-- Primary key with descending order
create table logs (
timestamp integer primary key desc,
event text not null
);The not null constraint ensures that a column cannot have a NULL value.
Syntax:
column_name data_type not null [conflict_clause]Example:
create table contacts (
id integer primary key,
first_name text not null,
last_name text not null,
email text not null,
phone text
);The unique constraint ensures that all values in a column are different.
Syntax - Column Constraint:
column_name data_type unique [conflict_clause]Syntax - Table Constraint:
unique (column[, ...]) [conflict_clause]Examples:
-- Single-column unique constraint
create table users (
id integer primary key,
email text unique,
username text unique
);
-- Multi-column unique constraint
create table bookings (
id integer primary key,
room_id integer,
date text,
unique (room_id, date)
);A table may not carry the same plain UNIQUE twice. Two unnamed, non-partial UNIQUE
constraints over the same column set are refused with CONSTRAINT — neither has a name
for DROP CONSTRAINT to address, so neither could be removed short of recreating the
table, while every write pays the identical check twice. Column order is not identity
(unique (a, b) and unique (b, a) are one rule) and column names fold case. The rule
holds identically at CREATE TABLE (both the column-level and table-level spellings, and
across the two), ALTER TABLE … ADD CONSTRAINT, and ALTER TABLE … ADD COLUMN … unique
— see ALTER § ADD CONSTRAINT for the two carve-outs (a named UNIQUE
beside an unnamed one stays legal, as do partial UNIQUEs with a where predicate).
Reading the catalog of a database written before the rule is unaffected.
The check constraint ensures that values in a column satisfy a specific condition.
Syntax - Column Constraint:
column_name data_type check [on operation_list] (expression)Syntax - Table Constraint:
check [on operation_list] (expression)The optional on operation_list specifies when the constraint should be checked (insert, update, delete).
Examples:
-- Column-level check constraint
create table products (
id integer primary key,
name text not null,
price real check (price > 0),
discount real check (discount >= 0 and discount <= 1)
);
-- Table-level check constraint
create table transfers (
id integer primary key,
source_account_id integer not null,
dest_account_id integer not null,
amount real not null check (amount > 0),
check (source_account_id != dest_account_id)
);
-- Operation-specific check constraint
create table audit_log (
id integer primary key,
record_id integer not null,
action text not null,
timestamp text not null,
check on insert (action in ('insert', 'update', 'delete'))
);
-- JSON structure validation with check constraint
create table events (
id integer primary key,
event_type text not null,
data json check (json_schema(data, '[{x:integer,y:number}]'))
);
-- Complex JSON schema validation
create table api_logs (
id integer primary key,
endpoint text not null,
request json check (json_schema(request, '{ method: string, headers: any, body: any }')),
response json check (json_schema(response, '{ status: number, body: any }'))
);The default constraint provides a default value for a column when no value is specified.
Syntax:
column_name data_type default valueExamples:
-- Constant default value
create table posts (
id integer primary key,
title text not null,
content text,
views integer default 0,
status text default 'draft'
);
-- Function-based default
create table audit_records (
id integer primary key,
action text not null,
timestamp text default (datetime('now'))
);The foreign key constraint links tables together and ensures referential integrity.
Foreign key enforcement is controlled by the foreign_keys pragma (default: on):
pragma foreign_keys = on; -- enable FK enforcement (default)
pragma foreign_keys = off; -- parse but don't enforceWhen no ON DELETE or ON UPDATE clause is specified, the default action is RESTRICT. NO ACTION is currently treated as a synonym for RESTRICT.
A foreign key records its child columns by position but its parent columns by name, re-resolving them on every write. So ALTER TABLE ... DROP COLUMN treats the two sides differently: dropping a child column removes the key along with it, while dropping a column some key points at as a parent column is rejected — drop the referencing key first (ALTER TABLE <child> DROP CONSTRAINT <name>). The rejection does not depend on pragma foreign_keys, since a schema left in that state breaks the referencing table as soon as enforcement is switched on. See sql-alter.md § 2.7 under DROP COLUMN.
Syntax - Column Constraint:
column_name data_type references [schema.]foreign_table [(column)] [ref_actions]Syntax - Table Constraint:
foreign key (column[, ...]) references [schema.]foreign_table [(column[, ...])] [ref_actions]The parent table may be schema-qualified (references other_schema.parent(id)),
so a child in one schema can reference a parent in another. An unqualified parent
defaults to the child's own schema and persists with no qualifier (byte-identical
to a same-schema FK). All reference actions (RESTRICT / CASCADE / SET NULL /
SET DEFAULT) and foreign_key_info()'s referenced_schema column work the same
across schemas.
Reference Actions:
[on delete action] [on update action]Where action can be:
set null— set child FK columns to NULL when parent row is deleted/updatedset default— set child FK columns to their default valuescascade— delete/update child rows when parent row is deleted/updatedrestrict(default) — immediately reject delete/update if child rows existno action— currently treated as a synonym forrestrict
Enforcement Semantics:
When pragma foreign_keys = on (the default):
- Child-side (INSERT/UPDATE): Validates that referenced parent rows exist. These checks are deferred to commit time (they use cross-table subqueries). Uses MATCH SIMPLE semantics (SQL default): if any FK column is NULL, the constraint is satisfied without checking the parent table. If the referenced parent table does not exist, non-NULL FK rows are rejected.
- Parent-side DELETE/UPDATE with RESTRICT: Immediately rejects the operation if child rows reference the parent row being modified. Two layers of enforcement run: a plan-time
NOT EXISTSsynthesized into the DML's constraint-check node, and a runtimeselect 1 from <child> where <fk> = ? limit 1pre-check fired by the DML executor before the vtabxUpdatecall. The runtime pass is defense-in-depth so any vtab module — including those whose subquery evaluation diverges from a plain row scan — sees a consistent enforcement path. The check honours MATCH SIMPLE (NULL parent values cannot be referenced) and, on UPDATE, skips when no referenced parent column actually changed. - Parent-side DELETE/UPDATE with CASCADE: Automatically deletes or updates matching child rows.
- Parent-side DELETE/UPDATE with SET NULL: Sets child FK columns to NULL.
- Parent-side DELETE/UPDATE with SET DEFAULT: Sets child FK columns to their default values.
On UPDATE, all three propagating actions (CASCADE / SET NULL / SET DEFAULT) apply the same short-circuit as the RESTRICT check above: if the update leaves every parent column the FK references at its old value, the action does not fire and child rows are not written at all — an update to an unrelated parent column never touches, re-points, or emits a data-change event for the children.
Cascade cycle detection prevents infinite recursion when cascading actions chain across multiple tables.
Examples:
-- Column-level foreign key (no action clause = RESTRICT default)
create table posts (
id integer primary key,
user_id integer references users(id),
title text not null
);
-- Table-level foreign key with explicit actions
create table comments (
id integer primary key,
post_id integer,
user_id integer,
content text not null,
foreign key (post_id) references posts(id) on delete cascade,
foreign key (user_id) references users(id) on delete set null
);Indexes improve query performance for specific columns.
Syntax:
create [unique] index [if not exists] index_name
on table_name (column [asc|desc][, ...]) [where condition]Examples:
-- Simple index
create index idx_users_email on users(email);
-- Multi-column index
create index idx_posts_user_date on posts(user_id, created_at desc);
-- Partial index with WHERE clause
create index idx_active_users on users(last_login) where status = 'active';
-- Unique index
create unique index idx_products_sku on products(sku);Syntax:
drop index [if exists] index_nameExample:
drop index idx_users_email;