Three design questions settled during 4a implementation (plan + ADR §13 + README in lockstep): - CHECK/DEFAULT defer to the 4a.2 constraint slice: sql_expr is validate-only (no Expr AST), so they need raw-SQL-text storage on a separate path, not do_create_table's Expr->compile reuse. 4a.2 now also covers composite UNIQUE / multi-column table CHECK. - double precision (the lone two-word alias) handled via a keyword-pair branch; single-word aliases + discarded (len) cover the rest. - serial sole-PK in a multi-column table must inline PRIMARY KEY to keep autoincrement (worker-step do_create_table extension). 4a core narrows to columns + types + NOT NULL/UNIQUE/PRIMARY KEY + IF NOT EXISTS; everything else errors "not yet supported".
20 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> [NOT NULL] [UNIQUE] [PRIMARY KEY]— the clean-reuse constraints only.DEFAULT/CHECKare 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→ plainintPK (no auto-increment);serialPK autoincrements even in a multi-column table (§6.4).- Table-level
PRIMARY KEY (<col>, …)— single and compound. DEFAULT,CHECK, and any table-levelUNIQUE/CHECKrejected "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_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 + a constraint walk admitting onlyNOT NULL/UNIQUE/PRIMARY KEY(column-level).DEFAULT/CHECKare 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 usesType::from_sql_name; the length arg is discarded. - Table element (4a):
PRIMARY KEY ( col, … )only. NoFOREIGN KEY/ inlineREFERENCES(4b), no table-levelUNIQUE/CHECK(4a.2) — those parse-error / "not yet supported".
- Column element: name slot (
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. CHECK,DEFAULT, and all table-levelUNIQUE/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 cleando_create_tablereuse: column-levelNOT NULL,UNIQUE, andPRIMARY KEY, plus table-levelPRIMARY 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).
-
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-0031sql_exprsurface, captured as raw SQL text and stored directly (ColumnSchema. check/.defaultare alreadyString/Option<String>). Needed becausesql_expris validate-only — it builds noExprAST, so it cannot feedcompile_check_sql(expr: &Expr)/ColumnSpec.check: Option<Expr>. This is a separate execution path (raw-text, not theExpr→compile path), threaded throughSqlCreateTable+ ado_create_tablevariant — hence its own slice rather than 4a.- Composite
UNIQUE(a,b)and multi-column tableCHECK— the first structures with no slot inTableSchema; need the model + round-trip extension: extendTableSchema(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 — theunique/not_null/checkpattern, no migration); extendread_schema_snapshot(src/db.rs~2225) to detect them; extenddo_create_tableto emit them; round-trip tests (save → load → rebuild). - Until 4a.2:
CREATE TABLEwithCHECK,DEFAULT, or any table-levelUNIQUE/CHECKerrors "not yet supported" (same treatment as composite UNIQUE) — not a confusing parse error.
-
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. -
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 toType::Real. Single-word aliases + an optional discarded(len[,len])cover the rest. Delivers the ADR §3 item without bending it. -
serialPK inline emission (implementer call, step 3/worker).do_create_tabletoday inlinesPRIMARY KEY(the rowid-alias that makesserialautoincrement) only when the table has one column. SQL mode allowsCREATE TABLE t (id serial primary key, name text)— aserialsole-PK in a multi-column table — which would otherwise get a table-level PK and lose autoincrement. Step 3 extends the inline condition to "sole-PK column whose type isserial→ inlinePRIMARY KEYon that column," leaving the simple-mode paths (single-column, or compound) unchanged. Covered by a worker test. -
Redundant PK constraints (implementer call). SQL mode is lenient like real engines:
id int primary key not null/… uniqueis accepted, and the builder silently de-dups the redundantnot_null/uniqueflag off a sole-PK column (so the emitted DDL/metadata stay clean fordo_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.)
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 incl. aliases +double precision+ discarded(len), column-levelNOT NULL/UNIQUE/PRIMARY KEY, compound table-level PK,INTEGER PRIMARY KEY→int,IF NOT EXISTSflag;DEFAULT/CHECK/table-levelUNIQUE/FK-shape rejected "not yet supported") → red → addCommand::SqlCreateTable,sql_create_table.rs, REGISTRY entry, exhaustive-match stubs → green. Include a fallback test: simplecreate … with pkstill parses in advanced mode. (Compiles only once the worker dispatch handles the newCommand, so steps 2–3 land together.) - Worker — write Tier-3
tests/sql_create_table.rs(metadata, CSV, describe; alias end-to-end;serialPK autoincrements in a multi-column table, §6.4;IF NOT EXISTSno-op-with-note; duplicate-table plain error; redundant PK constraint de-duped, §6.5) → red → addRequest::SqlCreateTable+snapshot_thenarm + the §4.4CreateOutcome+ the §6.4do_create_tableserial-inline extension + 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.