Files
rdbms-playground/docs/plans/20260524-adr-0035-sql-ddl-4a.md
claude@clouddev1 631074ff9c feat: ADR-0035 4a — SQL CREATE TABLE command, worker, and exit gate
Command + builder + worker for advanced-mode SQL CREATE TABLE
(sub-phase 4a), executed structurally through do_create_table:

- Command::SqlCreateTable + build_sql_create_table (ddl.rs): aliases via
  from_sql_name (incl. double precision), column- and table-level
  PRIMARY KEY, redundant-flag de-dup off a sole PK, IF NOT EXISTS.
  Advanced REGISTRY entry on the shared `create` word (SQL-first, DSL
  fallback); no-PK tables allowed (user-confirmed).
- Worker (db.rs): Request::SqlCreateTable + CreateOutcome + snapshot_then
  (one undo step); IF NOT EXISTS no-op (no snapshot, but journalled, like
  read-only commands). do_create_table inline-PK rule aligned with the
  rebuild generator schema_to_ddl — no round-trip DDL drift; serial
  autoincrement is independent of inline-PK (verified by round-trip
  tests).
- Runtime/App: dispatch + CommandOutcome::SchemaSkipped +
  AppEvent::DslCreateSkipped (structure + "already exists — skipped"
  note). Friendly catalog keys added (engine-neutral).

DEFAULT/CHECK/table-level UNIQUE are absent from the 4a grammar (parse
error with usage skeleton; friendly message + support land in the 4a.2
constraint slice) — user-confirmed.

Tests: type resolver, grammar shape, builder (incl. the PK
detection bug they caught), and tests/sql_create_table.rs (worker
round-trip, serial autoincrement first/non-first across rebuild, IF NOT
EXISTS no-op + journalling, no-PK table, one undo step) + a replay-as-
write test. 1739 pass / 0 fail / 1 ignored; clippy clean.

Exit gate: ADR-0035 Proposed -> Accepted (validated end-to-end by 4a);
README + requirements.md Q1 updated.
2026-05-25 10:04:28 +00:00

414 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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> [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 (<col>, …)` — **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<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.
- **`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<String>`). Needed
because `sql_expr` is **validate-only** — it builds no `Expr` AST,
so it cannot feed `compile_check_sql(expr: &Expr)` /
`ColumnSpec.check: Option<Expr>`. 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<Vec<String>>`, `check_constraints: Vec<String>`); 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")], <type-ident +
optional (len)> ]`); 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 23 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.