Files
rdbms-playground/docs/plans/20260524-adr-0035-sql-ddl-4a.md
T
claude@clouddev1 093496fe6b docs: ADR-0035 4a plan + 4a.2 split for composite UNIQUE / table CHECK
Add the sub-phase 4a implementation plan (docs/plans/), test-first,
mirroring the ADR-0033 DML sub-phase model: SqlCreateTable as its own
command executed structurally through the existing do_create_table
helper; shared-entry-word dispatch (SQL-first, simple fallback); the
type-alias resolver; IF NOT EXISTS no-op-with-note (CreateOutcome
enum); INTEGER PRIMARY KEY -> plain int; one-undo-step wiring.

Records the user-confirmed 4a/4a.2 split: composite UNIQUE(a,b) and
multi-column table CHECK move to a dedicated slice because they are the
first structures TableSchema cannot already represent, so they need a
persistence-model + round-trip extension rather than parse+execute
reuse. ADR-0035 §13 gains 4a.2; README sub-phase line updated in
lockstep.
2026-05-24 22:54:07 +00:00

18 KiB
Raw Blame History

Plan: ADR-0035 Phase 4, sub-phase 4a — dispatch + CREATE TABLE core

This is the first slice of ADR-0035 (advanced-mode SQL DDL). It lands advanced-mode create dispatch and a structurally-executed SqlCreateTable command for the column/constraint/PK core — no foreign keys (those are 4b). It mirrors the ADR-0033 DML sub-phase model (tests/sql_insert.rs et al.) and reuses the existing low-level create-table machinery per ADR-0035 §1.

