Add table-level CHECK (e.g. `CREATE TABLE t (a int, b int, CHECK (a < b))`) to advanced-mode SQL CREATE TABLE. Since SQLite exposes no PRAGMA for CHECK constraints, a table-level CHECK cannot be read back from the engine and becomes the source of truth in a new internal metadata table `__rdbms_playground_table_checks (table_name, seq, check_expr)`. - Grammar: new TABLE_CHECK element in ELEMENT_CHOICES. - Builder: distinguishes a table-level CHECK from a column-level one by element position (no column-def open in the element), using depth-aware boundary tracking so a length-arg comma (`numeric(10,2)`) or a table-PRIMARY KEY's inner comma is not mistaken for an element separator. - Worker: do_create_table emits the CHECK clauses and writes the metadata rows in its transaction; schema_to_ddl emits them identically on rebuild; read_schema / read_schema_snapshot read them from the metadata table; do_drop_table clears them. - Persistence: TableSchema.check_constraints round-trips through project.yaml (#[serde(default)], optional on read), mirroring unique_constraints. - Composite UNIQUE deliberately stays PRAGMA-detected (engine-reportable, unlike CHECK) — user-confirmed. DA/runda round added cross-cutting tests and a forward-looking doc fix: - table CHECK survives a rebuild triggered by `add column`, and a later rebuild_from_text (the ADR-0013 rebuild primitive uses a raw DROP, so the metadata rows keyed on the final name are preserved); - dropping a column a table CHECK references fails cleanly (rollback, table intact); detection is 4e, friendly wording is H1; - dropping a table clears its CHECK metadata (no orphan rows on re-create); - amended ADR §6 so 4h's RENAME also updates the new metadata table. 20 Tier-3 + 9 grammar/builder + 2 YAML tests. Docs: ADR-0035 Status/§13/§6, README index, requirements.md Q1. Help/usage skeleton + describe display of table-level constraints deferred to 4i (symmetric with 4a.2). Tests: 1769 passing, 0 failing, 1 ignored. Clippy clean.
12 KiB
Plan: ADR-0035 Phase 4, sub-phase 4a.3 — table-level / multi-column CHECK
The constraint slice's second (and final) half. Adds, to advanced-mode
SQL CREATE TABLE, the one constraint that needs a new __rdbms_*
metadata table: a table-level CHECK (<expr>) that can reference
several columns, e.g. CREATE TABLE t (a int, b int, CHECK (a < b)).
SQLite exposes no PRAGMA for CHECK constraints, so a table-level
CHECK cannot be read back from the engine and must live in metadata as
its source of truth (the ADR-0012/0013 pattern). Builds directly on the
4a/4a.2 SqlCreateTable command + grammar.
1. Baseline
- Tests: 1752 passing, 0 failing, 0 skipped, 1 ignored (the
friendly/mod.rs```ignoredoctest); clippy clean (cargo clippy --all-targets -- -D warnings). Branchmain, last commit1991fb4(handoff-37). 4a.3 starts here.
2. Decisions locked with the user (do not re-litigate)
-
New metadata table —
__rdbms_playground_table_checks(user confirmed 2026-05-25), focused/minimal, purpose-named like the existing metadata tables:__rdbms_playground_table_checks ( table_name TEXT NOT NULL, seq INT NOT NULL, -- declaration order check_expr TEXT NOT NULL, PRIMARY KEY (table_name, seq) ) STRICT;It is the source of truth for table-level CHECKs;
read_schemareads them from here, not PRAGMA. Auto-filtered fromlist_tablesby the__rdbms_prefix. A constraintnamecolumn is not added now — 4g'sADD CONSTRAINT <name>will add it when actually needed. -
Composite
UNIQUEstays PRAGMA-detected (user confirmed, 2026-05-25): the PRAGMA/metadata split is principled — engine- reportable (UNIQUE, PK, FK, indexes) → PRAGMA; not reportable (CHECK, column + table) → metadata. No churn to shipped 4a.2 code. -
Stored as raw SQL text, like 4a.2's column CHECK:
sql_expris validate-only (noExprAST), so the builder captures the inner expression text by byte span viacapture_parenthesised_span. -
One undo step; structural execution reuses
do_create_table, which writes the metadata rows inside its existing transaction. -
FK stays rejected (4b). Only the table-level CHECK shape is lifted from the 4a "not yet supported" parse rejection.
3. Phase 1 — Requirements checklist (4a.3)
Functional
- Table element
CHECK (<sql_expr>)parses (advanced mode), in any position among the elements, accepting the fullsql_exprsurface. - The builder distinguishes a table-level CHECK (no column-def open in the current element) from a column-level CHECK (after a column's type — 4a.2, unchanged). Depth-aware element-boundary detection (§4.2).
- Multiple table-level CHECKs in one statement, preserved in
declaration order (the
seqcolumn). - A table-level CHECK is enforced by the engine (a violating insert fails) and survives a rebuild (the part-D proof).
- The 4a/4a.2 "table-level CHECK not yet supported" parse-rejection is lifted; FK stays rejected (4b).
- Engine-neutral errors;
STRICTpreserved; one undo step.
Cross-cutting / round-trip
- A table with one or more table-level CHECKs survives save → load
→ rebuild (DDL + enforcement). The new metadata table is the source
of truth on read;
schema_to_ddlre-emits the clauses on rebuild. project.yamlround-trips the CHECKs (TableSchema.check_constraints, YAML#[serde(default)], optional on read — mirrorsunique_constraints).history.log/ replay unchanged (part of the samecreatewrite command).
Testing (ADR-0008 four tiers)
- Tier 1 (builder,
sql_create_table.rs): table CHECK captured verbatim, distinct from a column CHECK; multiple table CHECKs ordered; table CHECK after a length-arg column + column CHECK (the depth probe); table CHECK after a table-level PK/UNIQUE; nested parens balanced; FK still rejected (updatetable_level_check_and_fk_still_rejected). - Tier 3 (
tests/sql_create_table.rs): worker round-trip — table CHECK enforced (violating insert fails), survives rebuild; multiple CHECKs all enforced. - YAML round-trip unit test for the metadata field.
4. Architecture & design
4.1 Grammar (src/dsl/grammar/sql_create_table.rs)
Add a table-level CHECK element, mirroring TABLE_UNIQUE:
static TABLE_CHECK_NODES: &[Node] = &[
Node::Word(Word::keyword("check")),
Node::Punct('('),
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
Node::Punct(')'),
];
const TABLE_CHECK: Node = Node::Seq(TABLE_CHECK_NODES);
Extend ELEMENT_CHOICES:
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, COLUMN_DEF]. Order note: a
column literally named check is already unavailable (it is a keyword
in the column-constraint set); TABLE_CHECK before COLUMN_DEF keeps
the table-level form winning at element start. (COLUMN_DEF's own
CHECK lives inside COL_CONSTRAINT_SUFFIX, so the two never compete
for the same position.)
4.2 Builder distinguisher (the load-bearing mechanism)
MatchedKind::Word carries no role or node provenance (only
Ident carries role), so the table-level and column-level check
keywords are indistinguishable by kind. Distinguish by element
position, depth-aware:
- Track
depthover the top-level item stream:+1on eachPunct('(')that reaches the loop,-1on eachPunct(')'). The column-list interior isdepth == 1. (Parens consumed inside thecheck/defaultcapture helpers and the table-uniquesub-loop never reach the loop, so they don't perturbdepth; the outer list parens, type length-args(10, 2), and table-PRIMARY KEY (a, b)parens do, and balance.) - Track
column_open: bool— settruewhen acol_type/doublefinalises a column; resetfalseon aPunct(',')atdepth == 1(an element separator). - On
MatchedKind::Word("check"): capture the parenthesised span as today; then route bycolumn_open—true→ column-level (columns.last_mut().check_sql, 4a.2 behaviour);false→ table-level (push raw text ontocheck_constraints).
This is verified by probe tests, not reasoning — in particular the
a numeric(10,2) check (a>0) case (a naive "reset on any comma" would
misclassify it because the length-arg comma is at depth == 2).
The capture for a table-level CHECK reuses capture_parenthesised_span
(src/dsl/grammar/ddl.rs) unchanged — CHECK ( … ) is paren-bounded.
4.3 Command AST (src/dsl/command.rs)
Command::SqlCreateTable gains check_constraints: Vec<String> (raw
inner SQL texts, declaration order), peer to unique_constraints.
4.4 Worker / DDL — both generators in lockstep (src/db.rs)
The two DDL generators must emit the table CHECK clauses
identically (the §6.1 rule; the 4a serial bug is the cautionary tale):
do_create_tablegains acheck_constraints: &[String]param; emits, CHECK (expr)table clauses (after composite UNIQUE, beforeSTRICT), and writes the__rdbms_playground_table_checksrows (one per CHECK,seq= index) inside its existing transaction.schema_to_ddlemits the same clauses fromReadSchema.check_constraints.configure_connectioncreates the new metadata table alongside the existing__rdbms_*tables.read_schema+read_schema_snapshotread the CHECKs from the metadata table (ordered byseq) intoReadSchema/SchemaSnapshot(→TableSchema.check_constraints).Request::SqlCreateTabledispatch passes the new field through todo_create_table;snapshot_thenwrapping unchanged (one undo step).- A table drop / rebuild must clear/repopulate the metadata rows — verify
the existing drop path clears
__rdbms_*rows for the table (it does for columns/relationships); extend it to the new table.
4.5 Persistence round-trip
TableSchema.check_constraints: Vec<String>(src/persistence/mod.rs).RawTable.check_constraintswith#[serde(default)];write_tableemits only when non-empty;parse_schemamaps it — all mirroringunique_constraintsexactly.
4.6 Friendly catalog / keys
Update the ddl.sql_create_table help body and
parse.usage.sql_create_table usage skeleton to show the table-level
CHECK (…) form. No new keys expected (the parse error for a still-
rejected shape reuses existing keys); if any new diagnostic key is
added, keep keys.rs ↔ en-US.yaml in lockstep (the validator test)
and engine-neutral (the vocab audit).
5. Out of 4a.3 scope
- FK (4b);
DROP(4c); indexes (4d);ALTER(4e–4h). CONSTRAINT <name> CHECK (…)(named constraints) → 4g (adds anamecolumn to the metadata table then).
6. Open items / implementer calls
- Builder distinguisher (§4.2) — depth-aware; settle by the probe tests in step 2 before relying on it.
- Drop / rebuild cleanup of the new metadata rows — confirm by test that dropping (and rebuilding) a table leaves no orphan CHECK rows and repopulates correctly.
- CHECK column-validation at create time — table CHECKs reference
columns being defined (not yet in the schema cache); confirm by test
they raise no spurious unknown-column
[ERR](mirror the 4a.2 column-CHECK finding; it was fine there).
7. Devil's Advocate review of this plan
- Why a new table at all — is CHECK really unreportable? Yes;
SQLite has no PRAGMA for CHECK (column or table). 4a.2's column
CHECK only round-tripped because it was already stored in
__rdbms_playground_columns.check_exprfor the same reason. A table-level CHECK has no column to hang on, hence the new table. ✓ - Two DDL generators in sync? The plan emits the CHECK clauses in
both
do_create_tableandschema_to_ddland adds a survives-rebuild test — the exact safety net the 4aserialdrift proved necessary. ✓ - Distinguisher robust? The naive comma reset is explicitly
rejected (length-arg / table-PK inner commas); depth-aware detection
is probe-tested, including the
numeric(10,2) check(...)trap. ✓ - Silent scope creep? FK stays rejected (4b); named CHECK is 4g; composite UNIQUE deliberately stays on PRAGMA (user-confirmed). ✓
- Round-trip detectable on read? The metadata table is read by both
read_schemaandread_schema_snapshot; YAML mirrorsunique_constraints. ✓ - Tests first? §8 orders failing tests before code at every step. ✓
8. Implementation sequence (test-first)
- Builder probe + Tier-1 — write the distinguisher tests (table
CHECK captured + ordered; the
numeric(10,2) check(...)depth trap; table CHECK after table PK/UNIQUE; nested parens; column CHECK still column-level; updatetable_level_check_and_fk_still_rejectedso table CHECK is accepted and FK stays rejected) → red → addTABLE_CHECKgrammar + the depth-aware builder branch +Command.check_constraints→ green. (Compiles once the worker dispatch threads the new field; steps 1–2 land together.) - Worker + metadata table — write Tier-3 (
tests/sql_create_table.rs): table CHECK enforced (violating insert fails); multiple CHECKs; the metadata rows present → red → add__rdbms_playground_table_checksinconfigure_connection, thedo_create_tableemission + metadata writes, theread_schema/read_schema_snapshotreads, and the drop-path cleanup → green. - Round-trip — extend
TableSchema+ YAML +schema_to_ddl→ Tier-3 survives-rebuild test + a YAML round-trip unit test → green. - Catalog — update help/usage bodies; run
keys_validate_against_catalog+ the vocab audit → green. - Full sweep —
cargo test(no regressions from 1752) +cargo clippy --all-targets -- -D warnings. - Docs — ADR §13 already records 4a.3; update
requirements.mdQ1 note; flip nothing (ADR already Accepted). Propose the commit message; wait for approval.
9. Exit gate
- All §3 checklist items satisfied; four tiers green, zero skips; no regression from the 1752 baseline; written DA pass on the delivered slice; clippy clean.