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.
9.4 KiB
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 (<expr>) and DEFAULT <expr> (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 commit631074f(4a). 4a.2 starts here.
2. Decisions locked with the user (do not re-litigate)
- Scope (2026-05-25): 4a.2 = per-column
CHECK/DEFAULT+ compositeUNIQUE(a,b). No new internal table. Table-level / multi-columnCHECK→ 4a.3 (new__rdbms_*metadata table). CHECK/DEFAULTare stored as raw SQL text, not a compiledExpr:sql_expris validate-only (no AST). Per-columnCHECKreuses the existing__rdbms_playground_columns.check_exprcolumn;DEFAULTround-trips via the engine's nativePRAGMA table_info(dflt_value) — both echoed verbatim byschema_to_ddl.- Single-column table-level
UNIQUE(a)normalises into the column'suniqueflag (so it round-trips via the existing single-column path,read_unique_columns); compositeUNIQUE(a,b)is a newTableSchema.unique_constraintsfield, detected on read via the UNIQUE-constraint index (PRAGMA index_listoriginu, >1 column).
3. Phase 1 — Requirements checklist (4a.2)
Functional
- Column constraint
DEFAULT <sql_expr>parses (advanced mode) and is stored/emitted as raw SQL; round-trips viaPRAGMA table_info. - Column constraint
CHECK (<sql_expr>)parses and is stored as raw SQL in__rdbms_playground_columns.check_expr; round-trips. CHECK/DEFAULTaccept the fullsql_exprsurface (the same fragmentWHERE/projections use), not the DSL subset.- Table element
UNIQUE (<col>, …): single-column normalises into the column'sunique; composite (≥2) →unique_constraints. - Composite
UNIQUEemitted in the create DDL and the rebuild DDL (schema_to_ddl); detected byread_schema_snapshot. - The 4a "not yet supported" parse-rejection is lifted for
these forms (the grammar now admits them); table-level/multi-column
CHECKstill rejected (→ 4a.3). - One undo step; structural execution reuses
do_create_table. - Engine-neutral errors;
STRICTpreserved.
Cross-cutting
- Round-trip: a table with per-column
CHECK/DEFAULTand a compositeUNIQUEsurvives save → load → rebuild (DDL + enforcement). history.log/ replay unchanged (these are part of the samecreatewrite command).
Testing (ADR-0008 four tiers)
- Tier 1: builder tests —
CHECK/DEFAULTraw text captured verbatim; composite vs singleUNIQUErouting; the fullsql_exprsurface parses; table-level/multi-columnCHECKstill rejected. - Tier 3 (
tests/sql_create_table.rs): worker round-trip —CHECKenforced (a violating insert fails),DEFAULTapplied (an omitted column gets it), compositeUNIQUEenforced (dup rejected); rebuild preserves all three.
4. Architecture & design
4.1 Grammar (src/dsl/grammar/sql_create_table.rs)
- Column constraints — extend
COL_CONSTRAINT_CHOICESwith:DEFAULT <sql_expr>:Seq[ Word("default"), Subgrammar(&sql_expr::SQL_OR_EXPR) ].CHECK ( <sql_expr> ):Seq[ Word("check"), Punct('('), Subgrammar(&sql_expr::SQL_OR_EXPR), Punct(')') ].
- Table element — extend
ELEMENT_CHOICESwith table-levelUNIQUE ( col, … ):Seq[ Word("unique"), Punct('('), Repeated(uniq_column, ',', 1), Punct(')') ]. (Distinct ident role, e.g.unique_column, so the builder routes it separately frompk_column.) No table-levelCHECKelement (→ 4a.3). - Column-validation in CHECK at create time — verify by test
(§6.1): the
sql_exprcolumn 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-modeexpr-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 <expr>— thesql_exprmatch is maximal, so its terminals are exactly the default expression; take from the first expr terminal after thedefaultkeyword 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<Expr> / default: Option<Value> can't hold
raw SQL. Chosen: add check_sql: Option<String> and
default_sql: Option<String> 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<ColumnSpec>, … } and reuses
do_create_table rather than forking it.
4.4 Composite UNIQUE end-to-end
Command::SqlCreateTablegainsunique_constraints: Vec<Vec<String>>.do_create_table+schema_to_ddlemitUNIQUE (<cols>)table clauses (insertion points: after the table-level PK clause, before FK /STRICT).TableSchema.unique_constraints: Vec<Vec<String>>+RawTable.unique_constraints(#[serde(default)], optional-on-read);write_tableemits only when non-empty.read_schema_snapshotdetects them: readPRAGMA index_listoriginu+index_info; single-column →ColumnSchema.unique(existingread_unique_columnspath), multi-column →unique_constraints(lift the currentlen() == 1filter 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
- CHECK column-validation at create time — verify (test) that
sql_exprcolumn 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. - DEFAULT expression boundary — confirm the
sql_exprmatch is maximal enough that the raw-text slice forDEFAULT <expr>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?
CHECKraw text → the samecheck_exprcolumn simple mode uses (echoed verbatim byschema_to_ddl);DEFAULT→ engine PRAGMA. Both reuse proven round-trip paths. The new piece (compositeUNIQUE) gets explicit rebuild tests. ✓ - Reuse vs fork?
do_create_tableis still the single executor; thecheck_sql/default_sqlfields add a branch, not a fork. ✓ - Single vs composite UNIQUE consistency? Single normalises to
column.uniqueso read-back (which maps single-column origin-utocolumn.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)
sql_exprCHECK column-validation probe (§6.1) → settle the grammar's schema-check behaviour before building the shapes.- Grammar + builder,
CHECK/DEFAULT— Tier-1 tests (raw text captured verbatim; fullsql_exprsurface; still-rejected table-CHECK) → red → add the constraint shapes + raw-text capture +ColumnSpec.check_sql/default_sql+do_create_tablebranch → green. - Grammar + builder, composite
UNIQUE— Tier-1 (single→column, composite→unique_constraints) → red → add theUNIQUE(…)element +Command/do_create_table/schema_to_ddlemission → green. - Persistence round-trip — extend
TableSchema+ YAML +read_schema_snapshotcomposite-UNIQUE detection → Tier-3 round-trip tests (CHECK enforced, DEFAULT applied, composite UNIQUE enforced; survive rebuild) → green. - Full sweep —
cargo test(no regressions) +cargo clippy --all-targets -- -D warnings; engine-neutral string audit. - Docs —
requirements.mdQ1 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.