Files
rdbms-playground/docs/plans/20260525-adr-0035-sql-ddl-4e.md
T
claude@clouddev1 bbc2e34b33 feat: ADR-0035 4e — ALTER TABLE add/drop/rename column
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.
2026-05-25 19:49:13 +00:00

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:

  1. No IF [NOT] EXISTS for column ALTER actions (SQLite has none; the ADR doesn't add it) — so no skip plumbing, no new outcome enums.
  2. No new worker layer. The runtime decomposes SqlAlterTable into the existing db.add_column / db.drop_column / db.rename_column calls — each already snapshot_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, HEAD 701217d (4d). 4e starts here.

2. Decisions (settled — user-confirmed 2026-05-25 + ADR §4/§13 4e)

  1. SQL DROP COLUMN over an index-covered column → refuse (no --cascade SQL spelling): matches raw SQLite (its native DROP COLUMN also errors on an indexed column) and the simple-mode default; the simple drop column … --cascade stays the only cascade path. Implementation: the runtime calls db.drop_column(…, cascade = false).
  2. 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_columns on 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. Helper column_referenced_by_check.) Add the up-front refusal to do_drop_column / do_rename_column, so simple drop/rename column AND SQL ALTER TABLE both refuse a column a table CHECK references. This also fixes a latent bug: today a simple rename column of a CHECK-referenced column silently drifts __rdbms_playground_table_checks (SQLite rewrites the live CHECK; our metadata keeps the old name) and breaks a later rebuild. 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.
  3. alter is advanced-only (§2): CommandCategory::Advanced, no simple node; is_advanced_only("alter") is true → simple-mode alter gets the "this is SQL" hint. Simple mode keeps add/drop/rename/change column.
  4. Require the COLUMN keyword in all three actions (ADD COLUMN / DROP COLUMN / RENAME COLUMN) — clearest pedagogically and reserves bare RENAME TO <newtable> for 4h (table rename) and ADD CONSTRAINT for 4g without grammar ambiguity. (SQLite allows ADD/RENAME without COLUMN; 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_expr is validate-only, 4a.2), so do_add_column is extended to consume the raw text: (a) column_constraints_sql already prefers default_sql (plain path works); (b) the routing branch must send a check_sql column (and a not_null + default_sql column) to the rebuild path; (c) do_add_constrained_column_via_rebuild uses spec.check_sql / spec.default_sql when present (else the AST); (d) its pre-flight NOT-NULL/UNIQUE refusals count default_sql as a default; (e) the serial/shortid refusals count check_sql/default_sql. This mirrors the 4a.2 do_create_table raw-text mechanism.
  5. ADD COLUMN carries column constraints, not inline REFERENCES. <col-def> = <name> <type> [NOT NULL] [UNIQUE] [DEFAULT …] [CHECK …] (reusing the create-table COLUMN_DEF grammar + do_add_column's native-vs-rebuild routing). Inline REFERENCES / table constraints are 4g (add FK / add constraint), matching the simple add column boundary (no inline FK there either).

3. Phase 1 — Requirements checklist (4e)

Grammar / dispatch:

  • alter parses only in advanced mode; simple-mode alter … → "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-family reject_internal_table validator 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 column of 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_column refuse an internal __rdbms_* table (as "no such table"); the SQL ALTER table slot also carries the parse-time reject_internal_table validator. Tested on both surfaces.

Testing

  • Tier 1 (in-crate sql_alter_table_tests in ddl.rs): the three actions parse → the right SqlAlterTable action; ADD COLUMN constraints captured; alter is advanced-only (simple-mode parse is not a SqlAlterTable); table slot rejects __rdbms_*.
  • Tier 3 (tests/sql_alter_table.rs): each action end-to-end via SqlAlterTable; the four DROP refusals (PK/FK/index/CHECK); the RENAME CHECK refusal; one-undo-step for each; the CHECK guard on the simple surface (simple rename column/drop column refused) + 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)

  • alter entry word, advanced-only. Shape: ALTER is the entry; the shape after it is TABLE <table> <action> [;].
    • Reuse a table-name slot with reject_internal_table (a dedicated node like the SQL create-table TABLE_NAME, not the validator-less TABLE_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 reuse COLUMN_DEF wholesale — its COL_CONSTRAINT Choice admits PRIMARY KEY (invalid on ADD COLUMN — can't add a PK to an existing table) and inline REFERENCES (4g; do_add_column would silently drop the FK). Compose a narrow constraint suffix that reuses only the leaf nodes NOT NULL / UNIQUE / DEFAULT / CHECK (the ADR-0029 set the simple add column supports), excluding PK and REFERENCES. SQL_TYPE + the four leaf constraint nodes get pub(crate)-exported from sql_create_table.rs. Typing ADD COLUMN id int PRIMARY KEY is 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).
  • 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 single ColumnSpec by reusing collect_column_constraints(path) (the same helper the simple add column builder uses for (not_null, unique, default, check)) plus the col_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 / Database method. The runtime decomposes the action (4.4).
  • New helper check_references_column(check_expr: &str, column: &str) -> bool: tokenize the raw CHECK text with the lex_helpers (consume_string_literal to skip string contents, consume_ident to collect identifiers), case-insensitive compare to column. Robust against same-spelling substrings in literals / longer identifiers.
  • do_drop_column: after the PK / FK / index guards, add — for each read_table_checks(conn, table) expr — if check_references_column, refuse with an engine-neutral Unsupported ("cannot drop T.c while a table CHECK references it; drop the constraint first"). Wording is terse/engine-neutral (H1 polishes).
  • do_rename_column: same guard on old (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 new CommandOutcome variants; 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 matching Operation (AddColumn / DropColumn / RenameColumn) with table (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 Unsupported strings 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. Rejectedsql_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 Choice branches each lead on a concrete keyword (add/drop/rename); requiring COLUMN keeps them unambiguous and reserves RENAME TO/ADD CONSTRAINT for 4h/4g. The COLUMN_DEF reuse needs a pub(crate) export — verify it doesn't pull in create-table-only context. Probe the dispatch (alter advanced-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] EXISTS for 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 TYPE are 4g/4f — the AlterTableAction enum is built to extend. The COLUMN-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_column call read_schema, which succeeds for internal tables; the simple add/drop/rename column grammar uses the validator-less TABLE_NAME_EXISTING. So drop 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 SQL ALTER gets a parse-time reject_internal_table guard 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") to do_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 TO table rename (4h).
  • IF [NOT] EXISTS on 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 explicit COLUMN keyword.
  • 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)

  1. Executor guards (isolated, both surfaces first). (a) Unit-test check_references_column (red → impl); add the table-CHECK guard to do_drop_column / do_rename_column. (b) Add the internal-__rdbms_* guard to do_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.
  2. Command + grammar + alter entry word + builder. Tier-1 parse + advanced-only dispatch tests → red → add SqlAlterTable / AlterTableAction, the grammar (export COLUMN_DEF), REGISTRY entry, the exhaustive-match arms (verb / target_table / app.rs / typing surface), catalog keys → green (parse only).
  3. 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.
  4. Full sweepcargo test (no regression from 1834) + cargo clippy --all-targets -- -D warnings.
  5. 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.