Advanced-mode SQL CREATE TABLE gains the constraints that need no new internal table (the 4a.2 slice): - Grammar (sql_create_table.rs): column-level DEFAULT/CHECK and table-level UNIQUE(cols). DEFAULT is a literal or a *parenthesised* expression (standard SQL) — a bare sql_expr greedily eats a following NOT (NOT IN/LIKE/BETWEEN), breaking `DEFAULT 0 NOT NULL`; the parens bound it. CHECK is paren-bounded already. - Builder (ddl.rs): captures CHECK/DEFAULT raw SQL text by byte span (sql_expr builds no AST) via capture_parenthesised_span / capture_expr_span; routes single-column table UNIQUE into the column's flag and composite UNIQUE into unique_constraints. - Command/worker: ColumnSpec gains check_sql/default_sql (raw, preferred over the typed Expr/Value); Command::SqlCreateTable + Request + do_create_table gain unique_constraints; do_create_table emits raw CHECK/DEFAULT and composite UNIQUE clauses. - Round-trip (part D): ReadSchema/TableSchema gain unique_constraints; read_schema detects composite UNIQUE via PRAGMA index_list origin 'u' (single-column still folds to the column flag); schema_to_ddl emits them; YAML RawTable/write_table round-trips (optional-on-read). CHECK round-trips via __rdbms_playground_columns.check_expr, DEFAULT via PRAGMA table_info — no new metadata table. Table-level/multi-column CHECK remains 4a.3 (rejected "not yet supported"); FK is 4b. Tests: +7 builder (raw-text capture incl. the DEFAULT 0 NOT NULL boundary the fix was found by; single/composite UNIQUE routing) and +4 Tier-3 (CHECK enforced, DEFAULT applied, composite UNIQUE enforced, and all three survive a rebuild — the part-D round-trip). 1752 pass / 0 fail / 1 ignored; clippy clean. Plan + requirements.md updated.
10 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 <value>where<value>is a literal (number / string /null/true/false) or a parenthesised( <sql_expr> )— not a baresql_expr. This matches standard SQL (a complex default must be parenthesised) and resolves a real ambiguity found in testing: a baresql_exprgreedily consumes a followingNOT(asNOT IN/NOT LIKE/NOT BETWEEN), breaking the commonDEFAULT 0 NOT NULL. The parens give the expression a clean right edge. (See §6.3.)CHECK ( <sql_expr> ):Seq[ Word("check"), Punct('('), Subgrammar(&sql_expr::SQL_OR_EXPR), Punct(')') ]— already paren-bounded, so no ambiguity.
- 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 grammar — resolved during implementation (§4.1). A bare
DEFAULT <sql_expr>greedily ate a followingNOT(start ofNOT IN/LIKE/BETWEEN), soDEFAULT 0 NOT NULLfailed to parse (caught by a builder test). Fixed by restricting a bare default to a literal, with complex defaults parenthesised (DEFAULT (expr)) — standard SQL, and the parens bound the expression. The raw-text capture keeps the parens so it re-emits as valid SQL.
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.