From 1c5013343817c22e96c18c48b9d22f0c8e05928d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 25 May 2026 10:34:04 +0000 Subject: [PATCH] docs: ADR-0035 4a.2 plan + split table-level CHECK to 4a.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey of the constraint persistence machinery revealed that table-level/multi-column CHECK needs a NEW __rdbms_* metadata table (SQLite exposes no PRAGMA for CHECK), unlike per-column CHECK/DEFAULT (reuse __rdbms_playground_columns.check_expr + PRAGMA dflt_value) and composite UNIQUE (PRAGMA index_list origin 'u' + a TableSchema field). User-confirmed split: 4a.2 = per-column CHECK/DEFAULT (raw sql_expr text) + composite UNIQUE(a,b), no new internal table; 4a.3 = table-level CHECK + the new metadata table. ADR §13 and README updated in lockstep; 4a.2 plan doc added. --- docs/adr/0035-advanced-mode-sql-ddl.md | 29 ++- docs/adr/README.md | 2 +- docs/plans/20260525-adr-0035-sql-ddl-4a2.md | 192 ++++++++++++++++++++ 3 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 docs/plans/20260525-adr-0035-sql-ddl-4a2.md diff --git a/docs/adr/0035-advanced-mode-sql-ddl.md b/docs/adr/0035-advanced-mode-sql-ddl.md index abc9f1d..c40d523 100644 --- a/docs/adr/0035-advanced-mode-sql-ddl.md +++ b/docs/adr/0035-advanced-mode-sql-ddl.md @@ -319,16 +319,25 @@ ADR-0033's structure: autoincrement is independent of inline-vs-table-level PK (the insert path computes the next value), verified by round-trip tests. **No FK** (4b); **no `DEFAULT`/`CHECK`/table-level `UNIQUE`** (4a.2). -- **4a.2 — The constraint slice.** Split out (2026-05-24, - user-confirmed) for the constraints that are *not* a clean reuse: - (1) **`CHECK`/`DEFAULT`** via the full `sql_expr` surface stored as - **raw SQL text** — needed because `sql_expr` is validate-only and - yields no `Expr` AST for `compile_check_sql`/`ColumnSpec`, so it is a - separate execution path; (2) **composite `UNIQUE(a,b)` and - multi-column table `CHECK`** — the first structures `TableSchema` - cannot already represent, needing a model + YAML round-trip + - `read_schema` detection + `do_create_table` emission extension, with - save/load/rebuild tests. Until then 4a rejects all of these +- **4a.2 — Per-column `CHECK`/`DEFAULT` + composite `UNIQUE(a,b)`.** + Split out (2026-05-24) and re-scoped (2026-05-25, user-confirmed) to + the constraints that need **no new internal table**: (1) + **`CHECK`/`DEFAULT`** via the full `sql_expr` surface stored as **raw + SQL text** — `sql_expr` is validate-only (no `Expr` AST for + `compile_check_sql`/`ColumnSpec`), so a separate execution path + captures the raw expression text; per-column `CHECK` reuses the + existing `__rdbms_playground_columns.check_expr` column, `DEFAULT` + round-trips via the engine's native `PRAGMA table_info`; (2) + **composite `UNIQUE(a,b)`** — a new `TableSchema.unique_constraints` + 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 diff --git a/docs/adr/README.md b/docs/adr/README.md index c419aff..331125d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,4 +40,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0032 — The full SQL `SELECT` grammar](0032-sql-select-grammar.md) — **Accepted**, the Phase-2 grammar commissioned by ADR-0030 §3: full `SELECT` with `INNER`/`LEFT`/`RIGHT`/`FULL OUTER`/`CROSS` joins, `GROUP BY`/`HAVING`, all four set ops (`UNION`/`UNION ALL`/`INTERSECT`/`EXCEPT`), `WITH` and `WITH RECURSIVE` CTEs, `LIMIT … OFFSET`, `DISTINCT`, `t.*`, and bare-alias projection (lifting Phase-1 §4.2); additive extensions to ADR-0031's `sql_expr` for scalar subqueries, `IN (SELECT …)`, `[NOT] EXISTS`, and qualified column refs (redeeming ADR-0031 §7 OOS-1/OOS-2); grammar-recursion via `Subgrammar(&SQL_SELECT_COMPOUND)` reuses ADR-0026's `MAX_SUBGRAMMAR_DEPTH = 64` cap unchanged; **softens ADR-0030 §8's "ambient assistance comes for free" claim**: completion scope needs new `WalkContext` accumulators (a `from_scope_stack` of `ScopeFrame`s holding `from_scope` / `cte_bindings` / `projection_aliases`), a **new walker node variant `Node::ScopedSubgrammar(&Node)`** as the push/pop trigger (existing `Node::Subgrammar` unchanged so DSL `Expr` and `sql_expr` recursion are unaffected), qualified-prefix completion narrowing, body-projection-derived CTE column resolution (so `SELECT *` and explicit-projection CTE bodies both yield real column completion past `cte_alias.|`), and a **post-walk fixup pass** that re-resolves projection-list identifier highlighting/validity once `FROM` is parsed (the projection-before-FROM problem); classifies every Phase-2 validation case against ADR-0027's ERROR/WARNING guideline (§11): five new `diagnostic.*` keys for parse-time-detectable cases (unknown qualifier, ambiguous column, projection-alias misplaced, CTE/compound arity mismatch) plus eight `engine.*` translation keys; a MatchedPath-walking predicate-warnings variant that closes the Phase-1 gap where SQL `WHERE` expressions emitted no `LIKE`-on-numeric / `= NULL` / type-mismatch warnings (ADR-0027 Amendment 1 finally extends to the SQL surface); adds a worker-side post-prepare type-resolution pass via engine column-origin metadata so bare column refs recover their playground type (partially lifting Phase-1 §4.5, the bool→0/1 case) — `Cargo.toml` gains `column_metadata` to rusqlite features (verified against pinned 0.39.0); `__rdbms_*` rejection extended to every new table-source slot; Amendment 1 narrows §12's resolution rule from a grammar-side structural classification to "trust the engine's column-origin metadata verbatim" after an empirical probe showed origin metadata follows through non-recursive CTEs, scalar subqueries, derived tables, set ops, and joins — the one structural exception is recursive CTE result columns, which return None and stay typeless; Amendment 2 records that §10.6's "rewrite the highlight class" prescription is realised via the two-pass schema-existence diagnostic + the renderer's diagnostic-overlay path (no separate per-byte rewrite step needed; no new HighlightClass variant), and that the projection-before-FROM completion narrowing has been improved by an `src/completion.rs` look-ahead probe when the leading walk's `from_scope` is empty but the full input parses - [ADR-0033 — The full SQL DML grammar (`INSERT` / `UPDATE` / `DELETE`)](0033-sql-dml-grammar.md) — **Accepted** (implemented + verified through sub-phase 3k, 2026-05-23; phase-exit report `docs/handoff/20260523-phase-3-verification.md`), the Phase-3 grammar commissioned by ADR-0030 §3: single- and multi-row `INSERT` (incl. `INSERT … SELECT` recursing through ADR-0032's `SQL_SELECT_COMPOUND`), `UPDATE` with `SET` assignment list, `DELETE`, all three optionally followed by `RETURNING projection_list`, plus full `ON CONFLICT … DO NOTHING / DO UPDATE` UPSERT on INSERT; **fixes the DSL-vs-SQL dispatch architecture for shared entry words (`insert`/`update`/`delete`)**: SQL-first / DSL-fallback in Advanced mode via a `Choice(SQL_shape, DSL_shape)` per shape, gated by a new walker capability `Node::Guard(fn)` — a zero-byte-consumption gating node that fails the enclosing Seq with a `ValidationError`; carries `Command::SqlInsert` / `SqlUpdate` / `SqlDelete` variants and `do_sql_*` worker handlers each of which knows the target table (for re-persistence) and the `returning: bool` flag (for DataResult routing); `shortid` auto-fill mirrors the DSL `do_insert` mechanism via worker post-fill; SQL DELETE produces the same per-relationship cascade summary the DSL DELETE does (ADR-0014 parity); three new walker diagnostics (`insert_arity_mismatch` ERROR, `auto_column_overridden` WARNING, `not_null_missing` WARNING) with positive + negative tests each; OOS list explicitly carves out `DEFAULT VALUES` (the project's planned seed feature), SQLite-specific `OR REPLACE` / `OR IGNORE` / `OR ABORT` / `OR FAIL` / `OR ROLLBACK` prefixes, `UPDATE FROM` multi-table updates, and WITH-prefixed DML; the `excluded` keyword inside `ON CONFLICT DO UPDATE` is a deliberate carve-out from ADR-0030 §7's engine-neutral posture (no standard-SQL UPSERT spelling exists that SQLite and PostgreSQL share); eleven phased sub-phases each with explicit exit gates + written DA gate, opening with the dispatch mechanism before any DML grammar lands; initial DA review recorded seven critiques that were resolved before status moved to Proposed; **Amendment 1 supersedes §2's dispatch mechanism**: the originally-chosen `Node::Guard(fn)` + `Choice(SQL_shape, DSL_shape)` was found during 3a to be unworkable as framed (any guard-in-`Choice` mechanism forces a `walk_choice` change — `walk_choice` only falls through on `NoMatch`, so Simple-mode valid-DSL would wrongly surface "this is SQL", and `walk_seq` treats a `NoMatch` past `idx 0` as a hard `Failed`, breaking Advanced-mode DSL fall-through); replaced by **category-grouped, mode-aware dispatch** in `walker::walk` (each `REGISTRY` entry tagged `CommandCategory::{Simple, Advanced}`, generalising the existing whole-command `is_advanced_only` gate), shared entry words carrying a node in both groups, no `Node::Guard` and no `walk_choice`/`walk_seq` change, advanced-mode completion SQL-first with DSL as a full-line fallback; **Amendment 2 (sub-phase 3f) supersedes §7's cascade mechanism**: the WHERE-injected per-child pre-count rested on a premise that was factually wrong about the DSL handler (which detects cascades by before/after row-count diffing inside a transaction, not by `Expr`-derived pre-count subqueries) and would have broken the §2 parity promise by reporting `SET NULL` the DSL path doesn't; replaced by mirroring `do_delete`'s count-diff exactly (verbatim DELETE executes, child-count diff observes the cascade — `ON DELETE CASCADE` row removals only, SET NULL deferred for both paths to preserve parity), which shares the render-layer formatter for free via `CommandOutcome::Delete` and **withdraws risk R2** (no WHERE-byte extraction, no N+1 subquery); **Amendment 3 (sub-phase 3j) records the command-identity model and defers the execution-mode side-channel**: a command is the typed outcome of a *mode-rooted* grammar path and its identity is intrinsic (Advanced mode tries SQL first, falls back to the *Simple* DSL command when no SQL branch matches a token, e.g. `delete … --all-rows`; note `update … --all-rows` does *not* fall back — the SQL `SET` expression eats `--all-rows`, harmless since the engine treats it as a comment); **Simple mode commits the DSL candidate for shared words** so the *real* DSL error surfaces, and when that line would also run in advanced mode the rendering layer **combines** them — DSL error **plus** an `advanced_mode.also_valid_sql` pointer ("… (valid as SQL in advanced mode)") — keeping the actionable DSL fix while pointing at advanced mode; bare "this is SQL" is reserved for entry words with no DSL form (`select`/`with`); a fully-overlapping input (`insert … values …`) legitimately yields *two distinct commands* (`Command::Insert` typed-AST vs `Command::SqlInsert` validated-text) that do the same thing but execute differently (ADR-0030 §4), so each is tested in the mode that produces it; **corrects the plan's 3j exit-gate premise** that the DSL DML tests run in Simple mode (they call `parse_command`, which defaults to Advanced) — the real invariant is "Simple-mode behaviour unchanged, Advanced mode SQL-first, DSL grammar tested in Simple mode, both variants tested in their producing mode", with §6/§7 parity keeping the paths observably equivalent; and **defers to its own future ADR** the execution-time mode side-channel (three-way `Mode`: simple/advanced/advanced-one-shot threaded through `Action`→worker, for mode-dependent *output* like echoing generated SQL) — today only the *rendering* side-channel `OutputLine.mode_at_submission` exists, and the three-way distinction is not required for Phase 3 dispatch correctness - [ADR-0034 — `history.log` as a complete command journal; replay reads success-only](0034-history-journal-and-replay-filter.md) — **Accepted**, resolves a three-way tension in `history.log`'s roles found while implementing ADR-0033 3f: (1) the persistent log is success-only while the in-memory Up/Down recall ring records *every* submission (success or failure, "so users can recall and edit typo'd commands"), and the ring is re-seeded from the log on project open — so **failed commands are recallable within a session but silently lost across sessions**; (2) replay wants the state-building (successful) commands while recall wants everything typed, which one success-only file cannot serve; (3) `replay history.log` never actually worked — `run_replay` parses each whole line through the DSL parser with no understanding of the `||` record shape, so a real log fails on line 1, and **no test ever fed the pipe format to replay** (the `replay_history_log_records_subcommands_only` test only checks what replay *writes*, never replays the log as input). Decision: `history.log` becomes a **complete journal** — every submission recorded, tagged `ok`/`err` via the status field the format already reserved (ADR-0015 §5) — and **each consumer filters**: hydration reads all records (cross-session recall matches in-session), replay reads `ok` only (and learns the journal format, while still accepting bare-command `.commands` scripts; detection by the leading timestamp+status prefix so a `|` inside a bare command isn't misread). Successful commands stay journalled transactionally by the worker; failed commands are journalled `err` best-effort from the runtime/app error path (a parse failure never reaches the worker). Amends ADR-0006's "successfully executed" wording and ADR-0015 §5 ("status always `ok`") / §12 (hydration). Code deferred to two tracked test-first sub-tasks (journal-failures+filtering; replay-parses-journal-format); existing all-`ok` logs need no migration; **implemented 2026-05-24** (plan `docs/plans/20260524-adr-0034-history-journal.md`); **Amendment 1 (2026-05-24): replay filters out app-lifecycle commands** — a working `replay history.log` (the §3 fix) exposed that the journal also records `save as`/`load`/`new`/`export`/`import`/`rebuild`/`mode` (which would panic the worker dispatch or abort the replay), so replay now re-applies **only** schema/data write commands and **skips** every `Command::App` + nested `Command::Replay`; **all skips continue** (never abort — reversing the prior nested-`replay` refusal, so a journal containing a once-run `replay` needs no hand-editing, and the infinite-loop footgun is closed by construction), with a `[skip]` **warning** on `import` and nested-`replay` skips (their omission can leave replayed state incomplete) and silent skips for the rest; `replay.error_nested` removed, `replay.skipped_import`/`replay.skipped_replay` added, `ReplayCompleted` carries `warnings` -- [ADR-0035 — Advanced-mode SQL DDL](0035-advanced-mode-sql-ddl.md) — **Accepted** (design agreed 2026-05-24; validated end-to-end by sub-phase 4a `CREATE TABLE`, implemented 2026-05-25; 4a.2/4b–4i pending), **Phase 4** of the ADR-0030 roadmap (peer of 0031/0032/0033) and **clarifies ADR-0030 §4**. Advanced-mode `CREATE`/`DROP`/`ALTER TABLE` + `CREATE`/`DROP INDEX` get their **own per-statement commands** (`SqlCreateTable`/`SqlAlterTable`/`SqlDropTable`/`SqlCreateIndex`/`SqlDropIndex`), like DML's `Sql*` set — but unlike DML they **execute *structurally*, not verbatim** (raw execution would lose the playground's types, named relationships, and `STRICT`; "verbatim" was a DML convenience, not a rule). Handlers **reuse the low-level schema/metadata helpers** where the operation matches simple mode and **stand alone where the SQL surface is richer** (clarity over forced refactoring); simple mode is untouched (additive). Dispatch: `create`/`drop` reuse ADR-0033 Amendment 1's category-grouped mode-aware dispatch (SQL-first, simple fallback); `alter` is a new advanced-only entry word. Full surface (no pre-emptive cuts, `Q4`): `CREATE TABLE` with column + table constraints, single/compound `PRIMARY KEY`, inline + table-level `FOREIGN KEY` → **named relationships** (one statement = one command = **one undo step**, ADR-0006); `ALTER TABLE` add/drop/rename column, `ALTER COLUMN TYPE`, add/drop constraint, add FK, **`RENAME TO`** (advanced-only table rename — new low-level op renaming the table + its CSV + the relationship metadata, closing the rename half of `C1`); `CREATE [UNIQUE] INDEX` / `DROP INDEX`. Type slot accepts the ten playground keywords **and** standard-SQL aliases (`integer`→`int`, `varchar`→`text`, `timestamp`→`datetime`, …; length args accepted-and-ignored; no engine type names in/out — ADR-0030 §5). `CHECK`/`DEFAULT` reuse ADR-0031 `sql_expr`. **Pre-implementation `/runda` refinements (2026-05-24, user-confirmed):** `CREATE TABLE`/`DROP TABLE` **admit `IF [NOT] EXISTS`** (no-op-that-succeeds-with-a-note — a near-universal cross-vendor idiom, reclassified *into* scope, not engine-specific); `INTEGER PRIMARY KEY` maps to a **plain `int`** PK, *not* auto-increment (`serial` stays the sole auto-increment type). **Column-type-conversion is unified** (ADR-0017 engine, mode-appropriate policy): clean auto-converts and incompatible/own-type-static cases refuse in both modes, but a **lossy** change refuses-by-default in simple mode (`--force-conversion` opts in) while advanced mode **performs it with a loss note** and relies on **`undo` as the safety net** — no force flag, no dropping to simple mode (a payoff of shipping ADR-0006 first). OOS: views/triggers/txn-control/PRAGMA/etc. (ADR-0030 §3), the Postgres `USING` clause, and the DSL→SQL teaching echo (ADR-0030 Phase 5). Sub-phases 4a–4i (plus a 4a.2 **constraint slice** split out for `CHECK`/`DEFAULT` — `sql_expr` is validate-only, yielding no `Expr` AST, so they store as raw SQL text via a separate path — and for composite `UNIQUE(a,b)` / multi-column table `CHECK`, the first structures `TableSchema` can't already represent; 4a rejects these "not yet supported"), each with exit + DA gates +- [ADR-0035 — Advanced-mode SQL DDL](0035-advanced-mode-sql-ddl.md) — **Accepted** (design agreed 2026-05-24; validated end-to-end by sub-phase 4a `CREATE TABLE`, implemented 2026-05-25; 4a.2/4b–4i pending), **Phase 4** of the ADR-0030 roadmap (peer of 0031/0032/0033) and **clarifies ADR-0030 §4**. Advanced-mode `CREATE`/`DROP`/`ALTER TABLE` + `CREATE`/`DROP INDEX` get their **own per-statement commands** (`SqlCreateTable`/`SqlAlterTable`/`SqlDropTable`/`SqlCreateIndex`/`SqlDropIndex`), like DML's `Sql*` set — but unlike DML they **execute *structurally*, not verbatim** (raw execution would lose the playground's types, named relationships, and `STRICT`; "verbatim" was a DML convenience, not a rule). Handlers **reuse the low-level schema/metadata helpers** where the operation matches simple mode and **stand alone where the SQL surface is richer** (clarity over forced refactoring); simple mode is untouched (additive). Dispatch: `create`/`drop` reuse ADR-0033 Amendment 1's category-grouped mode-aware dispatch (SQL-first, simple fallback); `alter` is a new advanced-only entry word. Full surface (no pre-emptive cuts, `Q4`): `CREATE TABLE` with column + table constraints, single/compound `PRIMARY KEY`, inline + table-level `FOREIGN KEY` → **named relationships** (one statement = one command = **one undo step**, ADR-0006); `ALTER TABLE` add/drop/rename column, `ALTER COLUMN TYPE`, add/drop constraint, add FK, **`RENAME TO`** (advanced-only table rename — new low-level op renaming the table + its CSV + the relationship metadata, closing the rename half of `C1`); `CREATE [UNIQUE] INDEX` / `DROP INDEX`. Type slot accepts the ten playground keywords **and** standard-SQL aliases (`integer`→`int`, `varchar`→`text`, `timestamp`→`datetime`, …; length args accepted-and-ignored; no engine type names in/out — ADR-0030 §5). `CHECK`/`DEFAULT` reuse ADR-0031 `sql_expr`. **Pre-implementation `/runda` refinements (2026-05-24, user-confirmed):** `CREATE TABLE`/`DROP TABLE` **admit `IF [NOT] EXISTS`** (no-op-that-succeeds-with-a-note — a near-universal cross-vendor idiom, reclassified *into* scope, not engine-specific); `INTEGER PRIMARY KEY` maps to a **plain `int`** PK, *not* auto-increment (`serial` stays the sole auto-increment type). **Column-type-conversion is unified** (ADR-0017 engine, mode-appropriate policy): clean auto-converts and incompatible/own-type-static cases refuse in both modes, but a **lossy** change refuses-by-default in simple mode (`--force-conversion` opts in) while advanced mode **performs it with a loss note** and relies on **`undo` as the safety net** — no force flag, no dropping to simple mode (a payoff of shipping ADR-0006 first). OOS: views/triggers/txn-control/PRAGMA/etc. (ADR-0030 §3), the Postgres `USING` clause, and the DSL→SQL teaching echo (ADR-0030 Phase 5). Sub-phases 4a–4i, plus **4a.2** (per-column `CHECK`/`DEFAULT` via raw `sql_expr` text — `sql_expr` is validate-only, no `Expr` AST — + composite `UNIQUE(a,b)`; no new internal table) and **4a.3** (table-level/multi-column `CHECK`, split off because SQLite has no PRAGMA for CHECK so it needs a new `__rdbms_*` metadata table); 4a rejects all of these "not yet supported" until they land. Each sub-phase has exit + DA gates diff --git a/docs/plans/20260525-adr-0035-sql-ddl-4a2.md b/docs/plans/20260525-adr-0035-sql-ddl-4a2.md new file mode 100644 index 0000000..00f6c25 --- /dev/null +++ b/docs/plans/20260525-adr-0035-sql-ddl-4a2.md @@ -0,0 +1,192 @@ +# Plan: ADR-0035 Phase 4, sub-phase 4a.2 — per-column `CHECK`/`DEFAULT` + composite `UNIQUE` + +The constraint slice's first half. Adds, to advanced-mode SQL +`CREATE TABLE`, the constraints that need **no new internal table**: +per-column `CHECK ()` and `DEFAULT ` (via the ADR-0031 +`sql_expr` surface, stored/echoed as **raw SQL text**), and composite +`UNIQUE (a, b)`. Table-level / multi-column `CHECK` is **4a.3** (it +needs a new `__rdbms_*` metadata table — SQLite has no PRAGMA for +CHECK). Builds directly on the 4a `SqlCreateTable` command + grammar. + +## 1. Baseline + +- Tests: **1739 passing, 0 failing, 0 skipped, 1 ignored**; clippy + clean. Branch `main`, last commit `631074f` (4a). 4a.2 starts here. + +## 2. Decisions locked with the user (do not re-litigate) + +1. **Scope (2026-05-25):** 4a.2 = per-column `CHECK`/`DEFAULT` + + composite `UNIQUE(a,b)`. **No new internal table.** Table-level / + multi-column `CHECK` → **4a.3** (new `__rdbms_*` metadata table). +2. **`CHECK`/`DEFAULT` are stored as raw SQL text**, not a compiled + `Expr`: `sql_expr` is validate-only (no AST). Per-column `CHECK` + reuses the existing `__rdbms_playground_columns.check_expr` column; + `DEFAULT` round-trips via the engine's native `PRAGMA table_info` + (`dflt_value`) — both echoed verbatim by `schema_to_ddl`. +3. **Single-column table-level `UNIQUE(a)` normalises into the column's + `unique` flag** (so it round-trips via the existing single-column + path, `read_unique_columns`); **composite `UNIQUE(a,b)`** is a new + `TableSchema.unique_constraints` field, detected on read via the + UNIQUE-constraint index (`PRAGMA index_list` origin `u`, >1 column). + +## 3. Phase 1 — Requirements checklist (4a.2) + +### Functional + +- [ ] Column constraint `DEFAULT ` parses (advanced mode) and + is stored/emitted as raw SQL; round-trips via `PRAGMA table_info`. +- [ ] Column constraint `CHECK ()` parses and is stored as raw + SQL in `__rdbms_playground_columns.check_expr`; round-trips. +- [ ] `CHECK`/`DEFAULT` accept the **full `sql_expr` surface** (the same + fragment `WHERE`/projections use), not the DSL subset. +- [ ] Table element `UNIQUE (, …)`: single-column normalises into + the column's `unique`; composite (≥2) → `unique_constraints`. +- [ ] Composite `UNIQUE` emitted in the create DDL **and** the rebuild + DDL (`schema_to_ddl`); detected by `read_schema_snapshot`. +- [ ] The 4a "not yet supported" parse-rejection is **lifted** for + these forms (the grammar now admits them); table-level/multi-column + `CHECK` still rejected (→ 4a.3). +- [ ] One undo step; structural execution reuses `do_create_table`. +- [ ] Engine-neutral errors; `STRICT` preserved. + +### Cross-cutting + +- [ ] Round-trip: a table with per-column `CHECK`/`DEFAULT` and a + composite `UNIQUE` survives save → load → rebuild (DDL + enforcement). +- [ ] `history.log` / replay unchanged (these are part of the same + `create` write command). + +### Testing (ADR-0008 four tiers) + +- [ ] **Tier 1**: builder tests — `CHECK`/`DEFAULT` raw text captured + verbatim; composite vs single `UNIQUE` routing; the full `sql_expr` + surface parses; table-level/multi-column `CHECK` still rejected. +- [ ] **Tier 3** (`tests/sql_create_table.rs`): worker round-trip — + `CHECK` enforced (a violating insert fails), `DEFAULT` applied (an + omitted column gets it), composite `UNIQUE` enforced (dup rejected); + **rebuild** preserves all three. + +## 4. Architecture & design + +### 4.1 Grammar (`src/dsl/grammar/sql_create_table.rs`) + +- **Column constraints** — extend `COL_CONSTRAINT_CHOICES` with: + - `DEFAULT `: `Seq[ Word("default"), + Subgrammar(&sql_expr::SQL_OR_EXPR) ]`. + - `CHECK ( )`: `Seq[ Word("check"), Punct('('), + Subgrammar(&sql_expr::SQL_OR_EXPR), Punct(')') ]`. +- **Table element** — extend `ELEMENT_CHOICES` with table-level + `UNIQUE ( col, … )`: `Seq[ Word("unique"), Punct('('), + Repeated(uniq_column, ',', 1), Punct(')') ]`. (Distinct ident role, + e.g. `unique_column`, so the builder routes it separately from + `pk_column`.) **No** table-level `CHECK` element (→ 4a.3). +- **Column-validation in CHECK at create time** — *verify by test* + (§6.1): the `sql_expr` column refs name columns being defined, which + don't exist in the schema cache yet. Confirm they don't raise an + unknown-column `[ERR]`; mirror whatever the simple-mode `expr`-based + CHECK does. + +### 4.2 Raw-text capture (the load-bearing mechanism) + +`MatchedItem` carries `span: (usize, usize)` byte offsets into the +source (confirmed via `build_sql_insert`, which slices `source` by a +keyword's span). The builder (`build_sql_create_table`, which already +receives `source`) captures each `CHECK`/`DEFAULT` expression's raw +text as `source[first_expr_terminal.span.0 .. last_expr_terminal.span.1]`: + +- `CHECK ( … )` — paren-delimited: take the text between the matched + `(` and `)` terminals. +- `DEFAULT ` — the `sql_expr` match is maximal, so its terminals + are exactly the default expression; take from the first expr terminal + after the `default` keyword to the last before the next element + boundary (comma / `)` / next constraint keyword). + +### 4.3 Column representation for raw `CHECK`/`DEFAULT` (implementer call) + +`ColumnSpec.check: Option` / `default: Option` can't hold +raw SQL. **Chosen:** add `check_sql: Option` and +`default_sql: Option` to `ColumnSpec` (the advanced-mode +raw-text alternative). `do_create_table` prefers the raw `_sql` field +when present, else compiles the `Expr`/`Value` (simple-mode path). +`ColumnSpec::new` sets them `None`; struct-literal sites get +`check_sql: None, default_sql: None` (compiler-found). This keeps +`SqlCreateTable { columns: Vec, … }` and reuses +`do_create_table` rather than forking it. + +### 4.4 Composite `UNIQUE` end-to-end + +- `Command::SqlCreateTable` gains `unique_constraints: Vec>`. +- `do_create_table` + `schema_to_ddl` emit `UNIQUE ()` table + clauses (insertion points: after the table-level PK clause, before + FK / `STRICT`). +- `TableSchema.unique_constraints: Vec>` + + `RawTable.unique_constraints` (`#[serde(default)]`, optional-on-read); + `write_table` emits only when non-empty. +- `read_schema_snapshot` detects them: read `PRAGMA index_list` origin + `u` + `index_info`; **single-column → `ColumnSchema.unique`** + (existing `read_unique_columns` path), **multi-column → + `unique_constraints`** (lift the current `len() == 1` filter that + drops composites). + +### 4.5 Worker / undo / persistence + +Same as 4a: one `create` = one undo step (`snapshot_then`); structural +execution; `finalize_persistence` writes yaml/CSV/journal. No new +`Request` variant (still `SqlCreateTable`). + +## 5. Out of 4a.2 scope + +- **Table-level / multi-column `CHECK`** → 4a.3 (new metadata table). +- FK (4b); `DROP` (4c); indexes (4d); `ALTER` (4e–4h). + +## 6. Open items / implementer calls + +1. **CHECK column-validation at create time** — verify (test) that + `sql_expr` column refs to columns-being-defined don't raise an + unknown-column `[ERR]`. If they do, the grammar must suppress + schema-existence checks in the CREATE-TABLE CHECK context (as the + simple-mode path effectively does). Resolve during step 1. +2. **DEFAULT expression boundary** — confirm the `sql_expr` match is + maximal enough that the raw-text slice for `DEFAULT ` ends + cleanly before the next element. Covered by builder tests. + +## 7. Devil's Advocate review of this plan + +- **Raw text vs compiled — round-trip safe?** `CHECK` raw text → the + same `check_expr` column simple mode uses (echoed verbatim by + `schema_to_ddl`); `DEFAULT` → engine PRAGMA. Both reuse proven + round-trip paths. The new piece (composite `UNIQUE`) gets explicit + rebuild tests. ✓ +- **Reuse vs fork?** `do_create_table` is still the single executor; the + `check_sql`/`default_sql` fields add a branch, not a fork. ✓ +- **Single vs composite UNIQUE consistency?** Single normalises to + `column.unique` so read-back (which maps single-column origin-`u` to + `column.unique`) round-trips identically — no asymmetry (the 4a + lesson). ✓ +- **Silent scope creep?** Table-level CHECK is explicitly out (4a.3), + rejected by the grammar, not silently half-done. ✓ + +## 8. Implementation sequence (test-first) + +1. **`sql_expr` CHECK column-validation probe** (§6.1) → settle the + grammar's schema-check behaviour before building the shapes. +2. **Grammar + builder, `CHECK`/`DEFAULT`** — Tier-1 tests (raw text + captured verbatim; full `sql_expr` surface; still-rejected + table-CHECK) → red → add the constraint shapes + raw-text capture + + `ColumnSpec.check_sql`/`default_sql` + `do_create_table` branch → + green. +3. **Grammar + builder, composite `UNIQUE`** — Tier-1 (single→column, + composite→`unique_constraints`) → red → add the `UNIQUE(…)` element + + `Command`/`do_create_table`/`schema_to_ddl` emission → green. +4. **Persistence round-trip** — extend `TableSchema` + YAML + + `read_schema_snapshot` composite-UNIQUE detection → Tier-3 round-trip + tests (CHECK enforced, DEFAULT applied, composite UNIQUE enforced; + survive rebuild) → green. +5. **Full sweep** — `cargo test` (no regressions) + `cargo clippy + --all-targets -- -D warnings`; engine-neutral string audit. +6. **Docs** — `requirements.md` Q1 note; ADR §13 already records 4a.2. + +## 9. Exit gate + +- All §3 checklist items satisfied; four tiers green, zero skips; no + regression from the 1739 baseline; written DA pass; clippy clean.