Files
rdbms-playground/docs/plans/20260525-adr-0035-sql-ddl-4a3.md
T
claude@clouddev1 60111f69d5 feat: ADR-0035 4a.3 — table-level / multi-column CHECK
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.
2026-05-25 14:06:52 +00:00

12 KiB
Raw Permalink Blame History

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 ```ignore doctest); clippy clean (cargo clippy --all-targets -- -D warnings). Branch main, last commit 1991fb4 (handoff-37). 4a.3 starts here.

2. Decisions locked with the user (do not re-litigate)

  1. 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_schema reads them from here, not PRAGMA. Auto-filtered from list_tables by the __rdbms_ prefix. A constraint name column is not added now — 4g's ADD CONSTRAINT <name> will add it when actually needed.

  2. Composite UNIQUE stays 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.

  3. Stored as raw SQL text, like 4a.2's column CHECK: sql_expr is validate-only (no Expr AST), so the builder captures the inner expression text by byte span via capture_parenthesised_span.

  4. One undo step; structural execution reuses do_create_table, which writes the metadata rows inside its existing transaction.

  5. 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 full sql_expr surface.
  • 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 seq column).
  • 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; STRICT preserved; 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_ddl re-emits the clauses on rebuild.
  • project.yaml round-trips the CHECKs (TableSchema.check_constraints, YAML #[serde(default)], optional on read — mirrors unique_constraints).
  • history.log / replay unchanged (part of the same create write 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 (update table_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 depth over the top-level item stream: +1 on each Punct('(') that reaches the loop, -1 on each Punct(')'). The column-list interior is depth == 1. (Parens consumed inside the check / default capture helpers and the table-unique sub-loop never reach the loop, so they don't perturb depth; the outer list parens, type length-args (10, 2), and table-PRIMARY KEY (a, b) parens do, and balance.)
  • Track column_open: bool — set true when a col_type / double finalises a column; reset false on a Punct(',') at depth == 1 (an element separator).
  • On MatchedKind::Word("check"): capture the parenthesised span as today; then route by column_opentrue → column-level (columns.last_mut().check_sql, 4a.2 behaviour); false → table-level (push raw text onto check_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_table gains a check_constraints: &[String] param; emits , CHECK (expr) table clauses (after composite UNIQUE, before STRICT), and writes the __rdbms_playground_table_checks rows (one per CHECK, seq = index) inside its existing transaction.
  • schema_to_ddl emits the same clauses from ReadSchema.check_constraints.
  • configure_connection creates the new metadata table alongside the existing __rdbms_* tables.
  • read_schema + read_schema_snapshot read the CHECKs from the metadata table (ordered by seq) into ReadSchema / SchemaSnapshot (→ TableSchema.check_constraints).
  • Request::SqlCreateTable dispatch passes the new field through to do_create_table; snapshot_then wrapping 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_constraints with #[serde(default)]; write_table emits only when non-empty; parse_schema maps it — all mirroring unique_constraints exactly.

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.rsen-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 (4e4h).
  • CONSTRAINT <name> CHECK (…) (named constraints) → 4g (adds a name column to the metadata table then).

6. Open items / implementer calls

  1. Builder distinguisher (§4.2) — depth-aware; settle by the probe tests in step 2 before relying on it.
  2. 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.
  3. 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_expr for 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_table and schema_to_ddl and adds a survives-rebuild test — the exact safety net the 4a serial drift 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_schema and read_schema_snapshot; YAML mirrors unique_constraints. ✓
  • Tests first? §8 orders failing tests before code at every step. ✓

8. Implementation sequence (test-first)

  1. 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; update table_level_check_and_fk_still_rejected so table CHECK is accepted and FK stays rejected) → red → add TABLE_CHECK grammar + the depth-aware builder branch + Command.check_constraints → green. (Compiles once the worker dispatch threads the new field; steps 12 land together.)
  2. 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_checks in configure_connection, the do_create_table emission + metadata writes, the read_schema / read_schema_snapshot reads, and the drop-path cleanup → green.
  3. Round-trip — extend TableSchema + YAML + schema_to_ddl → Tier-3 survives-rebuild test + a YAML round-trip unit test → green.
  4. Catalog — update help/usage bodies; run keys_validate_against_catalog + the vocab audit → green.
  5. Full sweepcargo test (no regressions from 1752) + cargo clippy --all-targets -- -D warnings.
  6. Docs — ADR §13 already records 4a.3; update requirements.md Q1 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.