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.
This commit is contained in:
claude@clouddev1
2026-05-25 14:06:52 +00:00
parent 1991fb4fc7
commit 60111f69d5
12 changed files with 899 additions and 39 deletions
+34 -19
View File
@@ -3,10 +3,12 @@
## Status
Accepted. Design agreed with the user (2026-05-24); the approach is
**validated end-to-end by sub-phase 4a** (`CREATE TABLE`, implemented
2026-05-25 — plan `docs/plans/20260524-adr-0035-sql-ddl-4a.md`), so the
decision is accepted while the remaining sub-phases (**4a.2, 4b4i**,
§13) continue. This is **Phase 4** of the ADR-0030 roadmap (the
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3** (`CREATE TABLE`
with column- and table-level constraints, implemented 2026-05-25 —
plans `docs/plans/20260524-adr-0035-sql-ddl-4a.md`,
`…-4a2.md`, `…-4a3.md`), so the decision is accepted while the remaining
sub-phases (**4b4i**, §13) continue. This is **Phase 4** of the
ADR-0030 roadmap (the
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
on how DDL is represented and executed.
@@ -201,10 +203,12 @@ is no simple-mode rename-table verb. It needs a genuinely new
low-level operation (none exists today): within one transaction,
rename the table in the database, rename its `data/<table>.csv` file,
and update every metadata row that names it — the column-metadata
rows, and **both ends of any relationship** in
`__rdbms_playground_relationships` that references the old name. Name
validation and `__rdbms_*` rejection apply to the target. This closes
the rename half of `C1` for the advanced surface.
rows, **both ends of any relationship** in
`__rdbms_playground_relationships` that references the old name, and
**the table-level CHECK rows** in `__rdbms_playground_table_checks`
(added in 4a.3; keyed by `table_name`). Name validation and
`__rdbms_*` rejection apply to the target. This closes the rename half
of `C1` for the advanced surface.
### 7. Column type conversion — one engine, mode-appropriate policy
@@ -332,23 +336,34 @@ ADR-0033's structure:
field, detected on read via the UNIQUE-constraint index
(`PRAGMA index_list` origin `u`), round-tripped through YAML, with
save/load/rebuild tests.
- **4a.3 — Table-level / multi-column `CHECK(…)`.** Split from 4a.2
(2026-05-25, user-confirmed) because SQLite exposes **no PRAGMA for
CHECK constraints**, so a table-level CHECK cannot be read back from
the engine and needs a **new `__rdbms_*` metadata table** as its
source of truth (the ADR-0012/0013 pattern) — a distinct
architectural step. Until 4a.2/4a.3 land, 4a rejects these forms
"not yet supported". (The general rule: a DDL feature needs new
model/execution work only when it introduces a structure simple mode
could never produce, or an expression the structural helper cannot
consume — cf. the `UNIQUE`-index flag in 4d and the rename op in 4h.)
- **4a.3 — Table-level / multi-column `CHECK(…)`.** *(Implemented
2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4a3.md`.)*
Split from 4a.2 (2026-05-25, user-confirmed) because SQLite exposes
**no PRAGMA for CHECK constraints**, so a table-level CHECK cannot be
read back from the engine and needs a **new `__rdbms_*` metadata
table** as its source of truth (the ADR-0012/0013 pattern) — a
distinct architectural step. Landed as
`__rdbms_playground_table_checks (table_name, seq, check_expr)`; the
builder distinguishes a table-level CHECK from a column-level one by
element position (no column-def open). Composite `UNIQUE` deliberately
stays PRAGMA-detected (engine-reportable, unlike CHECK). (The general
rule: a DDL feature needs new model/execution work only when it
introduces a structure simple mode could never produce, or an
expression the structural helper cannot consume — cf. the
`UNIQUE`-index flag in 4d and the rename op in 4h.)
- **4b — Foreign keys in `CREATE TABLE`.** Inline `REFERENCES` +
table-level `FOREIGN KEY` → relationship metadata, one undo step.
- **4c — `DROP TABLE [IF EXISTS]`** → `SqlDropTable` (cascade parity;
`IF EXISTS` no-op-with-note, §4).
- **4d — `CREATE [UNIQUE] INDEX` / `DROP INDEX`** → `SqlCreateIndex`
/ `SqlDropIndex` (ADR-0025; the `UNIQUE` flag extension if needed).
- **4e — `ALTER TABLE` add/drop/rename column.**
- **4e — `ALTER TABLE` add/drop/rename column.** Drop/rename column
must guard against a **table-level CHECK that references the column**
(4a.3): today the rebuild rejects it cleanly via the engine (the
rebuilt table's `CHECK` names a missing column), leaving the table
intact — 4e adds the up-front detection (parsing column references
out of the raw CHECK text in `__rdbms_playground_table_checks`) so the
refusal is deliberate; the friendly wording itself is **H1** work.
- **4f — `ALTER TABLE … ALTER COLUMN TYPE`** (the §7 conversion
model + the lossy-with-note path).
- **4g — `ALTER TABLE` add/drop constraint, add foreign key.**
+1 -1
View File
File diff suppressed because one or more lines are too long
+256
View File
@@ -0,0 +1,256 @@
# 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.
+5 -3
View File
@@ -218,9 +218,11 @@ handoff-14 cleanup; 449 after B2/C2.)
`CREATE TABLE` (ADR-0035, 2026-05-25 — executed structurally: columns
+ types + `NOT NULL`/`UNIQUE`/`PRIMARY KEY` + `IF NOT EXISTS` (4a),
then per-column `DEFAULT`/`CHECK` (raw `sql_expr` text) and composite
`UNIQUE(a,b)` (4a.2)). Remaining DDL — table-level/multi-column
`CHECK` (4a.3), FK (4b), `DROP TABLE` (4c), indexes (4d),
`ALTER TABLE` (4e4h) — is phased per ADR-0035 §13.)*
`UNIQUE(a,b)` (4a.2), then table-level/multi-column `CHECK` (4a.3 —
round-trips via the new `__rdbms_playground_table_checks` metadata
table, since the engine reports no CHECK constraints)). Remaining DDL
— FK (4b), `DROP TABLE` (4c), indexes (4d), `ALTER TABLE` (4e4h) —
is phased per ADR-0035 §13.)*
- [ ] **Q2** Non-standard syntax rejected with a clear message
pointing at the supported subset.
*(Design done — ADR-0030 §8: out-of-subset statements are