1. Baseline

  • Tests: 1698 passing, 0 failing, 0 skipped, 1 ignored (the long-standing friendly/mod.rs ```ignore doctest). Clippy clean (cargo clippy --all-targets -- -D warnings).
  • Branch main; last commit 19d3cd3 (the ADR-0035 /runda refinements). Re-confirmed green at the start of this work.

2. Decisions locked with the user (do not re-litigate)

From the ADR-0035 design (handoff 36 §3) and the pre-implementation /runda round (2026-05-24), all user-confirmed:

  1. Own command, structural execution. SqlCreateTable is its own Command / Request variant (not a reuse of Command::CreateTable). It executes structurally by reusing the low-level helper do_create_tablenot verbatim SQL. Simple mode is untouched.
  2. IF NOT EXISTS is admitted as a no-op that succeeds with a note ("table already exists — skipped"), not a parse error and not the plain-form "already exists" error. (ADR §3/§4/§12/§13.)
  3. INTEGER PRIMARY KEY → plain int PK. The type map is purely lexical; auto-increment is reachable only via the explicit serial type. (ADR §3.)
  4. Dispatch: create is a shared entry word — it stays CommandCategory::Simple for ddl::CREATE and gains an Advanced entry for the new SQL node, exactly as insert/update/ delete do (ADR-0033 Amendment 1: SQL-first in advanced, simple fallback).
  5. Type vocabulary: the ten playground keywords and the standard-SQL aliases (integer/smallint/bigintint, varchar/chartext, booleanbool, timestampdatetime, numericdecimal, float/double precisionreal, binary/varbinaryblob); a length/precision argument (varchar(255)) is accepted and ignored.
  6. Undo: SqlCreateTable is a user mutation carrying a source, wrapped in snapshot_then like the existing 19 mutating arms → one undo step. create needs no is_app_lifecycle_entry_word change (it's a write).

3. Phase 1 — Requirements checklist (4a scope)

Functional

  • CREATE TABLE <name> ( <col-elements>, [table-PK] ) parses in advanced mode and produces Command::SqlCreateTable.
  • Simple-mode create table T with pk … is unchanged and still parses as Command::CreateTable (dispatch fallback verified).
  • Column element: <name> <type> [constraints…] where constraints are NOT NULL, UNIQUE, PRIMARY KEY, DEFAULT <expr>, CHECK (<expr>) — the per-column ADR-0029 set spelled in SQL.
  • Type slot: ten keywords + the §2.5 alias map; length/precision arg accepted-and-ignored.
  • INTEGER PRIMARY KEY → plain int PK (no auto-increment).
  • Table-level PRIMARY KEY (<col>, …)single and compound.
  • Single-column table-level UNIQUE(a) / CHECK(expr-over-a) accepted by normalizing into the column spec; composite UNIQUE(a,b) / multi-column table CHECK rejected "not yet supported" (→ 4a.2, §6).
  • IF NOT EXISTS → no-op-with-note on an existing table.
  • Structural execution: reuses do_create_table; the new object is a first-class playground table — __rdbms_playground_columns populated with the playground user_type, STRICT applied, CSV layer
    • project.yaml correct (ADR-0015 §6 ordering preserved).
  • One undo step per CREATE TABLE; undo removes the table + metadata; redo recreates it.
  • Errors route through the friendly layer, engine-neutral wording (unknown type; duplicate column name; the IF NOT EXISTS note).

Cross-cutting / integration

  • history.log records the literal submitted SQL line; replay re-runs it as a write (no replay-filter change — ADR §10).
  • Ambient assistance comes free from the walker: highlighting, [ERR]/[WRN], usage skeleton, completion. The node authors IdentSource::NewName on the table-name slot, IdentSource::Types on the type slot, its help_id/usage_ids, and the 4a DDL diagnostics.

Documentation

  • On 4a end-to-end validation, flip ADR-0035 status Proposed → Accepted (the ADR-0033 lifecycle) and update docs/adr/README.md in the same edit.
  • requirements.md: Q4 progresses (DDL create landing); do not mark C1 [x] (rename is 4h, advanced-only).

Testing (ADR-0008 four tiers)

  • Tier 1 (unit, in-crate): type-alias map; the ast_builder for valid/invalid CREATE TABLE shapes; column-constraint parsing; compound PK; INTEGER PRIMARY KEY→int; IF NOT EXISTS flag captured.
  • Tier 2 (insta): structure-view snapshot after a SQL create, if it adds signal over existing create-table snapshots (mirror the existing simple-mode create snapshot; skip if redundant).
  • Tier 3 (tests/sql_create_table.rs, mirror tests/sql_insert.rs): full-stack parse→dispatch→worker; assert metadata rows, CSV/yaml, describe output; alias mapping; IF NOT EXISTS no-op-with-note; plain-form duplicate error; simple-mode create still works in advanced mode (fallback).
  • Undo Tier 3 (in tests/undo_snapshots.rs or a sibling): CREATE TABLE is one undo step; undo drops the table + clears its metadata; redo recreates it identically.

4. Architecture & design

4.1 New grammar node — src/dsl/grammar/sql_create_table.rs

Mirror src/dsl/grammar/sql_insert.rs:

  • CommandNode SQL_CREATE_TABLE with entry: Word::keyword("create"), a CREATE_TABLE shape, ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), usage_ids: &["parse.usage.sql_create_table"].
  • Table-name slot: IdentSource::NewName, role "table_name", writes_table: false (the table doesn't exist yet), plus the internal-__rdbms_* / validate_user_name rejection used elsewhere.
  • After table keyword: an optional IF NOT EXISTS keyword run that sets a flag in the builder.
  • Column list: a Repeated of column-or-table elements inside ( … ).
    • Column element: name slot (IdentSource::NewName) + type slot (IdentSource::Types) + a constraint walk. The type slot consumes an optional parenthesised number/number-pair and discards it (length/precision ignored).
    • Table element (4a): PRIMARY KEY ( col, … ). No FOREIGN KEY, no inline REFERENCES in 4a (those parse-error for now; 4b adds them).
  • CHECK (<expr>) / DEFAULT <expr> reuse the ADR-0031 fragment via Node::Subgrammar(&sql_expr::SQL_OR_EXPR) (the same mechanism sql_insert.rs uses for value expressions). The matched expression is compiled to storable SQL via the existing compile_check_sql path used by do_create_table.

Register in REGISTRY (src/dsl/grammar/mod.rs, Advanced group): (&data::SQL_CREATE_TABLE, CommandCategory::Advanced) alongside the existing (&ddl::CREATE, CommandCategory::Simple). The category-grouped dispatcher then tries SQL-first in advanced mode and falls back to the simple create table … with pk … node when the SQL shape doesn't match — identical to the insert precedent. (Confirm the exact re-export path: simple CREATE is ddl::CREATE; place the new node so it's reachable as the registry expects, mirroring how SQL_INSERT is wired.)

4.2 Command AST — src/dsl/command.rs

New variant (4a shape; 4b will extend it with FK/relationship specs):

SqlCreateTable {
    name: String,
    columns: Vec<ColumnSpec>,     // reuse the existing ColumnSpec
    primary_key: Vec<String>,     // single or compound, table- or column-level
    if_not_exists: bool,
}

ColumnSpec (name, ty: Type, not_null, unique, default: Option<Value>, check: Option<Expr>) is reused unchanged — the SQL and simple paths build the same spec. Column-level PRIMARY KEY and table-level PRIMARY KEY (…) both normalise into primary_key.

4.3 Worker — src/db.rs

  • New Request::SqlCreateTable { name, columns, primary_key, if_not_exists, source: Option<String>, reply }.
  • Dispatch arm wraps in snapshot_then(snap, batch, conn, source.as_deref(), reply, || …) exactly like the CreateTable arm.
  • The closure:
    • If if_not_exists and the table already exists → no-op: return a success outcome that the runtime renders as a note rather than a structure refresh. (See 4.4.)
    • Else → call the existing do_create_table(conn, persistence, source, &name, &columns, &primary_key); structural execution, metadata, CSV, STRICT all come from the shared helper.
  • handle_request's exhaustive match forces handling the new variant — it must be wrapped, never reply.send-ed raw (the handoff must-not-forget).
  • No is_app_lifecycle_entry_word change: create is a write.

4.4 The IF NOT EXISTS no-op-with-note outcome

do_create_table returns TableDescription. For the skip path we need the runtime to show a note instead of (or alongside) a structure. Two candidate mechanisms — pick one, escalate if non-obvious:

  • (a) Worker reply is an enum CreateOutcome { Created(TableDescription), Skipped(TableDescription) }; the runtime emits the engine-neutral note on Skipped and the structure on Created.
  • (b) Reply stays TableDescription and carries an optional note: Option<NoteKey> the runtime renders.

Decided: mechanism (a) (§6.2) — explicit and reused by DROP TABLE IF EXISTS (4c). This touches the worker reply type and the runtime/event rendering; the one piece of 4a that adds plumbing beyond "mirror SqlInsert".

4.5 Type-alias map — src/dsl/types.rs

No alias map exists today (Type: FromStr covers only the ten canonical names). Add a single resolver, e.g. fn resolve_type_name(raw: &str) -> Option<Type> that lowercases, maps the §2.5 aliases onto the canonical Type, and falls through to the canonical FromStr. The grammar's type slot calls this; an unrecognised name yields the engine-neutral diagnostic/error "unknown type". Length/precision is stripped by the grammar before the name reaches the resolver.

4.6 Friendly catalog + keys — lockstep

For every new key, add a body to src/friendly/strings/en-US.yaml and an entry to KEYS_AND_PLACEHOLDERS in src/friendly/keys.rs (the keys_validate_against_catalog test enforces both). 4a keys:

  • ddl.sql_create_table — the per-command help_id body.
  • parse.usage.sql_create_table — the usage skeleton.
  • diagnostic.* DDL peers of the ADR-0033 DML diagnostics: unknown type, duplicate column name (and any 4a-relevant pre-submit checks).
  • The IF NOT EXISTS skipped note key.

All wording engine-neutral (no SQLite/STRICT/PRAGMA) — the vocab audit enforces it.

5. Out of 4a scope (no pre-emptive cuts — just later sub-phases)

  • Foreign keys: inline REFERENCES + table-level FOREIGN KEY → 4b.
  • DROP TABLE [IF EXISTS] → 4c. (IF EXISTS no-op-with-note reuses the 4.4 mechanism.)
  • Indexes, ALTER TABLE, ALTER COLUMN TYPE, table rename → 4d4h.
  • Composite UNIQUE(a,b) / multi-column table CHECK → 4a.2 (a persistence-model extension; see §6). 4a accepts single-column table-level UNIQUE(a) / CHECK(expr-over-one-col) by normalizing them into the column spec (free — ColumnSchema already carries per-column unique/check), and rejects the composite/multi-column forms with a "not yet supported" message until 4a.2 lands.
  • CREATE TABLE … AS SELECT (CTAS): not in ADR-0035's surface at all — an ordinary parse error, not a deferral.

6. Decisions settled this round (user-confirmed) + the 4a/4a.2 split

  1. 4a/4a.2 split (user-confirmed). Composite UNIQUE(a,b) and multi-column table CHECK are deferred to a dedicated slice 4a.2. Rationale (the general rule): a DDL feature needs data-model work exactly when it introduces a structure simple mode could never produce — not merely new syntax. Almost all of advanced DDL (per-column constraints, compound PK, FK, indexes, drop, alter-column) maps onto structures the model already persists (ColumnSchema, TableSchema.primary_key, RelationshipSchema, IndexSchema), so it is syntax-only + reuse. Composite UNIQUE / table CHECK are the first structures with no slot in TableSchema, so they need a model + round-trip extension and earn their own slice. Same class, already on the radar: CREATE UNIQUE INDEX (4d — IndexSchema has no unique field; ADR-0025 deferred it) and table rename (4h — a new low-level op the ADR already flags).

    4a.2 scope (its own test-first slice, after 4a core):

    • Extend TableSchema (src/persistence/mod.rs) with table-level constraint slots — e.g. unique_constraints: Vec<Vec<String>> and check_constraints: Vec<String>.
    • Extend the YAML writer/parser + RawTable (src/persistence/yaml.rs) — additive, backward-compatible, optional-on-read (the pattern used when unique/not_null/ check were added; no migration needed).
    • Extend read_schema_snapshot (src/db.rs ~2225) to detect composite UNIQUE / table CHECK from the database.
    • Extend do_create_table to emit them in the DDL.
    • Extend the SqlCreateTable command shape + grammar to carry/parse them (lifting the 4a "not yet supported" rejection).
    • Round-trip tests (save → load → rebuild) proving they survive, on top of parse/execute/undo coverage.
  2. No-op-with-note plumbing — mechanism (a) (the CreateOutcome enum, §4.4). Explicit and reused by DROP TABLE IF EXISTS (4c); chosen now so the worker reply type is designed once.

7. Devil's Advocate review of this plan

  • Does it reuse rather than fork execution? Yes — do_create_table is the single executor; SqlCreateTable only differs at the parse/AST layer and the if_not_exists branch. No drift risk. ✓
  • Is the dispatch genuinely the proven precedent? Yes — same category-grouped SQL-first/simple-fallback as insert. The plan verifies the simple-mode form still parses in advanced mode (a real fallback test, not just the SQL happy path). ✓
  • Undo actually one step? The plan wires snapshot_then exactly as the existing arms and adds a dedicated undo test (create = one step, undo drops + clears metadata, redo recreates). ✓
  • Silent scope drop? The table-level UNIQUE/CHECK gap was the one place 4a could quietly under-deliver against §4's surface — it was escalated and resolved as the 4a.2 split (§6), with composite forms rejected by an explicit "not yet supported" message (not a confusing parse error), not silently dropped. ✓
  • Engine neutrality? All new strings are catalog keys subject to the vocab audit; the plan names no engine in user-facing text. ✓
  • Tests first? §8 orders failing tests before code at every step. ✓

8. Implementation sequence (test-first throughout)

  1. Type-alias resolver — write Tier-1 tests for the §2.5 map (canonical + aliases + length-ignored + unknown→None) → red → add resolve_type_name → green.
  2. Command + parser — write Tier-1 ast_builder tests (valid columns/types/constraints, compound PK, single-column table-level UNIQUE/CHECK normalized, INTEGER PRIMARY KEY→int, IF NOT EXISTS flag; FK-shape and composite UNIQUE(a,b) / multi-column CHECK rejected "not yet supported") → red → add Command::SqlCreateTable, sql_create_table.rs, REGISTRY entry → green. Include a fallback test: simple create … with pk still parses in advanced mode.
  3. Worker — write Tier-3 tests/sql_create_table.rs (metadata, CSV, describe; alias end-to-end; IF NOT EXISTS no-op-with-note; duplicate-table plain error) → red → add Request::SqlCreateTable + snapshot_then arm + the §4.4 outcome + reuse do_create_table → green.
  4. Undo — write the undo Tier-3 test (one step; undo/redo) → red → confirm it passes (the snapshot_then wrap should make it green with no extra code; if not, the wrap is wrong) → green.
  5. Friendly catalog/keys — add the 4a keys + bodies; run keys_validate_against_catalog and the vocab audit → green.
  6. Full sweepcargo test (expect 1698 + new, 0 fail, 0 skip) and cargo clippy --all-targets -- -D warnings clean. Compare against the §1 baseline; no regressions.
  7. Docs — flip ADR-0035 to Accepted (README in lockstep); update requirements.md Q4. Propose the commit message; wait for approval.

9. Engine-neutral string notes (ADR-0002)

Every user-facing string added in 4a — help body, usage skeleton, diagnostics, the IF NOT EXISTS skipped note, the unknown-type and duplicate-column messages — refers to "the table" / "the database" / "the type", never SQLite / STRICT / PRAGMA / rusqlite. The vocab audit test is the gate.

10. Exit gate for 4a (mirrors ADR-0035 §13 / ADR-0033)

  • All §3 checklist items satisfied or explicitly escalated (§6).
  • All four tiers green, zero skips; baseline not regressed.
  • A written DA pass on the delivered slice (not just this plan).
  • ADR-0035 flipped Proposed → Accepted; requirements.md updated.

11. Next action after approval

All design questions are settled (§2, §6). Start at §8 step 1: the type-alias resolver, test-first. 4a.2 (composite UNIQUE / table CHECK, §6.1) follows 4a as its own test-first slice.