Realises ADR-0030 §10 (the DSL→SQL teaching bridge) as a /runda'd design
set, before implementation:
- ADR-0037 (new): execution-time mode side-channel — SubmissionMode
{Simple, Advanced, AdvancedOneShot} threaded Action→worker, output-only;
redeems ADR-0033 Amendment 3's deferred follow-up. Replay stays silent.
- ADR-0038 (new): the teaching echo + full catalogue (Buckets A/B/C),
the copy-paste round-trip contract, the three-category framework, and
the Value→SQL-literal renderer. DDL + show-data centric (overlapping
DML is SQL-first, so already SQL). Build-order deps recorded.
- ADR-0035 Amendment 2: standard-first dialect stance + ALTER COLUMN
SET/DROP NOT NULL, SET/DROP DEFAULT, ISO SET DATA TYPE gap-fill.
- ADR-0033 Amendment 4: reclassifies the `update … --all-rows`
non-fall-back as a bug; it now falls back to the DSL Update and echoes
(keyed on adjacent `--`; spaced arithmetic preserved).
- ADR-0039 (new): EXPLAIN over advanced SQL — decision recorded, build
deferred; supersedes ADR-0030 §13 OOS-2.
- ADR-0000: out-of-scope discipline (deferred vs rejected). README index
updated for all of the above.
Reconcile CLAUDE.md: simple-mode column ops are implemented, not pending
(requirements.md C2/B2 already [x]).
42 KiB
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 / 4h / 4i (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, ALTER TABLE
add/drop constraint + add foreign key, ALTER TABLE … RENAME TO, and the
4i verification sweep — completion merge, simple/advanced completion
colour, describe of table-level constraints, self-ref FK indicator, and
the CREATE-TABLE help/usage refresh — implemented 2026-05-25/26 — 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,
docs/plans/20260526-adr-0035-sql-ddl-4h.md,
docs/plans/20260526-adr-0035-sql-ddl-4i.md). Phase 4 is complete
(4a–4i all shipped). Amendment 1 (2026-05-26) adds a way to drop a
composite UNIQUE via a derived, engine-neutral name (unique_<cols>)
that reuses the existing DROP CONSTRAINT <name> grammar — no new
syntax, no metadata, the §4g anonymity decision intact (see the amendment
below). 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:
- 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. - 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: thatidisserial, that aREFERENCESclause is a named relationship, thatSTRICTapplies, 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:
SqlCreateTableSqlAlterTableSqlDropTableSqlCreateIndexSqlDropIndex
They are produced by the unified grammar's ast_builders 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 <name> ( <element>, … )
- Column elements:
<name> <type> [constraints…], where the column constraints are the ADR-0029 set spelled in SQL:NOT NULL,UNIQUE,PRIMARY KEY,DEFAULT <expr>,CHECK (<expr>), and an inlineREFERENCES <T>(<col>) [ON DELETE …] [ON UPDATE …](§5). - Table elements:
PRIMARY KEY (<col>, …)(single and compound),UNIQUE (<col>, …),CHECK (<expr>),[CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES <T>(<col>) [ON DELETE …] [ON UPDATE …](§5). CHECKandDEFAULTexpressions reuse the ADR-0031sql_exprgrammar (the same fragmentWHERE/HAVING/projections use).CREATE TABLE IF NOT EXISTS <name> …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 EXISTSis 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] <name> → 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 <name> <action> → SqlAlterTable, where <action>
covers, mapping to the existing low-level operations:
| SQL action | Underlying operation |
|---|---|
ADD COLUMN <name> <type> [constraints] |
add-column (ADR-0013 rebuild where needed) |
DROP COLUMN <name> |
drop-column |
RENAME COLUMN <old> TO <new> |
rename-column |
ALTER COLUMN <name> TYPE <type> |
change-column-type (§5 conversion) |
ADD [CONSTRAINT <name>] <table-constraint> |
add-constraint / add-relationship (FK) |
DROP CONSTRAINT <name> |
drop-constraint |
RENAME TO <new> |
table rename (§6, new low-level op) |
CREATE [UNIQUE] INDEX [<name>] ON <table> (<col>, …) →
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 <name> → 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 <name> FOREIGN KEY …names the relationship; an unnamed FK is auto-named by the existing ADR-0013 convention. ALTER TABLE child ADD [CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES <P>(<col>) …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 <old> RENAME TO <new> 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/<table>.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 <expr> 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 <expr>, CHECK (<expr>). 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.logrecords the literal submitted SQL line; replay re-runs it through the one walker with the advanced view active.create/drop/alterare 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 — unlikeundo/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 aCREATE TABLEwith 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 <expr>conversion clause (§7) — heavy (per-row expression evaluation), dialect-specific, and unable to express playground-type targets. - The simple-mode
--dont-convertsemantics have no SQL form (advancedALTER COLUMN TYPEalways 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] EXISTSwas 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 TABLEcore. Advancedcreatedispatch;SqlCreateTablefor columns + types (the §3 map, incl. the two-worddouble precisionand discarded length args) + the clean-reuse column constraints only —NOT NULL/UNIQUE/ column-levelPRIMARY KEY— + single/compound table-levelPRIMARY KEY, plusIF NOT EXISTS(no-op-with-note, §4). Reusesdo_create_table, whose inline-PK rule is aligned with the rebuild generatorschema_to_ddl(inline only a first-column single PK) so a created table and its rebuilt form have identical DDL;serialautoincrement is independent of inline-vs-table-level PK (the insert path computes the next value), verified by round-trip tests. No FK (4b); noDEFAULT/CHECK/table-levelUNIQUE(4a.2). - 4a.2 — Per-column
CHECK/DEFAULT+ compositeUNIQUE(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/DEFAULTvia the fullsql_exprsurface stored as raw SQL text —sql_expris validate-only (noExprAST forcompile_check_sql/ColumnSpec), so a separate execution path captures the raw expression text; per-columnCHECKreuses the existing__rdbms_playground_columns.check_exprcolumn,DEFAULTround-trips via the engine's nativePRAGMA table_info; (2) compositeUNIQUE(a,b)— a newTableSchema.unique_constraintsfield, detected on read via the UNIQUE-constraint index (PRAGMA index_listoriginu), round-tripped through YAML, with save/load/rebuild tests. - 4a.3 — Table-level / multi-column
CHECK(…). (Implemented 2026-05-25 — plandocs/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). CompositeUNIQUEdeliberately 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. theUNIQUE-index flag in 4d and the rename op in 4h.) - 4b — Foreign keys in
CREATE TABLE. (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4b.md.) InlineREFERENCES <parent>[(<col>)] [ON DELETE/UPDATE …]+ table-level[CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES …→ ADR-0013 relationship metadata, written in the create transaction (one undo step). Reuses the relationship name/uniqueness/metadata helpers shared withadd relationship;do_create_tableemits theFOREIGN KEYclause identically toschema_to_ddl. Self-references (parent = the table being created, validated against the in-statement columns/PK) and the bareREFERENCES <parent>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 takesCONSTRAINT <name>. PK-target only (UNIQUE-target deferred withadd relationship);Type::fk_target_type(ADR-0011) governs type compatibility. - 4c —
DROP TABLE [IF EXISTS]→SqlDropTable. (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4c.md.) Reusesdo_drop_table(cascade parity + the inbound-relationship refusal + metadata cleanup), so it matches the simpledrop table;IF EXISTSon an absent table is a no-op-with-note (a newDropOutcome::SkippedmirroringCreateOutcome::Skipped; journalled, no snapshot, §4).dropis a shared entry word:drop tableparses asSqlDropTablein advanced mode,drop column/relationship/index/constraintfall back to the simpledropnode. Advanced- modedropcompletion now surfaces the SQLtable(the shared-entry-word behaviour fromcreate, ADR-0033 Amendment 3); the DSL drops still parse via fallback — 4i grows the surface asDROP INDEXlands in 4d. - 4d —
CREATE [UNIQUE] INDEX/DROP INDEX→SqlCreateIndex/SqlDropIndex. (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4d.md.) Both reuse the ADR-0025 executors (do_add_index/do_drop_index), like 4c reuseddo_drop_table.CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)(the unnamed form auto-named per ADR-0025; the leading[UNIQUE]is a concrete-keywordChoice, the optional name anon-led-first selector mirroring thedrop indexpositional selector) andDROP INDEX [IF EXISTS] <name>(name-only — the positionaldrop index on T(…)stays the simple form via fallback).IF [NOT] EXISTSreuses the 4c no-op-with-note skip (journalled, not snapshotted).CREATE UNIQUE INDEXis 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 anIndexSchema.uniqueflag (additive YAML,version1; rebuild re-emitsCREATE UNIQUE INDEX; the structure view + items panel mark[unique]), recorded as ADR-0025 Amendment 1. Simple-modeadd unique indexstays deferred.create/dropeach gain a second advanced node, exercising the all-candidates dispatch (decidetries every advanced candidate). - 4e —
ALTER TABLEadd/drop/rename column. (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4e.md.)alteris a new advanced-only entry word (likeselect/with);ALTER TABLE <T> ADD COLUMN <col> <type> [NOT NULL|UNIQUE|DEFAULT| CHECK] | DROP COLUMN <col> | RENAME COLUMN <old> TO <new>→SqlAlterTable { AlterTableAction }, runtime-decomposed to the existingdo_add_column/do_drop_column/do_rename_column(one undo step each) — no new worker layer. TheCOLUMNkeyword is required (reserves bareRENAME TOfor 4h,ADD CONSTRAINTfor 4g); ADD COLUMN takes column constraints only (no PK / inline REFERENCES).do_add_columnwas extended to consume the SQL raw-textdefault_sql/check_sql(DEFAULT/CHECK;sql_expris validate-only — the 4a.2 mechanism), so ADD COLUMN reaches parity withCREATE 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 simpledrop/rename columnand 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_checksor column-level in__rdbms_playground_columns— stale, breaking a later rebuild). SQLDROP COLUMNover an index-covered column is refused (no--cascadeSQL spelling — matches SQLite + the simple default; user-confirmed). The shared column executors (anddo_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 ondo_change_column_type/do_add_constraint/do_add_relationshipis a tracked follow-up.) - 4f —
ALTER TABLE … ALTER COLUMN TYPE(the §7 conversion model + the lossy-with-note path). (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4f.md.) A fourthAlterTableAction::AlterColumnType, runtime-decomposed to the existingchange_column_typeexecutor withChangeColumnMode::ForceConversion— which is the §7 advanced policy: lossy cells are performed and counted (the engine-neutralclient_side.transformed_lossynote fires), incompatible cells refuse, and the ADR-0017 static refusals (↔ blob, same-type,date ↔ datetime, non-int → serial) refuse in both modes.int → serialis 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, noUSING, noSET DATA TYPEsynonym (§7/§12);undois the advanced safety net. The grammar adds a fourth action branch leading onalter, discriminated in the builder by thetypekeyword (unique — ADD COLUMN's type is an ident); the type slot reusesSQL_TYPE. The internal-__rdbms_*guard was folded intodo_change_column_type(user-confirmed 2026-05-25), closing the simplechange columnexposure too. (The remaining internal-table guard ondo_add_constraint/do_add_relationshiprides in 4g.) - 4g —
ALTER TABLEadd/drop constraint, add foreign key. (Implemented 2026-05-25 — plandocs/plans/20260525-adr-0035-sql-ddl-4g.md.)ALTER TABLE <T> ADD [CONSTRAINT <name>] (CHECK (…) | UNIQUE (…) | FOREIGN KEY (…) REFERENCES …)andDROP CONSTRAINT <name>. ADD scope (user- confirmed): CHECK + composite UNIQUE + FK;ADD PRIMARY KEYis 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 toadd_relationship(the same machineryadd 1:n relationshipuses — bareREFERENCES <P>resolves to the parent's single PK;create_fk = falseas the column must exist). DROP CONSTRAINT (user-confirmed) resolves the name to a named table-CHECK then a named FK whose child is<T>, else refuses. Named table-CHECK round-trip (user-confirmed): theCHECK_TABLEmetadata gains a nullablenamecolumn (rebuild-only arrival — a pre-4g project gains it onrebuild; a named CHECK add on an un-upgraded project is refused with a friendly "rebuild first" message), andproject.yaml'scheck_constraintsis extended to carry the name ({expr, name}mapping; the bare-string form still reads, name =None) so a named CHECK survives a rebuild —rebuildreconstructs from the yaml. The internal-__rdbms_*guard was folded intodo_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). (Implemented 2026-05-26 — plandocs/plans/20260526-adr-0035-sql-ddl-4h.md.) The one genuinely new low-level executor in Phase 4 (do_rename_table): a native engineRENAME TO(structure-preserving — no rebuild) plus reconciliation, in one transaction (commit-db-last), of everything the engine does not track — every metadata row that names the table (__rdbms_playground_columns, both ends of__rdbms_playground_relationships,__rdbms_playground_table_checks), the CSV file (via the existing persistence rewrite+delete path:rewritten_tables = [new],deleted_tables = [old]— no new persistence method), and CHECK text that qualifies a column with the old table name (T.age→U.age, both column- and table-level — a planning-/rundafinding: the engine rewrites the live CHECK but the stored text would drift and break a fresh rebuild;rewrite_check_table_qualifierkeeps them in step; bounded because a CHECK references only its own table). Grammar: a fifth action,AlterTableAction::RenameTable { new }, added by splitting therenameverb into one branch with an innerChoiceon a distinct second keyword (column→ rename-column,to→ rename-table — the §6.1 trap-safe pattern); the new-name slot mirrors theCREATE TABLEname slot (IdentSource::NewName+ thereject_internal_tableparse validator). Refusals (user-confirmed 2026-05-26): rename to the same name, to an existing other table, to an__rdbms_*name, or of a non-existent table. Collision checks are case-insensitive (the engine matches names that way), with an engine-neutral pre-check so a case-only rename or a case-insensitive clash never surfaces the raw engine error (a finished-slice/rundafinding). Auto-named indexes and relationships keep their stale names (only the table-name columns update; ADR-0035 §6 scope — user-confirmed; documented collision caveat). One undo step (the whole-project snapshot). Advanced-mode only; closes the rename half ofC1. - 4i — Verification sweep (completes Phase 4). (Implemented
2026-05-26 — plan
docs/plans/20260526-adr-0035-sql-ddl-4i.md.)- (a)
CREATE TABLEhelp/usage skeleton refreshed for the 4a.2DEFAULT/CHECK/composite-UNIQUE, 4a.3 table-CHECK, and 4b FK forms (the index forms already carried their own since 4d). - (b)
describeof table-level constraints —TableDescriptiongainedunique_constraints+check_constraints, rendered in a "Table constraints:" section (compositeUNIQUE, tableCHECKincl. named CHECKs). The per-column[unique]-index marker shipped in 4d. - (c) self-ref FK indicator —
schema_existence_diagnosticscollects theCREATE TABLEtarget(s) (IdentSource::NewName, roletable_name) and exempts aTablesreference matching one from the unknown-table flag, so a self-referencing FK no longer pre-flags the not-yet-created table; a FK to a genuinely-unknown other table still flags. - (d) shared-entry-word completion merge — at the advanced-mode
entry-word boundary,
completion_probe_in_modewalks every candidate node and unions the viable (Incomplete) ones' continuations, sodropofferstable·index·column·relationship·constraintanddrop rel→relationship(was an empty dead-end). Completion-only (the parse path is untouched); deeper positions keep the committed walk. - (e) simple-vs-advanced completion colour — each continuation is
classified
Both/Advanced/Simpleand, only when the candidate list mixes modes, coloured (mode_advanced/mode_simple,Both= token-kind) and block-orderedBoth → Advanced → Simple(user-chosen design, 2026-05-26). Single-mode lists keep the token-kind colours. - Staples: matrix/typing-surface coverage extended (completion + describe tests); engine-neutral wording held (the vocab audit covers the new strings); undo-parity is N/A for 4i — every change is read-side (completion / diagnostics / describe / help), so no undo steps are introduced.
- (a)
Amendment 1 — Dropping a composite UNIQUE constraint (2026-05-26)
A whole-Phase-4 /runda surfaced that a composite UNIQUE(a,b) — kept
anonymous by design (§4a.2, and §4g refused a named UNIQUE add) —
has no way to be dropped: DROP CONSTRAINT <name> (§4g) resolves
only a named table-CHECK or a named FK, so recreating the table was the
only escape. This amendment adds a drop path. Written with explicit user
approval; the plan is
docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md.
Implemented 2026-05-26.
It does not reverse the §4g anonymity decision. Storage stays a bare
column-list (unique_constraints: Vec<Vec<String>>, PRAGMA-detected) and
a UNIQUE still cannot be named on ADD. The addition is purely a
derived, engine-neutral name used to display and address the
constraint on drop.
The derived name (user-decided: derived, no storage; unique_<cols>)
The name is a deterministic function of the column list —
unique_<col1>_<col2>… — recomputed live wherever it is shown or
matched. Nothing is persisted: the constraint remains a bare column-list,
so the name round-trips for free and needs no metadata table and no
rebuild-arrival migration (the cost §4a.3 deliberately avoided). If a
column in the UNIQUE is later renamed, the displayed name tracks it —
arguably more correct than a frozen stored name. Alternatives weighed and
rejected: naming UNIQUEs with a user-supplied name + new metadata table
(reverses §4g; heaviest), and a positional drop … unique (cols) form
(needs new grammar). The derived name reuses the existing DROP CONSTRAINT <name> grammar — no new syntax.
Surfaces
describe/ structure view. The "Table constraints:" section (4i b) annotates each composite UNIQUE with its name:unique_b_c: UNIQUE (b, c).ALTER TABLE <T> DROP CONSTRAINT <name>(advanced-SQL only, matching the §4gADDform).do_drop_constraint_by_namegains a third resolution step after named table-CHECK and named FK: it recomputes the derived name of each composite UNIQUE on<T>and matches. On a single match it rebuilds the table without that entry (thedo_alter_add_uniquerebuild in reverse). A name matching more than one UNIQUE is refused as ambiguous (e.g. a column literally namedb_ccolliding withUNIQUE (b, c)) — it never guesses which to drop. Resolution order means a user-named CHECK/FK with the same string shadows a derived UNIQUE name; the distinctiveunique_prefix makes this unlikely and it is documented, not guarded.
Dropping a column a composite UNIQUE covers (F1)
do_drop_column gains an up-front guard (alongside the index-covering
and CHECK guards): a column participating in any composite UNIQUE is
refused with the constraint's derived name and the actionable drop
command — cannot drop \T.c` … part of the UNIQUE constraint
`unique_b_c` (b, c); drop that constraint first (`alter table T drop
constraint unique_b_c`). The refusal itself is unchanged (the engine already refuses it); the message becomes engine-neutral and actionable. Single-column UNIQUE column drops are a **parallel** gap (different mechanism — ADR-0029 column-level drop constraint`) and are out of
scope here.
Amendment 2 — Standard-first dialect + ALTER COLUMN constraint gap-fill (2026-05-27)
Designing the DSL → SQL teaching echo (ADR-0030 §10, specified in
ADR-0038) surfaced two related things about this ADR's surface. First, a
dialect drift: ADR-0030 frames advanced mode as "the standard-SQL
surface," but §4f shipped the type-change verb as bare
ALTER COLUMN … TYPE — the PostgreSQL shorthand — and explicitly
declined the ISO SET DATA TYPE synonym (§4f, line "no SET DATA TYPE
synonym"). Second, gaps: advanced mode has no way to toggle NOT NULL
or DEFAULT on an existing column, though simple mode does
(ADR-0029 add/drop constraint), and the rebuild primitive that would
back them is already in place (it backs §4f type-change and §4g
constraint-add). This amendment records a dialect stance and fills
the clean gaps so the echo can emit portable SQL that is also runnable
in advanced mode. Recorded with explicit user approval (2026-05-27).
The dialect stance — standard-first (refines ADR-0030)
Where ISO SQL provides a spelling, the authored grammar's canonical form is the ISO one, and the echo emits the ISO form. A widely- recognised vendor shorthand may be accepted as a synonym (so a learner who knows it is not punished), but it is never the canonical or emitted form. Where ISO provides no spelling for an operation the playground teaches, one widely-recognised vendor spelling is adopted as a deliberate, documented extension — not silently. This realigns the surface with ADR-0030's stated posture and makes the divergence a conscious, recorded choice rather than drift.
The stance applies to the whole advanced surface going forward; this
amendment exercises it on the ALTER COLUMN family.
Type change: ISO SET DATA TYPE canonical, TYPE retained as a synonym
Reverses §4f's "no SET DATA TYPE synonym." The grammar now accepts
both ALTER COLUMN <c> SET DATA TYPE <type> (ISO; canonical) and
ALTER COLUMN <c> TYPE <type> (PostgreSQL; accepted synonym, no
breakage for already-shipped usage). Both decompose to the same §4f
AlterColumnType action and the same ChangeColumnMode::ForceConversion
executor — semantics are unchanged; only the accepted spelling set
and the canonical/echoed form change. The echo (ADR-0038) emits
SET DATA TYPE.
New: SET/DROP DEFAULT (ISO) and SET/DROP NOT NULL (the one extension)
Four new AlterColumnType-family actions under ALTER COLUMN <c>:
| Spelling | Standing | Decomposes to (ADR-0029 executor) |
|---|---|---|
SET DEFAULT <expr> |
ISO-standard | do_add_constraint(Default) |
DROP DEFAULT |
ISO-standard | do_drop_constraint(Default) |
SET NOT NULL |
documented extension | do_add_constraint(NotNull) |
DROP NOT NULL |
documented extension | do_drop_constraint(NotNull) |
SET DEFAULT/DROP DEFAULT are taken directly from the ISO
<alter column action> set. NOT NULL toggling has no ISO spelling
— in the standard NOT NULL is a column constraint, not an in-place
ALTER COLUMN verb, and the vendors diverge (PostgreSQL
SET/DROP NOT NULL; SQL Server ALTER COLUMN <c> <type> NOT NULL;
MySQL MODIFY; Oracle MODIFY). Per the stance, one spelling is
adopted as a deliberate extension: PostgreSQL's SET/DROP NOT NULL,
chosen because it is the only form that is type-independent (it does not
force the user to restate the column type), reads as plain English, and
composes uniformly with the ISO SET/DROP DEFAULT it sits beside.
SET DEFAULT's value slot reuses the §4a.2 / §4e raw sql_expr
default mechanism (default_sql), so a default may be any expression
the create-table DEFAULT accepts — one syntax, not a third
(ADR-0030 §11).
Execution — rebuild-backed, no new low-level op
Each new action runtime-decomposes to an existing ADR-0029 executor
(do_add_constraint / do_drop_constraint), exactly as §4e/§4f
decompose their actions — the populated-column pre-flight dry-run
guard (ADR-0029 §5) and the internal-__rdbms_* guard come for free.
No new worker layer. The grammar discriminates the ALTER COLUMN <c> …
tail by its leading keyword: type / set data type (type change),
set not null / drop not null, set default / drop default — the
set/drop lead is new alongside §4f's type lead.
Parity reached, and the one residual gap
This brings advanced mode to constraint-modification parity with
simple mode (ADR-0029) for NOT NULL and DEFAULT — add and drop,
both directions. It closes the simple↔advanced asymmetry the echo design
flagged for those ops.
Residual gap (deliberately not closed here): dropping a
column-level UNIQUE or CHECK (the single-column, anonymous
constraints simple mode adds via ADR-0029 add constraint unique/check).
DROP CONSTRAINT <name> (§4g) + the derived composite-UNIQUE name
(Amendment 1) resolve table-level / named constraints; a single-
column column-level UNIQUE lives as the column's unique flag and a
column-level CHECK is likewise anonymous, so neither has a portable
name to address. This is the same class Amendment 1 called a "parallel
gap … out of scope." Consequently ADR-0038's catalogue marks
drop constraint unique/check from T.col as no headline echo (a
residual gap), rather than inventing a name or a recipe. Flagged for the
user; closing it (e.g. extending the derived-name approach to single-
column UNIQUE) would be its own small follow-up.
Engine neutrality holds (the rebuild stays hidden)
The chosen spellings are portable SQL, not engine features. The fact
that this engine satisfies SET NOT NULL / SET DATA TYPE via a table
rebuild (because it lacks in-place ALTER) is a Category-1 engine
implementation detail (ADR-0038's taxonomy) and stays invisible —
no recipe, no rebuild steps surfaced — exactly as §9 and ADR-0030 §7
require. A learner sees the standard statement; the engine's means of
honouring it is not the lesson.
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-FKCREATE 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/SELECTphases 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 forCHECK/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).