# 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_table` — *not* 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`/`bigint`→`int`, `varchar`/`char`→`text`, `boolean`→`bool`, `timestamp`→`datetime`, `numeric`→`decimal`, `float`/`double precision`→`real`, `binary`/`varbinary`→`blob`); 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 ( , [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: ` [NOT NULL] [UNIQUE] [PRIMARY KEY]` — the clean-reuse constraints only. `DEFAULT` / `CHECK` are **not** in 4a (→ 4a.2, §6.1). - [ ] Type slot: ten keywords + the §2.5 alias map (incl. the two-word `double precision`, §6.3); length/precision arg accepted-and-ignored. - [ ] `INTEGER PRIMARY KEY` → plain `int` PK (no auto-increment); `serial` PK autoincrements even in a multi-column table (§6.4). - [ ] Table-level `PRIMARY KEY (, …)` — **single and compound**. - [ ] `DEFAULT`, `CHECK`, and any table-level `UNIQUE`/`CHECK` rejected "not yet supported" (→ 4a.2, §6.1). - [ ] `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 + a constraint walk admitting only `NOT NULL` / `UNIQUE` / `PRIMARY KEY` (column-level). `DEFAULT` / `CHECK` are **not** in 4a (→ 4a.2); typing them is "not yet supported". - Type slot: `Choice[ Seq[Word("double"), Word("precision")], Seq[ Ident{source: Types, validator: SQL_TYPE_VALIDATOR}, Optional<( number [, number] )> ] ]` (§6.3). The validator uses `Type::from_sql_name`; the length arg is discarded. - Table element (4a): `PRIMARY KEY ( col, … )` only. **No** `FOREIGN KEY` / inline `REFERENCES` (4b), **no** table-level `UNIQUE`/`CHECK` (4a.2) — those parse-error / "not yet supported". 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): ```rust SqlCreateTable { name: String, columns: Vec, // reuse the existing ColumnSpec primary_key: Vec, // single or compound, table- or column-level if_not_exists: bool, } ``` `ColumnSpec` (`name`, `ty: Type`, `not_null`, `unique`, `default: Option`, `check: Option`) 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, 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` 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` 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 → 4d–4h. - **`CHECK`, `DEFAULT`, and all table-level `UNIQUE`/`CHECK` → 4a.2** (the constraint slice; see §6.1). 4a rejects them with a "not yet supported" message. 4a keeps only the constraints that are a clean `do_create_table` reuse: column-level `NOT NULL`, `UNIQUE`, and `PRIMARY KEY`, plus table-level `PRIMARY KEY (cols)`. - `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 (all user-confirmed unless noted) **The general rule (the litmus test).** A DDL feature needs data-model or new-execution work exactly when it introduces a **structure simple mode could never produce, or an expression representation the structural helper can't consume** — not merely new syntax. Almost all of advanced DDL (per-column NOT NULL/UNIQUE, compound PK, FK, indexes, drop, alter-column) maps onto structures the model already persists (`ColumnSchema`, `TableSchema.primary_key`, `RelationshipSchema`, `IndexSchema`) and feeds the existing helpers, so it is syntax-only + reuse. The exceptions 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 flags). 1. **The constraint slice 4a.2 (user-confirmed).** A dedicated test-first slice, after 4a core, gathering every constraint that is *not* a clean reuse: - **`CHECK` / `DEFAULT`** — via the full ADR-0031 `sql_expr` surface, captured as **raw SQL text** and stored directly (`ColumnSchema. check` / `.default` are already `String`/`Option`). Needed because `sql_expr` is **validate-only** — it builds no `Expr` AST, so it cannot feed `compile_check_sql(expr: &Expr)` / `ColumnSpec.check: Option`. This is a **separate execution path** (raw-text, not the `Expr`→compile path), threaded through `SqlCreateTable` + a `do_create_table` variant — hence its own slice rather than 4a. - **Composite `UNIQUE(a,b)` and multi-column table `CHECK`** — the first structures with no slot in `TableSchema`; need the model + round-trip extension: extend `TableSchema` (`src/persistence/mod.rs`, e.g. `unique_constraints: Vec>`, `check_constraints: Vec`); extend the YAML writer/parser + `RawTable` (`src/persistence/yaml.rs`, additive/backward-compatible/optional-on-read — the `unique`/`not_null`/`check` pattern, **no migration**); extend `read_schema_snapshot` (`src/db.rs` ~2225) to **detect** them; extend `do_create_table` to **emit** them; **round-trip tests** (save → load → rebuild). - Until 4a.2: `CREATE TABLE` with `CHECK`, `DEFAULT`, or any table-level `UNIQUE`/`CHECK` errors **"not yet supported"** (same treatment as composite UNIQUE) — not a confusing parse error. 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. 3. **`double precision` (implementer call).** The lone two-word alias is handled by a dedicated keyword-pair branch in the type slot (`Choice[ Seq[Word("double"), Word("precision")], ]`); the builder maps the word-pair to `Type::Real`. Single-word aliases + an optional discarded `(len[,len])` cover the rest. Delivers the ADR §3 item without bending it. 4. **`do_create_table` inline-PK rule aligned with `schema_to_ddl` (implementer call, step 3/worker; corrected by the `/runda` probe).** *Original premise (wrong):* a `serial` sole-PK in a multi-column SQL table needs an inline `PRIMARY KEY` (rowid-alias) or it loses autoincrement. **A round-trip probe disproved this:** after a rebuild the table gets a *table-level* PK (`schema_to_ddl` only inlines a first-column PK), yet `serial` still autoincremented — the insert path computes the next value itself, independent of rowid-alias. *Actual fix:* `do_create_table` now uses the **same inline rule as `schema_to_ddl`** — inline only a single PK that is the **first column** — so a freshly-created table and its rebuilt form have identical DDL (no round-trip drift, the real latent issue a multi-column SQL create would have exposed). Simple-mode paths (always single-column create + `add column`) are unchanged. Covered by two round-trip tests (serial PK as first **and** non-first column survive a rebuild). 5. **Redundant PK constraints (implementer call).** SQL mode is **lenient** like real engines: `id int primary key not null` / `… unique` is accepted, and the builder silently de-dups the redundant `not_null`/`unique` flag off a sole-PK column (so the emitted DDL/metadata stay clean for `do_create_table`). Simple mode's ADR-0029 §9 *rejection* is unchanged. (Diverges from simple mode deliberately, matching the advanced-mode "trust the user like SQL" posture, ADR-0035 §7.) 6. **Deferred constraints surface as a parse error (user-confirmed 2026-05-25).** `DEFAULT` / `CHECK` / table-level `UNIQUE` are absent from the 4a grammar, so typing them is an ordinary parse error — the ADR-0021 usage skeleton lists the supported `CREATE TABLE` form, implicitly communicating what is not yet available. A bespoke "not yet supported" message needs the deferred expression-parsing work and so lands with 4a.2, when those shapes are added. 7. **No-PK tables allowed in advanced mode (user-confirmed 2026-05-25).** `create table t (id int)` with no primary key is accepted (standard SQL; the §7 trust posture), unlike simple mode which requires/defaults a PK. Pinned by a worker test. 8. **No-op skip is journalled (`/runda` fix).** A successful `CREATE TABLE IF NOT EXISTS` no-op appends its line to `history.log` like other read-only/no-op commands (`show table`) — the complete journal (ADR-0034) — while taking no undo snapshot. ## 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 incl. aliases + `double precision` + discarded `(len)`, column-level `NOT NULL`/`UNIQUE`/`PRIMARY KEY`, compound table-level PK, `INTEGER PRIMARY KEY`→int, `IF NOT EXISTS` flag; `DEFAULT`/`CHECK`/table-level `UNIQUE`/FK-shape rejected "not yet supported") → red → add `Command::SqlCreateTable`, `sql_create_table.rs`, REGISTRY entry, exhaustive-match stubs → green. Include a fallback test: simple `create … with pk` still parses in advanced mode. *(Compiles only once the worker dispatch handles the new `Command`, so steps 2–3 land together.)* 3. **Worker** — write Tier-3 `tests/sql_create_table.rs` (metadata, CSV, describe; alias end-to-end; `serial` PK autoincrements in a multi-column table, §6.4; `IF NOT EXISTS` no-op-with-note; duplicate-table plain error; redundant PK constraint de-duped, §6.5) → red → add `Request::SqlCreateTable` + `snapshot_then` arm + the §4.4 `CreateOutcome` + the §6.4 `do_create_table` serial-inline extension + 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 sweep** — `cargo 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.