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

354 lines
18 KiB
Markdown
Raw 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> [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):
```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.
- **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 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.