# ADR-0035: Advanced-mode SQL DDL ## Status Accepted. Design agreed with the user (2026-05-24); the approach is **validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d / 4e / 4f / 4g** (`CREATE TABLE` with column- and table-level constraints and foreign keys, `DROP TABLE [IF EXISTS]`, `CREATE [UNIQUE] INDEX` / `DROP INDEX [IF EXISTS]`, `ALTER TABLE` add/drop/rename column, `ALTER TABLE … ALTER COLUMN TYPE`, and `ALTER TABLE` add/drop constraint + add foreign key, implemented 2026-05-25 — plans `docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`, `docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`, `…-4e.md`, `…-4f.md`, `…-4g.md`), so the decision is accepted while the remaining sub-phases (**4h–4i**, §13) continue. This is **Phase 4** of the ADR-0030 roadmap (the advanced-mode SQL surface), the peer of ADR-0031 (expression grammar), ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4** on how DDL is represented and executed. **Refinements (2026-05-24, pre-implementation `/runda` round, user-confirmed).** Two open micro-calls were settled before 4a: (1) `IF [NOT] EXISTS` is **admitted** as a no-op-that-succeeds-with-a-note rather than refused — it is a near-universal cross-vendor idiom (PostgreSQL, MySQL/MariaDB, SQLite, Oracle 23ai), not an engine-specific spelling, so it belongs in the standard surface (§3/§4/§12/§13); (2) `INTEGER PRIMARY KEY` maps to a **plain `int`** primary key, *not* auto-increment — `serial` remains the sole auto-increment type (§3). ## Context ADR-0030 fixed the *architecture* of advanced mode — SQL authored as grammar in the unified tree (not a separate batch parser), with the playground's own type vocabulary and metadata model — and noted that each large grammar piece gets its own focused ADR. Phases 1–3 shipped: the SQL expression grammar (ADR-0031), full `SELECT` (ADR-0032), and DML — `INSERT`/`UPDATE`/`DELETE` (ADR-0033). Phase 4 is **DDL**: `CREATE` / `DROP` / `ALTER TABLE` and `CREATE` / `DROP INDEX`. Two things from the earlier phases shape this one: 1. **The advanced surface gets its *own* commands.** ADR-0033 established that a SQL statement produces a distinct command (`SqlInsert` / `SqlUpdate` / `SqlDelete`), separate from the simple-mode typed command for the same verb. Those DML commands execute as **validated SQL run verbatim** — possible only because DML changes no schema and touches no metadata. 2. **DDL cannot run verbatim.** If `CREATE TABLE Orders (id INTEGER)` executed as-is, the engine would make the table, but the playground would lose what the user meant: that `id` is `serial`, that a `REFERENCES` clause is a *named relationship*, that `STRICT` applies, that the ten-type vocabulary governs. Recovering that needs the parsed statement either way. ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That remains right in spirit — DDL is *structurally* executed, not raw — but it predates the DML build and read as "reuse the simple-mode `CreateTable` variant." This ADR clarifies it: **DDL gets its own advanced commands too**, executed structurally (not verbatim). The "verbatim" execution of the DML commands is an implementation convenience available only because nothing about DML required otherwise — not an architectural rule. Requirements touched: realizes `Q4` for DDL; closes the advanced-mode side of table/column/index/constraint/relationship operations; lands the table-rename half of `C1` (advanced mode only). ## Decision ### 1. Own per-statement SQL DDL commands (clarifies ADR-0030 §4) New `Command` variants, one per statement kind — granularity mirrors the DML phase: - `SqlCreateTable` - `SqlAlterTable` - `SqlDropTable` - `SqlCreateIndex` - `SqlDropIndex` They are produced by the unified grammar's `ast_builder`s in advanced mode. Unlike the DML `Sql*` commands they **execute structurally**: the handler reads the parsed structure and performs the schema change through the playground's metadata-maintaining machinery — writing `__rdbms_playground_columns` / `__rdbms_playground_relationships`, applying `STRICT`, using the ten-type vocabulary — so an advanced-mode-created object is a first-class playground object, identical to a simple-mode-created one (ADR-0030 §5). **Simple mode is untouched.** The existing typed commands (`CreateTable`, `AddColumn`, `AddRelationship`, …) and their grammar are unchanged; advanced SQL DDL is purely additive. **Execution sharing (per the user's steer).** The SQL DDL handlers **reuse the low-level schema/metadata helpers** — the table builder, the metadata writers, the rebuild-table primitive (ADR-0013) — where the underlying operation is genuinely the same, so the two surfaces cannot drift. Where the SQL path is genuinely different (e.g. a `CREATE TABLE` that declares several inline foreign keys, which has no simple-mode shape), it is implemented directly **for clarity rather than bending the simple-mode command shapes to absorb it**. Shared where it works; separate where it doesn't. ### 2. Dispatch — shared entry words, advanced-only `alter` `create` and `drop` are already simple-mode entry words. They reuse the **category-grouped, mode-aware dispatch** from ADR-0033 Amendment 1: each appears in both the `Simple` and `Advanced` groups of the `REGISTRY`; in advanced mode the SQL node is tried first and falls back to the simple node when the SQL shape doesn't match. So in advanced mode `CREATE TABLE T (id serial)` parses as SQL while `create table T with pk id(serial)` still parses as the simple form — exactly as `insert` behaves today. `alter` is a **new advanced-only entry word** (`CommandCategory::Advanced`); simple mode keeps its `add column` / `drop column` / `rename column` / `change column` verbs and gains no `alter`. ### 3. Type vocabulary (restates ADR-0030 §5) The type-name slot accepts the playground keywords directly (`text`, `int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`, `serial`, `shortid`) **and** standard-SQL aliases mapped onto them: `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)`, `numeric(10,2)`) is **accepted and ignored** — the playground's types are unparameterised. Engine storage-type names are neither accepted as input nor shown (§9). The map is purely **lexical**: `INTEGER PRIMARY KEY` becomes a plain `int` primary key — it is **not** treated as auto-increment, unlike the engine's rowid-alias idiom. Auto-increment is reached only through the explicit `serial` type (`id serial primary key`). This keeps the engine's storage behaviour from leaking into the standard surface and matches ADR-0005's single-auto-increment-type model. ### 4. The DDL surface (full; `Q4`, no pre-emptive cuts) **`CREATE TABLE ( , … )`** - **Column elements**: ` [constraints…]`, where the column constraints are the ADR-0029 set spelled in SQL: `NOT NULL`, `UNIQUE`, `PRIMARY KEY`, `DEFAULT `, `CHECK ()`, and an inline `REFERENCES () [ON DELETE …] [ON UPDATE …]` (§5). - **Table elements**: `PRIMARY KEY (, …)` (single **and compound**), `UNIQUE (, …)`, `CHECK ()`, `[CONSTRAINT ] FOREIGN KEY () REFERENCES () [ON DELETE …] [ON UPDATE …]` (§5). - `CHECK` and `DEFAULT` expressions reuse the ADR-0031 `sql_expr` grammar (the same fragment `WHERE`/`HAVING`/projections use). - `CREATE TABLE IF NOT EXISTS …` is admitted: when the table already exists the statement is a **no-op that succeeds with a note** ("table already exists — skipped") instead of the plain-form "table already exists" error. `IF NOT EXISTS` is a near-universal cross-vendor idiom, not an engine-specific spelling, so it is part of the standard surface (refines §12). **`DROP TABLE [IF EXISTS] `** → `SqlDropTable`. Cascade of inbound relationships follows the existing `drop table` semantics. `IF EXISTS` is admitted (universal across the major engines): dropping an absent table is then a **no-op that succeeds with a note** instead of the plain-form "no such table" error. **`ALTER TABLE `** → `SqlAlterTable`, where `` covers, mapping to the existing low-level operations: | SQL action | Underlying operation | |---|---| | `ADD COLUMN [constraints]` | add-column (ADR-0013 rebuild where needed) | | `DROP COLUMN ` | drop-column | | `RENAME COLUMN TO ` | rename-column | | `ALTER COLUMN TYPE ` | change-column-type (§5 conversion) | | `ADD [CONSTRAINT ] ` | add-constraint / add-relationship (FK) | | `DROP CONSTRAINT ` | drop-constraint | | `RENAME TO ` | **table rename (§6, new low-level op)** | **`CREATE [UNIQUE] INDEX [] ON (, …)`** → `SqlCreateIndex`, mapped to the ADR-0025 index machinery; `UNIQUE` sets the index's uniqueness (a small extension to ADR-0025's index model if it does not already carry the flag, called out in §13). **`DROP INDEX `** → `SqlDropIndex`. ### 5. Foreign keys → named relationships A `REFERENCES` / `FOREIGN KEY` clause is the SQL spelling of an ADR-0013 relationship. Because `SqlCreateTable` is its own command carrying the whole parsed structure, a `CREATE TABLE` that declares FK columns **creates the table and its relationship metadata together** — one statement, one command, one transaction, **one undo step** (§10). No decomposition into separate commands is needed. - `ON DELETE` / `ON UPDATE` → the ADR-0013 referential actions. - A `CONSTRAINT FOREIGN KEY …` names the relationship; an unnamed FK is auto-named by the existing ADR-0013 convention. - `ALTER TABLE child ADD [CONSTRAINT ] FOREIGN KEY () REFERENCES

