Files
rdbms-playground/docs/plans/20260525-adr-0035-sql-ddl-4a2.md
claude@clouddev1 c0f5626787 feat: ADR-0035 4a.2 — per-column CHECK/DEFAULT + composite UNIQUE
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.
2026-05-25 11:04:59 +00:00

10 KiB
Raw Permalink Blame History

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 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 CHECK4a.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 <sql_expr> parses (advanced mode) and is stored/emitted as raw SQL; round-trips via PRAGMA table_info.
  • Column constraint CHECK (<sql_expr>) 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 (<col>, …): 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 <value> where <value> is a literal (number / string / null / true / false) or a parenthesised ( <sql_expr> )not a bare sql_expr. This matches standard SQL (a complex default must be parenthesised) and resolves a real ambiguity found in testing: a bare sql_expr greedily consumes a following NOT (as NOT IN/NOT LIKE/NOT BETWEEN), breaking the common DEFAULT 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_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 timeverify 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 <expr> — 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<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::SqlCreateTable gains unique_constraints: Vec<Vec<String>>.
  • do_create_table + schema_to_ddl emit UNIQUE (<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_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 (4e4h).

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 grammar — resolved during implementation (§4.1). A bare DEFAULT <sql_expr> greedily ate a following NOT (start of NOT IN/LIKE/BETWEEN), so DEFAULT 0 NOT NULL failed 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? 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 sweepcargo test (no regressions) + cargo clippy --all-targets -- -D warnings; engine-neutral string audit.
  6. Docsrequirements.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.