Files
rdbms-playground/docs/plans/20260525-adr-0035-sql-ddl-4a3.md
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

257 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```sql
__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`:
```rust
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_open`** — `true` → 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.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` (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 sweep** — `cargo 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.