(

) …` adds a relationship to an existing table (the clean 1:1 with add-relationship). - FK column type compatibility follows `Type::fk_target_type` (ADR-0011) unchanged. ### 6. Table rename — advanced mode only (`C1`) `ALTER TABLE RENAME TO ` is **advanced-mode only**; there is no simple-mode rename-table verb. It needs a genuinely new low-level operation (none exists today): within one transaction, rename the table in the database, rename its `data/
.csv` file, and update every metadata row that names it — the column-metadata rows, **both ends of any relationship** in `__rdbms_playground_relationships` that references the old name, and **the table-level CHECK rows** in `__rdbms_playground_table_checks` (added in 4a.3; keyed by `table_name`). Name validation and `__rdbms_*` rejection apply to the target. This closes the rename half of `C1` for the advanced surface. ### 7. Column type conversion — one engine, mode-appropriate policy The per-cell classification of ADR-0017 (clean / lossy / incompatible, plus static refusals for playground-type-specific targets such as `→ serial` and `↔ blob`) is a property of the **type set**, shared by both modes. The policy on the *lossy* tier differs by mode: | Tier | Simple mode | Advanced mode (`ALTER COLUMN … TYPE`) | |---|---|---| | **clean** | auto-convert | auto-convert | | **incompatible** | refuse (friendly) | refuse (friendly) — real SQL errors too | | **static-refused** (`→serial`, `↔blob`, …) | refuse | refuse — our own types have no SQL meaning to mirror | | **lossy** (`3.14`→`3`) | **refuse by default**; `--force-conversion` opts in | **perform it** (what SQL does), with a post-op "N values converted with loss" note; **no force flag** | Rationale: **simple mode protects up front; advanced mode trusts the user like SQL does and lets `undo` catch regrets.** A lossy advanced conversion is snapshotted (§10), so it is one `undo` away — there is no silent *irreversible* loss, and no need to drop to simple mode to "force". Conversions that exist only in the playground's vocabulary stay protected in both modes. The simple-mode `--force-conversion` / `--dont-convert` flags are unchanged and have **no SQL spelling** (advanced mode always performs the conversion); the Postgres `USING ` clause is **not** adopted (§12). ### 8. Constraints Column- and table-level constraints map to the ADR-0029 model: `NOT NULL`, `UNIQUE`, `PRIMARY KEY` (incl. compound, table-level), `DEFAULT `, `CHECK ()`. A populated-column constraint addition reuses ADR-0029's pre-flight dry-run guard. `CHECK` / `DEFAULT` expressions are stored as the SQL the user could re-enter in advanced mode (ADR-0030 §11) — one syntax, not a third. ### 9. Engine neutrality (ADR-0030 §7) No engine type names in or out (§3). `STRICT` is applied internally by the create path; it is not in the authored grammar, so typing it is an ordinary parse error, not a surfaced engine feature. Parse errors, out-of-subset refusals, and execution failures route through the friendly-error layer (ADR-0019) with engine-neutral wording. ### 10. Persistence, metadata, history, replay, undo - Structural execution keeps `project.yaml`, the metadata tables, and the CSV layer correct with the same guarantees as the simple-mode path (ADR-0015 §6 ordering preserved). - `history.log` records the **literal submitted SQL line**; replay re-runs it through the one walker with the advanced view active. `create` / `drop` / `alter` are **schema-write entry words, not in ADR-0034 Amendment 1's app-lifecycle skip set**, so SQL DDL **replays as a write** (re-applied) with **no replay-filter change** — unlike `undo` / `redo`, which had to be added to that skip set. - **Undo (ADR-0006):** each SQL DDL statement is a user mutation carrying a `source`, so it is snapshotted by the worker hook and is **one undo step** — including a `CREATE TABLE` with foreign keys, precisely because it is a single command (§5) rather than a decomposed sequence. ### 11. Ambient assistance comes for free (ADR-0030 §8) Because the DDL is grammar in the unified tree, the walker **mechanisms** apply with no DDL-specific assistance code: syntax highlighting, the `[ERR]`/`[WRN]` validity indicator (ADR-0027), the per-command parse-error usage skeleton (ADR-0021), and the completion engine. What each grammar node still **authors** (this is writing the grammar, not bolting assistance on afterwards): the correct `IdentSource` on every schema-name slot — so `ALTER TABLE`/`DROP TABLE`/`DROP INDEX` and `REFERENCES T(col)` / `CREATE INDEX ON T (cols)` complete from the `SchemaCache`; the per-node hint + usage catalog keys (as the app-command nodes carry `help_id` / `usage_ids`); and the DDL-specific walker diagnostics with their catalog keys — the DDL peers of the DML diagnostics ADR-0033 added (e.g. unknown type, column-already-exists, FK column-type mismatch, the §7 lossy-conversion note). The integration is structural, not free of authoring. ### 12. Out of scope - Per ADR-0030 §3: views, triggers, transaction control, `PRAGMA`, `ATTACH`/`DETACH`, `VACUUM`, virtual tables, multi-statement batches. One statement per submission; a trailing `;` is tolerated. - The Postgres `USING ` conversion clause (§7) — heavy (per-row expression evaluation), dialect-specific, and unable to express playground-type targets. - The simple-mode `--dont-convert` semantics have no SQL form (advanced `ALTER COLUMN TYPE` always converts). - The **DSL → SQL teaching echo** (ADR-0030 §10) is Phase 5, a separate ADR — not this one. - Engine-specific DDL spellings (`AUTOINCREMENT`, `WITHOUT ROWID`, collations) — the grammar admits the standard surface; extras are ordinary parse errors. (`IF [NOT] EXISTS` was **reclassified into scope** — see §4 — as a near-universal cross-vendor idiom rather than an engine-specific spelling.) ### 13. Phased implementation plan Sub-phases, each opening with the smallest end-to-end slice and each with an explicit exit gate + a written Devil's-Advocate gate, mirroring ADR-0033's structure: - **4a — Dispatch + `CREATE TABLE` core.** Advanced `create` dispatch; `SqlCreateTable` for columns + types (the §3 map, incl. the two-word `double precision` and discarded length args) + the **clean-reuse column constraints only** — `NOT NULL` / `UNIQUE` / column-level `PRIMARY KEY` — + single/compound table-level `PRIMARY KEY`, plus `IF NOT EXISTS` (no-op-with-note, §4). Reuses `do_create_table`, whose inline-PK rule is aligned with the rebuild generator `schema_to_ddl` (inline only a first-column single PK) so a created table and its rebuilt form have identical DDL; `serial` autoincrement is independent of inline-vs-table-level PK (the insert path computes the next value), verified by round-trip tests. **No FK** (4b); **no `DEFAULT`/`CHECK`/table-level `UNIQUE`** (4a.2). - **4a.2 — Per-column `CHECK`/`DEFAULT` + composite `UNIQUE(a,b)`.** Split out (2026-05-24) and re-scoped (2026-05-25, user-confirmed) to the constraints that need **no new internal table**: (1) **`CHECK`/`DEFAULT`** via the full `sql_expr` surface stored as **raw SQL text** — `sql_expr` is validate-only (no `Expr` AST for `compile_check_sql`/`ColumnSpec`), so a separate execution path captures the raw expression text; per-column `CHECK` reuses the existing `__rdbms_playground_columns.check_expr` column, `DEFAULT` round-trips via the engine's native `PRAGMA table_info`; (2) **composite `UNIQUE(a,b)`** — a new `TableSchema.unique_constraints` field, detected on read via the UNIQUE-constraint index (`PRAGMA index_list` origin `u`), round-tripped through YAML, with save/load/rebuild tests. - **4a.3 — Table-level / multi-column `CHECK(…)`.** *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4a3.md`.)* Split from 4a.2 (2026-05-25, user-confirmed) because SQLite exposes **no PRAGMA for CHECK constraints**, so a table-level CHECK cannot be read back from the engine and needs a **new `__rdbms_*` metadata table** as its source of truth (the ADR-0012/0013 pattern) — a distinct architectural step. Landed as `__rdbms_playground_table_checks (table_name, seq, check_expr)`; the builder distinguishes a table-level CHECK from a column-level one by element position (no column-def open). Composite `UNIQUE` deliberately stays PRAGMA-detected (engine-reportable, unlike CHECK). (The general rule: a DDL feature needs new model/execution work only when it introduces a structure simple mode could never produce, or an expression the structural helper cannot consume — cf. the `UNIQUE`-index flag in 4d and the rename op in 4h.) - **4b — Foreign keys in `CREATE TABLE`.** *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4b.md`.)* Inline `REFERENCES [()] [ON DELETE/UPDATE …]` + table-level `[CONSTRAINT ] FOREIGN KEY () REFERENCES …` → ADR-0013 relationship metadata, written in the create transaction (one undo step). Reuses the relationship name/uniqueness/metadata helpers shared with `add relationship`; `do_create_table` emits the `FOREIGN KEY` clause identically to `schema_to_ddl`. **Self-references** (parent = the table being created, validated against the in-statement columns/PK) and the **bare `REFERENCES `** form (resolves to the parent's single-column PK; composite → error) are both supported (user-confirmed). Inline FKs are auto-named; only the table-level form takes `CONSTRAINT `. PK-target only (UNIQUE-target deferred with `add relationship`); `Type::fk_target_type` (ADR-0011) governs type compatibility. - **4c — `DROP TABLE [IF EXISTS]`** → `SqlDropTable`. *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4c.md`.)* Reuses `do_drop_table` (cascade parity + the inbound-relationship refusal + metadata cleanup), so it matches the simple `drop table`; `IF EXISTS` on an absent table is a no-op-with-note (a new `DropOutcome::Skipped` mirroring `CreateOutcome::Skipped`; journalled, no snapshot, §4). `drop` is a shared entry word: `drop table` parses as `SqlDropTable` in advanced mode, `drop column`/`relationship`/ `index`/`constraint` fall back to the simple `drop` node. Advanced- mode `drop ` completion now surfaces the SQL `table` (the shared-entry-word behaviour from `create`, ADR-0033 Amendment 3); the DSL drops still parse via fallback — 4i grows the surface as `DROP INDEX` lands in 4d. - **4d — `CREATE [UNIQUE] INDEX` / `DROP INDEX`** → `SqlCreateIndex` / `SqlDropIndex`. *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4d.md`.)* Both reuse the ADR-0025 executors (`do_add_index` / `do_drop_index`), like 4c reused `do_drop_table`. `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [] ON (cols)` (the unnamed form auto-named per ADR-0025; the leading `[UNIQUE]` is a concrete-keyword `Choice`, the optional name an `on`-led-first selector mirroring the `drop index` positional selector) and `DROP INDEX [IF EXISTS] ` (name-only — the positional `drop index on T(…)` stays the simple form via fallback). `IF [NOT] EXISTS` reuses the 4c no-op-with-note skip (journalled, not snapshotted). **`CREATE UNIQUE INDEX` is admitted** (user-confirmed 2026-05-25): ADR-0025 deferred UNIQUE indexes for the *simple-mode DSL*, but advanced mode "trusts the user like SQL does" (§7) — so the model gains an `IndexSchema.unique` flag (additive YAML, `version` 1; rebuild re-emits `CREATE UNIQUE INDEX`; the structure view + items panel mark `[unique]`), recorded as **ADR-0025 Amendment 1**. Simple-mode `add unique index` stays deferred. `create`/`drop` each gain a *second* advanced node, exercising the all-candidates dispatch (`decide` tries every advanced candidate). - **4e — `ALTER TABLE` add/drop/rename column.** *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4e.md`.)* `alter` is a new advanced-**only** entry word (like `select`/`with`); `ALTER TABLE ADD COLUMN [NOT NULL|UNIQUE|DEFAULT| CHECK] | DROP COLUMN | RENAME COLUMN TO ` → `SqlAlterTable { AlterTableAction }`, **runtime-decomposed** to the existing `do_add_column` / `do_drop_column` / `do_rename_column` (one undo step each) — no new worker layer. The `COLUMN` keyword is required (reserves bare `RENAME TO` for 4h, `ADD CONSTRAINT` for 4g); ADD COLUMN takes column constraints only (no PK / inline REFERENCES). **`do_add_column` was extended** to consume the SQL raw-text `default_sql` / `check_sql` (DEFAULT/CHECK; `sql_expr` is validate-only — the 4a.2 mechanism), so ADD COLUMN reaches parity with `CREATE TABLE`'s column constraints. Drop/rename column now **refuse a column a CHECK references** — the 4a.3 deferral, extended (user-confirmed) to **both table-level and column-level CHECKs** — detected up-front by tokenizing the raw CHECK text (`check_references_column`, skipping string literals); for RENAME the column's *own* column-level CHECK counts (it drifts too), for DROP it does not (it drops with the column). This lives in the shared executors, so it guards **both** the simple `drop/rename column` and the SQL surface, fixing a latent rename-drift bug (a native rename rewrites the live CHECK but leaves the stored text — table-level in `__rdbms_playground_table_checks` or column-level in `__rdbms_playground_columns` — stale, breaking a later rebuild). SQL `DROP COLUMN` over an index-covered column is **refused** (no `--cascade` SQL spelling — matches SQLite + the simple default; user-confirmed). The shared column executors (and `do_add_index`) also gained an internal-`__rdbms_*`-table guard (refuse as "no such table"), closing a pre-existing exposure on both surfaces (user-confirmed). The friendly wording of the CHECK-guard refusal is **H1**. *(The broader internal-table guard on `do_change_column_type` / `do_add_constraint` / `do_add_relationship` is a tracked follow-up.)* - **4f — `ALTER TABLE … ALTER COLUMN TYPE`** (the §7 conversion model + the lossy-with-note path). *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4f.md`.)* A fourth `AlterTableAction::AlterColumnType`, runtime-decomposed to the existing `change_column_type` executor with `ChangeColumnMode::ForceConversion` — which **is** the §7 advanced policy: lossy cells are *performed* and counted (the engine-neutral `client_side.transformed_lossy` note fires), incompatible cells refuse, and the ADR-0017 static refusals (`↔ blob`, same-type, `date ↔ datetime`, non-`int → serial`) refuse in both modes. **`int → serial` is *allowed*** (auto-fills nulls, adds UNIQUE if non-PK — ADR-0018 §8; the §7 "static-refused →serial" summary is looser than the code). No force flag, no `USING`, no `SET DATA TYPE` synonym (§7/§12); `undo` is the advanced safety net. The grammar adds a fourth action branch leading on `alter`, discriminated in the builder by the **`type` keyword** (unique — ADD COLUMN's type is an ident); the type slot reuses `SQL_TYPE`. The internal-`__rdbms_*` guard was folded into `do_change_column_type` (user-confirmed 2026-05-25), closing the simple `change column` exposure too. *(The remaining internal-table guard on `do_add_constraint` / `do_add_relationship` rides in 4g.)* - **4g — `ALTER TABLE` add/drop constraint, add foreign key.** *(Implemented 2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4g.md`.)* `ALTER TABLE ADD [CONSTRAINT ] (CHECK (…) | UNIQUE (…) | FOREIGN KEY (…) REFERENCES …)` and `DROP CONSTRAINT `. **ADD scope (user- confirmed):** CHECK + composite UNIQUE + FK; `ADD PRIMARY KEY` is refused (every table already has a PK) and a **named UNIQUE** is refused (composite UNIQUE is anonymous in our model — PRAGMA-detected, §4a.2). Each ADD reuses an existing low-level path: table-CHECK and composite-UNIQUE rebuild the table (dry-run guards reject existing rows that would violate), FK decomposes to `add_relationship` (the same machinery `add 1:n relationship` uses — bare `REFERENCES

` resolves to the parent's single PK; `create_fk = false` as the column must exist). **DROP CONSTRAINT (user-confirmed)** resolves the name to a named table-CHECK then a named FK whose child is ``, else refuses. **Named table-CHECK round-trip (user-confirmed):** the `CHECK_TABLE` metadata gains a nullable `name` column (**rebuild-only** arrival — a pre-4g project gains it on `rebuild`; a named CHECK add on an un-upgraded project is refused with a friendly "rebuild first" message), and `project.yaml`'s `check_constraints` is **extended** to carry the name (`{expr, name}` mapping; the bare-string form still reads, name = `None`) so a named CHECK survives a rebuild — `rebuild` reconstructs from the yaml. The internal-`__rdbms_*` guard was folded into `do_add_constraint` / `do_add_relationship`, completing the 4d/4e/4f guard class. One undo step per statement. - **4h — `ALTER TABLE … RENAME TO`** (the §6 new low-level op). - **4i — Verification sweep.** Typing-surface + matrix coverage, engine-neutral error pass, undo-parity check (one step per statement), `help`/usage for the new forms. **Carried in from earlier slices:** (a) refresh the `CREATE TABLE` help/usage skeleton for the 4a.2 `DEFAULT`/`CHECK`/composite-`UNIQUE`, 4a.3 table-`CHECK`, and 4b FK forms (deferred from each) — **4d's index forms already carry their own help/usage** (`ddl.sql_create_index` / `ddl.sql_drop_index` + the `parse.usage.*` keys), since the nodes are new; (b) `describe` display of table-level constraints (composite `UNIQUE` + table `CHECK`) — note the **unique-*index* marker shipped in 4d** (`[unique]` in the structure view + items panel), so only the table-level *constraint* display remains here; (c) **4b self-ref FK indicator** — a `CREATE TABLE` with a self-referencing FK (`references `) parses + executes correctly, but the pre-submit schema-existence diagnostic falsely flags the not-yet-created self table as unknown (the FK parent slot is `IdentSource::Tables`). Make the diagnostic treat a FK parent equal to the `CREATE TABLE` target as valid, so the indicator stops lying for self-references. (d) **4c shared-entry-word completion merge** — in advanced mode a shared entry word surfaces only the SQL node's continuations, so `drop ` offers only `table` (not the DSL `column`/`relationship`/`index`/`constraint`) and a partial keyword like `drop rel` returns an *empty* list (a mid-word dead end), even though the DSL drops still parse + execute via fallback. Merge the expected sets of all candidate nodes for a shared entry word so advanced completion offers every valid continuation (`drop ` → table + column + relationship + index + constraint; `drop rel` → relationship); verify `create`/`insert`/`update`/`delete` completion stays sensible. **4d widened this:** `create` and `drop` now each have *two* advanced nodes (table + index), so a shared entry word's continuations now span two SQL shapes as well as the DSL ones — the merge matters more. (e) **Discussion flag (user, 2026-05-25):** before/with (d), discuss **visually distinguishing simple- vs advanced-mode completions in the hint UI (likely by colour)** so a learner can see which continuations are DSL and which are SQL — a UX design conversation, not just the mechanical merge. ## Consequences - Advanced mode reaches DDL parity with simple mode and adds table-rename, so a learner can build and evolve a whole schema in standard SQL with the playground's types, metadata, and safety intact. - The command set grows by five `Sql*` DDL variants; the worker gains their handlers, which lean on shared low-level helpers where the operation matches the simple-mode path and stand alone where the SQL surface is genuinely richer (multi-FK `CREATE TABLE`). - One genuinely new capability — table rename — adds a low-level op that the simple mode does not have; it must keep the CSV file name and the relationship metadata in step with the table name. - ADR-0030 §4 is clarified (own `Sql*` DDL commands, structurally executed); no behaviour of the shipped DML/`SELECT` phases changes. - The conversion model unifies simple and advanced without a force flag in SQL, relying on `undo` (ADR-0006) as the advanced-mode safety net — a concrete payoff of having shipped undo first. ## See also - **ADR-0030** — the advanced-mode architecture; this is its Phase 4 and clarifies §4 (DDL representation) and restates §5 (types) / §7 (neutrality) / §8 (assistance) / §11 (persistence). - **ADR-0033** — the DML phase; source of the category-grouped mode-aware dispatch (Amendment 1) reused for shared entry words. - **ADR-0031** — `sql_expr`, reused for `CHECK` / `DEFAULT`. - **ADR-0013** — relationships + the rebuild-table primitive that the `ALTER`/FK handlers build on. - **ADR-0017** — the column type-change classification §7 shares. - **ADR-0029** — column constraints; **ADR-0025** — indexes; **ADR-0011** — FK column-type compatibility; **ADR-0005** — the ten-type vocabulary. - **ADR-0006** — undo; each DDL statement is one undo step (§10).