Files
rdbms-playground/docs/plans/20260524-adr-0035-sql-ddl-4a.md
T
claude@clouddev1 94ec87b2ff docs: ADR-0035 4a — refine scope (CHECK/DEFAULT to constraint slice; double-precision; serial-inline)
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".
2026-05-25 07:55:22 +00:00

20 KiB
Raw Blame History

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_tablenot 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/bigintint, varchar/chartext, booleanbool, timestampdatetime, numericdecimal, float/double precisionreal, binary/varbinaryblob); 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):

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. serial PK inline emission (implementer call, step 3/worker). do_create_table today inlines PRIMARY KEY (the rowid-alias that makes serial autoincrement) only when the table has one column. SQL mode allows CREATE TABLE t (id serial primary key, name text) — a serial sole-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 is serial → inline PRIMARY KEY on that column," leaving the simple-mode paths (single-column, or compound) unchanged. Covered by a worker test.

  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.)

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 sweepcargo 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.