Advanced-only `alter` entry word; ALTER TABLE <T> ADD COLUMN <col> <type> [constraints] | DROP COLUMN <col> | RENAME COLUMN <old> TO <new> -> SqlAlterTable, runtime-decomposed to the existing column executors (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 NOT NULL / UNIQUE / DEFAULT / CHECK (no PK / inline REFERENCES). do_add_column extended to consume the SQL raw-text default_sql / check_sql (sql_expr is validate-only, the 4a.2 mechanism), reaching parity with CREATE TABLE's column constraints. - Drop/rename column refuse a column any CHECK references — table-level AND column-level (incl. a column's own self-check on rename) — the 4a.3 deferral, detected up-front by tokenizing the raw CHECK text (skipping string literals). In the shared executors, so it guards both the simple and SQL surfaces and fixes a latent rename-drift bug that desynced the stored CHECK text and broke rebuild. - SQL DROP COLUMN refuses an index-covered column (no --cascade SQL spelling — matches SQLite + the simple default). - The column executors and do_add_index gained an internal-__rdbms_* guard (refuse as "no such table"), closing a pre-existing exposure on both surfaces. (do_change_column_type / do_add_constraint / do_add_relationship are a tracked follow-up.) - `alter` is advanced-only; AlterTableAction::AddColumn is boxed (clippy::large_enum_variant). Docs: ADR-0035 status + §13 4e; ADR README; requirements.md Q1. Plan: docs/plans/20260525-adr-0035-sql-ddl-4e.md. Tests: 1854 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
19 KiB
Plan: ADR-0035 Phase 4, sub-phase 4e — ALTER TABLE add/drop/rename column
Add the advanced-only alter entry word and ALTER TABLE <T> <action> →
SqlAlterTable, where <action> is ADD COLUMN <col-def>, DROP COLUMN <name>, or RENAME COLUMN <old> TO <new> (ADR-0035 §1/§2/§4 the
add/drop/rename rows of the ALTER TABLE table; §13 4e). Each maps to an
existing executor (do_add_column / do_drop_column /
do_rename_column), like 4c/4d reused theirs. Plus the 4a.3-deferred
guard: refuse dropping/renaming a column a table-level CHECK references.
Two things make this simpler than 4d:
- No
IF [NOT] EXISTSfor column ALTER actions (SQLite has none; the ADR doesn't add it) — so no skip plumbing, no new outcome enums. - No new worker layer. The runtime decomposes
SqlAlterTableinto the existingdb.add_column/db.drop_column/db.rename_columncalls — each alreadysnapshot_then(one undo step) and journalled.
One genuinely new thing: alter is the first advanced-only DDL entry
word (CommandCategory::Advanced, like select/with) — simple mode
has no alter; typing it there yields the "this is SQL" hint.
1. Baseline
- Tests: 1834 passing, 0 failing, 0 skipped, 1 ignored; clippy clean.
Branch
main, HEAD701217d(4d). 4e starts here.
2. Decisions (settled — user-confirmed 2026-05-25 + ADR §4/§13 4e)
- SQL
DROP COLUMNover an index-covered column → refuse (no--cascadeSQL spelling): matches raw SQLite (its native DROP COLUMN also errors on an indexed column) and the simple-mode default; the simpledrop column … --cascadestays the only cascade path. Implementation: the runtime callsdb.drop_column(…, cascade = false). - CHECK guard in the executors (both surfaces). (Finished-slice
/runda, user-confirmed 2026-05-25: extended from table-level to also column-level CHECKs — a proven pre-existing rename-drift bug where a column-level CHECK, incl. a column's own self-check, would desync__rdbms_playground_columnson rename and break rebuild. The guard refuses rename when any CHECK — table- or column-level, incl. self — references the column, and refuses drop when a table-level or other column's CHECK does. Helpercolumn_referenced_by_check.) Add the up-front refusal todo_drop_column/do_rename_column, so simpledrop/rename columnAND SQLALTER TABLEboth refuse a column a table CHECK references. This also fixes a latent bug: today a simplerename columnof a CHECK-referenced column silently drifts__rdbms_playground_table_checks(SQLite rewrites the live CHECK; our metadata keeps the old name) and breaks a laterrebuild. The refusal is deliberate (ADR §13 4e); friendly wording is H1. Consequence: a column used in a table-level CHECK can't be dropped/renamed until 4g adds drop-constraint — accepted. alteris advanced-only (§2):CommandCategory::Advanced, no simple node;is_advanced_only("alter")is true → simple-modealtergets the "this is SQL" hint. Simple mode keepsadd/drop/rename/change column.- Require the
COLUMNkeyword in all three actions (ADD COLUMN/DROP COLUMN/RENAME COLUMN) — clearest pedagogically and reserves bareRENAME TO <newtable>for 4h (table rename) andADD CONSTRAINTfor 4g without grammar ambiguity. (SQLite allowsADD/RENAMEwithoutCOLUMN; we standardise on the explicit form. Implementer call, flagged.) 5b. ADD COLUMN supports the full constraint set (NOT NULL / UNIQUE / DEFAULT / CHECK) — user-confirmed 2026-05-25, parity with SQL CREATE TABLE. The SQL surface stores DEFAULT/CHECK as raw text (default_sql/check_sql;sql_expris validate-only, 4a.2), sodo_add_columnis extended to consume the raw text: (a)column_constraints_sqlalready prefersdefault_sql(plain path works); (b) the routing branch must send acheck_sqlcolumn (and anot_null+default_sqlcolumn) to the rebuild path; (c)do_add_constrained_column_via_rebuildusesspec.check_sql/spec.default_sqlwhen present (else the AST); (d) its pre-flight NOT-NULL/UNIQUE refusals countdefault_sqlas a default; (e) the serial/shortid refusals countcheck_sql/default_sql. This mirrors the 4a.2do_create_tableraw-text mechanism. - ADD COLUMN carries column constraints, not inline
REFERENCES.<col-def>=<name> <type> [NOT NULL] [UNIQUE] [DEFAULT …] [CHECK …](reusing the create-tableCOLUMN_DEFgrammar +do_add_column's native-vs-rebuild routing). InlineREFERENCES/ table constraints are 4g (add FK / add constraint), matching the simpleadd columnboundary (no inline FK there either).
3. Phase 1 — Requirements checklist (4e)
Grammar / dispatch:
alterparses only in advanced mode; simple-modealter …→ "this is SQL" hint (advanced-only entry word).ALTER TABLE <T> ADD COLUMN <name> <type> [constraints]→SqlAlterTable { AddColumn(ColumnSpec) }(constraints: NOT NULL / UNIQUE / DEFAULT / CHECK, reusing the create-table column-def grammar).ALTER TABLE <T> DROP COLUMN <name>→… DropColumn.ALTER TABLE <T> RENAME COLUMN <old> TO <new>→… RenameColumn.- Trailing
;tolerated; the<T>slot completes from the schema cache (IdentSource::Tables) and rejects internal__rdbms_*(reuse the SQL-familyreject_internal_tablevalidator on the table slot).
Execution (reuse existing executors):
- ADD COLUMN: plain + each constraint (NOT NULL+default, UNIQUE,
CHECK) lands via
do_add_column(native or rebuild as it already decides); one undo step; auto-show. - DROP COLUMN: removes the column (one undo step); refuses PK / FK / index-covered (no cascade) / table-CHECK-referenced columns.
- RENAME COLUMN: renames (one undo step), metadata mirrored; refuses same-name / existing-target / table-CHECK-referenced columns.
- CHECK guard, both surfaces: simple
drop column/rename columnof a table-CHECK-referenced column is also refused now (was a raw engine error / silent rename-drift); a regression-style test proves the simple surface and a rebuild-after still works. - Errors engine-neutral (inline
Unsupported, matching the existing PK/FK/index refusals). - Internal-table guard (both surfaces):
do_add_column/do_drop_column/do_rename_columnrefuse an internal__rdbms_*table (as "no such table"); the SQL ALTER table slot also carries the parse-timereject_internal_tablevalidator. Tested on both surfaces.
Testing
- Tier 1 (in-crate
sql_alter_table_testsinddl.rs): the three actions parse → the rightSqlAlterTableaction; ADD COLUMN constraints captured;alteris advanced-only (simple-mode parse is not aSqlAlterTable); table slot rejects__rdbms_*. - Tier 3 (
tests/sql_alter_table.rs): each action end-to-end viaSqlAlterTable; the four DROP refusals (PK/FK/index/CHECK); the RENAME CHECK refusal; one-undo-step for each; the CHECK guard on the simple surface (simplerename column/drop columnrefused) + a rebuild-survives check on a table that does carry a CHECK on an un-renamed column. - Unit (
check_references_column): tokenizer detects a bare identifier, is case-insensitive, ignores a same-spelling substring inside a string literal and inside a longer identifier. - Catalog lockstep + vocab audit for the new help/usage keys.
4. Architecture & design
4.1 Command (src/dsl/command.rs)
SqlAlterTable { table: String, action: AlterTableAction }.pub enum AlterTableAction { AddColumn(ColumnSpec), DropColumn { column: String }, RenameColumn { old: String, new: String } }(4f/4g/4h extend it:AlterColumnType,AddConstraint/AddForeignKey/DropConstraint,RenameTo).verb()→"alter table";target_table()→table.
4.2 Grammar (src/dsl/grammar/ddl.rs)
alterentry word, advanced-only. Shape:ALTERis the entry; the shape after it isTABLE <table> <action> [;].- Reuse a table-name slot with
reject_internal_table(a dedicated node like the SQL create-tableTABLE_NAME, not the validator-lessTABLE_NAME_EXISTING). <action>=Choice[ AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN ], each branch leading on a concrete keyword (add/drop/rename) — trap-safe.AT_ADD_COLUMN = Seq[ Word(add), Word(column), <col name (NewName)>, <SQL_TYPE>, <narrow-constraint-suffix> ]. Do NOT reuseCOLUMN_DEFwholesale — itsCOL_CONSTRAINTChoice admitsPRIMARY KEY(invalid on ADD COLUMN — can't add a PK to an existing table) and inlineREFERENCES(4g;do_add_columnwould silently drop the FK). Compose a narrow constraint suffix that reuses only the leaf nodesNOT NULL/UNIQUE/DEFAULT/CHECK(the ADR-0029 set the simpleadd columnsupports), excluding PK and REFERENCES.SQL_TYPE+ the four leaf constraint nodes getpub(crate)-exported fromsql_create_table.rs. TypingADD COLUMN id int PRIMARY KEYis then an ordinary parse error (unsupported constraint here) — acceptable; H1 can soften it.AT_DROP_COLUMN = Seq[ Word(drop), Word(column), <col name> ](existing-column ident).AT_RENAME_COLUMN = Seq[ Word(rename), Word(column), <old>, Word(to), <new-name> ](<old>existing-column ident;<new>NewName).
- Reuse a table-name slot with
pub static SQL_ALTER_TABLE: CommandNode { entry: "alter", shape, ast_builder: build_sql_alter_table, help_id, usage_ids }.- REGISTRY:
(&ddl::SQL_ALTER_TABLE, CommandCategory::Advanced). build_sql_alter_table: branch on the leading action keyword (add/drop/rename— the first Word item after the table name). For ADD COLUMN, build the singleColumnSpecby reusingcollect_column_constraints(path)(the same helper the simpleadd columnbuilder uses for(not_null, unique, default, check)) plus thecol_name/type idents — not the create-table multi-column loop. For DROP/RENAME, pull the column idents (require_ident).
4.3 Worker / executors (src/db.rs) — only the CHECK guard is new
- No new
Request/Databasemethod. The runtime decomposes the action (4.4). - New helper
check_references_column(check_expr: &str, column: &str) -> bool: tokenize the raw CHECK text with thelex_helpers(consume_string_literalto skip string contents,consume_identto collect identifiers), case-insensitive compare tocolumn. Robust against same-spelling substrings in literals / longer identifiers. do_drop_column: after the PK / FK / index guards, add — for eachread_table_checks(conn, table)expr — ifcheck_references_column, refuse with an engine-neutralUnsupported("cannot dropT.cwhile a table CHECK references it; drop the constraint first"). Wording is terse/engine-neutral (H1 polishes).do_rename_column: same guard onold(a rename would drift the CHECK metadata; refuse).
4.4 Runtime (src/runtime.rs)
Command::SqlAlterTable { table, action } → match action:
AddColumn(spec)→db.add_column(table, spec, src)→CommandOutcome::AddColumn(_)(the existing add-column outcome path).DropColumn { column }→db.drop_column(table, column, false, src)→CommandOutcome::DropColumn(_)(cascade = false per decision 1).RenameColumn { old, new }→db.rename_column(table, old, new, src)→CommandOutcome::Schema(Some(d)). No newCommandOutcomevariants; reuse the simple-mode ones (the auto-show / result rendering is identical — same executors).
4.5 app.rs failure-translate + typing_surface
app.rs build_translate_context:C::SqlAlterTable { table, action }→ route by action to the matchingOperation(AddColumn/DropColumn/RenameColumn) withtable(and the column where the action carries one).tests/typing_surface/mod.rs:SqlAlterTable { .. } => "SqlAlterTable".
4.6 Catalog (keys.rs + en-US.yaml)
help.ddl.sql_alter_table+parse.usage.sql_alter_table(new node). Usage:alter table <T> add column <col> <type> [constraints] | drop column <col> | rename column <old> to <new>. Engine-neutral.- No new note key — the CHECK-guard refusals are inline
Unsupportedstrings like the existing drop-column refusals.
5. Phase 2 — Candidate approaches
(A1) SqlAlterTable + an AlterTableAction enum, runtime-decomposed
to existing db methods (lead). No new worker layer; extends cleanly
for 4f/4g/4h (more action variants).
(A2) A new db.sql_alter_table worker method that dispatches inside
the worker. Rejected — adds a Request/method that just re-calls the
existing executors; the runtime-decompose (A1) reuses the shipped public
methods (already snapshot/journal-correct) with less surface.
(G1) ADD COLUMN reuses the create-table COLUMN_DEF node (lead) —
one column-def grammar, one constraint set, no drift from create-table.
(G2) A bespoke ALTER-ADD column-def grammar. Rejected — duplicates
the column-def + constraint grammar; risks divergence (the "two DDL
generators stay in sync" lesson, here for the parser).
(C1) CHECK-ref detection via the lex_helpers tokenizer (lead) —
skips string literals, matches whole identifiers; robust + reuses tested
primitives. (C2) A \bcol\b regex / contains. Rejected — false
positives inside string literals and longer identifiers would wrongly
refuse a legitimate drop. (C3) Parse via sql_expr and walk an AST.
Rejected — sql_expr is validate-only (no Expr AST, the 4a.2
finding); heavier with no payoff over C1.
6. Phase 3 — Selection vs the checklist
A1 + G1 + C1 satisfy every §3 item: the three actions parse (G1 +
concrete-keyword Choice), dispatch (advanced-only alter), execute
(reuse executors via A1), the four drop refusals + rename refusal (the
existing guards + the new C1 CHECK guard in the shared executors → both
surfaces), engine-neutral wording, undo parity (one executor call = one
snapshot). No requirement unmet. Selected: A1 + G1 + C1.
7. Devil's Advocate review of this plan
- Both forks escalated + user-confirmed? Yes (2026-05-25): SQL drop refuses index-covered (no cascade spelling); the CHECK guard lives in the executors (both surfaces, refuse). No autonomous scope calls. ✓
- Reuse vs fork? Executors reused wholesale (A1); only the CHECK guard is added, and it's added once in the shared executors — fixing both surfaces and a latent rename-drift bug. ✓
- Grammar trap? The action
Choicebranches each lead on a concrete keyword (add/drop/rename); requiringCOLUMNkeeps them unambiguous and reservesRENAME TO/ADD CONSTRAINTfor 4h/4g. TheCOLUMN_DEFreuse needs apub(crate)export — verify it doesn't pull in create-table-only context. Probe the dispatch (alteradvanced-only → simple-mode hint; the three actions each to the right command). ✓ - CHECK detection robustness? C1 tokenizer + a unit test with a string-literal false-positive case and a longer-identifier case. The detection is the only subtle logic; it gets dedicated unit coverage. ✓
- Latent simple-mode bug actually fixed + proven? A Tier-3 test renames/drops a CHECK-referenced column via the simple command and asserts the refusal, plus a rebuild-survives check. ✓
- Undo parity? Each action is exactly one existing executor call = one snapshot. A per-action undo test. ✓
- Anything silently dropped?
IF [NOT] EXISTSfor column ALTER is out of scope (SQLite has none; ADR doesn't add it) — stated, not silent.RENAME TO(table) is 4h;ADD CONSTRAINT/ADD FK/ALTER COLUMN TYPEare 4g/4f — theAlterTableActionenum is built to extend. TheCOLUMN-keyword-required choice is flagged (decision 4). ✓ - Help/usage skeleton? New node → its keys land now (not deferred, unlike the 4i CREATE TABLE refresh). ✓
- Internal-
__rdbms_*-table exposure on the column executors — OPEN, escalated. Verified:do_add_column/do_drop_column/do_rename_columncallread_schema, which succeeds for internal tables; the simpleadd/drop/rename columngrammar uses the validator-lessTABLE_NAME_EXISTING. Sodrop column from __rdbms_playground_columns: table_name(simple mode) parses and corrupts internal metadata — a pre-existing exposure (ADR-0013 era), more severe than 4d's phantom index, though low-likelihood (the user must type a name hidden everywhere). 4e's SQLALTERgets a parse-timereject_internal_tableguard either way; the question is whether to also close the simple surface at the executor (consistent with 4d's both-surfaces fix). RESOLVED (user, 2026-05-25): fix the executors, both surfaces — add an internal-table guard (refuse as "no such table") todo_add_column/do_drop_column/do_rename_column. The broader latent class (do_change_column_type/do_add_constraint/do_add_relationship) is flagged as a separate follow-up, not touched in 4e.
8. Out of 4e scope (tracked, not dropped)
ALTER COLUMN TYPE(4f),ADD/DROP CONSTRAINT+ADD FK(4g),RENAME TOtable rename (4h).IF [NOT] EXISTSon column ALTER actions (no SQLite support; not in ADR §4) — revisit only if a future ADR adds it.- COLUMN-keyword-optional forms (
ADD c int,RENAME a TO b) — could be admitted later; 4e requires the explicitCOLUMNkeyword. - Friendly wording for the CHECK-guard refusal — H1 (4e gives a terse engine-neutral message).
- Rewriting a table-CHECK's text on rename (instead of refusing) — a future enhancement; 4e refuses per ADR §13 4e.
9. Implementation sequence (test-first)
- Executor guards (isolated, both surfaces first). (a) Unit-test
check_references_column(red → impl); add the table-CHECK guard todo_drop_column/do_rename_column. (b) Add the internal-__rdbms_*guard todo_add_column/do_drop_column/do_rename_column. Tier-3 tests via the simple commands (CHECK drop+rename refusal + rebuild-survives; internal-table refusal on all three) → green. Lands both latent-bug fixes on their own, reviewable, before any SQL surface. - Command + grammar +
alterentry word + builder. Tier-1 parse + advanced-only dispatch tests → red → addSqlAlterTable/AlterTableAction, the grammar (exportCOLUMN_DEF), REGISTRY entry, the exhaustive-match arms (verb / target_table / app.rs / typing surface), catalog keys → green (parse only). - Runtime dispatch. Wire
SqlAlterTable→ the three existing db methods; Tier-3 (tests/sql_alter_table.rs): each action end-to-end + the four drop refusals + rename refusal + per-action undo → green. - Full sweep —
cargo test(no regression from 1834) +cargo clippy --all-targets -- -D warnings. - Docs — ADR-0035 Status + §13 4e implemented; README; requirements
Q1. Run
/runda. Propose commit; wait for approval.
10. Exit gate
- All §3 items satisfied; four tiers green, zero skips; no regression
from 1834;
/runda/ written-DA PASS; clippy clean; ADR-0035 §13 4e + README + requirements.md lockstep.