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.
18 KiB
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```ignoredoctest). Clippy clean (cargo clippy --all-targets -- -D warnings). - Branch
main; last commit19d3cd3(the ADR-0035/rundarefinements). 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:
- Own command, structural execution.
SqlCreateTableis its ownCommand/Requestvariant (not a reuse ofCommand::CreateTable). It executes structurally by reusing the low-level helperdo_create_table— not verbatim SQL. Simple mode is untouched. IF NOT EXISTSis 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.)INTEGER PRIMARY KEY→ plainintPK. The type map is purely lexical; auto-increment is reachable only via the explicitserialtype. (ADR §3.)- Dispatch:
createis a shared entry word — it staysCommandCategory::Simpleforddl::CREATEand gains anAdvancedentry for the new SQL node, exactly asinsert/update/deletedo (ADR-0033 Amendment 1: SQL-first in advanced, simple fallback). - 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. - Undo:
SqlCreateTableis a user mutation carrying asource, wrapped insnapshot_thenlike the existing 19 mutating arms → one undo step.createneeds nois_app_lifecycle_entry_wordchange (it's a write).
3. Phase 1 — Requirements checklist (4a scope)
Functional
CREATE TABLE <name> ( <col-elements>, [table-PK] )parses in advanced mode and producesCommand::SqlCreateTable.- Simple-mode
create table T with pk …is unchanged and still parses asCommand::CreateTable(dispatch fallback verified). - Column element:
<name> <type> [constraints…]where constraints areNOT 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→ plainintPK (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; compositeUNIQUE(a,b)/ multi-column tableCHECKrejected "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_columnspopulated with the playgrounduser_type,STRICTapplied, CSV layerproject.yamlcorrect (ADR-0015 §6 ordering preserved).
- One undo step per
CREATE TABLE;undoremoves the table + metadata;redorecreates it. - Errors route through the friendly layer, engine-neutral wording
(unknown type; duplicate column name; the
IF NOT EXISTSnote).
Cross-cutting / integration
history.logrecords 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 authorsIdentSource::NewNameon the table-name slot,IdentSource::Typeson the type slot, itshelp_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.mdin the same edit. requirements.md:Q4progresses (DDL create landing); do not markC1[x](rename is 4h, advanced-only).
Testing (ADR-0008 four tiers)
- Tier 1 (unit, in-crate): type-alias map; the
ast_builderfor valid/invalidCREATE TABLEshapes; column-constraint parsing; compound PK;INTEGER PRIMARY KEY→int;IF NOT EXISTSflag 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, mirrortests/sql_insert.rs): full-stack parse→dispatch→worker; assert metadata rows, CSV/yaml,describeoutput; alias mapping;IF NOT EXISTSno-op-with-note; plain-form duplicate error; simple-mode create still works in advanced mode (fallback). - Undo Tier 3 (in
tests/undo_snapshots.rsor a sibling):CREATE TABLEis 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:
CommandNodeSQL_CREATE_TABLEwithentry: Word::keyword("create"), aCREATE_TABLEshape,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_namerejection used elsewhere. - After
tablekeyword: an optionalIF NOT EXISTSkeyword run that sets a flag in the builder. - Column list: a
Repeatedof 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, … ). NoFOREIGN KEY, no inlineREFERENCESin 4a (those parse-error for now; 4b adds them).
- Column element: name slot (
CHECK (<expr>)/DEFAULT <expr>reuse the ADR-0031 fragment viaNode::Subgrammar(&sql_expr::SQL_OR_EXPR)(the same mechanismsql_insert.rsuses for value expressions). The matched expression is compiled to storable SQL via the existingcompile_check_sqlpath used bydo_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 theCreateTablearm. - The closure:
- If
if_not_existsand 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,STRICTall come from the shared helper.
- If
handle_request's exhaustive match forces handling the new variant — it must be wrapped, neverreply.send-ed raw (the handoff must-not-forget).- No
is_app_lifecycle_entry_wordchange:createis 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 onSkippedand the structure onCreated. - (b) Reply stays
TableDescriptionand carries an optionalnote: 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-commandhelp_idbody.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 EXISTSskipped 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-levelFOREIGN KEY→ 4b. DROP TABLE [IF EXISTS]→ 4c. (IF EXISTSno-op-with-note reuses the 4.4 mechanism.)- Indexes,
ALTER TABLE,ALTER COLUMN TYPE, table rename → 4d–4h. - Composite
UNIQUE(a,b)/ multi-column tableCHECK→ 4a.2 (a persistence-model extension; see §6). 4a accepts single-column table-levelUNIQUE(a)/CHECK(expr-over-one-col)by normalizing them into the column spec (free —ColumnSchemaalready carries per-columnunique/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
-
4a/4a.2 split (user-confirmed). Composite
UNIQUE(a,b)and multi-column tableCHECKare 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 inTableSchema, so they need a model + round-trip extension and earn their own slice. Same class, already on the radar:CREATE UNIQUE INDEX(4d —IndexSchemahas nouniquefield; 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>>andcheck_constraints: Vec<String>. - Extend the YAML writer/parser +
RawTable(src/persistence/yaml.rs) — additive, backward-compatible, optional-on-read (the pattern used whenunique/not_null/checkwere added; no migration needed). - Extend
read_schema_snapshot(src/db.rs~2225) to detect composite UNIQUE / table CHECK from the database. - Extend
do_create_tableto emit them in the DDL. - Extend the
SqlCreateTablecommand 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.
- Extend
-
No-op-with-note plumbing — mechanism (a) (the
CreateOutcomeenum, §4.4). Explicit and reused byDROP 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_tableis the single executor;SqlCreateTableonly differs at the parse/AST layer and theif_not_existsbranch. 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_thenexactly 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)
- Type-alias resolver — write Tier-1 tests for the §2.5 map
(canonical + aliases + length-ignored + unknown→None) → red → add
resolve_type_name→ green. - Command + parser — write Tier-1
ast_buildertests (valid columns/types/constraints, compound PK, single-column table-levelUNIQUE/CHECKnormalized,INTEGER PRIMARY KEY→int,IF NOT EXISTSflag; FK-shape and compositeUNIQUE(a,b)/ multi-columnCHECKrejected "not yet supported") → red → addCommand::SqlCreateTable,sql_create_table.rs, REGISTRY entry → green. Include a fallback test: simplecreate … with pkstill parses in advanced mode. - Worker — write Tier-3
tests/sql_create_table.rs(metadata, CSV, describe; alias end-to-end;IF NOT EXISTSno-op-with-note; duplicate-table plain error) → red → addRequest::SqlCreateTable+snapshot_thenarm + the §4.4 outcome + reusedo_create_table→ green. - Undo — write the undo Tier-3 test (one step; undo/redo) → red →
confirm it passes (the
snapshot_thenwrap should make it green with no extra code; if not, the wrap is wrong) → green. - Friendly catalog/keys — add the 4a keys + bodies; run
keys_validate_against_catalogand the vocab audit → green. - Full sweep —
cargo test(expect 1698 + new, 0 fail, 0 skip) andcargo clippy --all-targets -- -D warningsclean. Compare against the §1 baseline; no regressions. - Docs — flip ADR-0035 to Accepted (README in lockstep);
update
requirements.mdQ4. 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.mdupdated.
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.