The one genuinely new low-level op in Phase 4: a native engine RENAME TO
plus one-transaction reconciliation (commit-db-last) of everything the
engine does not track —
- every metadata row naming the table: __rdbms_playground_columns, both
ends of __rdbms_playground_relationships (FK parent, child, and
self-referential), and __rdbms_playground_table_checks;
- the CSV file, via the existing persistence rewrite+delete path
(rewritten_tables=[new], deleted_tables=[old]) — no new method;
- CHECK text that qualifies a column with the old table name
(T.age → U.age, column- and table-level): the engine rewrites the live
CHECK but the stored text would drift and break a fresh rebuild (a
planning-/runda finding); rewrite_check_table_qualifier keeps them in
step. Bounded — a CHECK references only its own table.
Grammar: a fifth AlterTableAction (RenameTable { new }), added by
splitting the `rename` verb into one branch with an inner Choice on a
distinct second keyword (column vs to); the new-name slot mirrors the
CREATE TABLE name slot (NewName + reject_internal_table validator).
Refusals are engine-neutral and case-insensitive (the engine matches
names that way): same-name, case-only, existing-target, __rdbms_*, and
non-existent source. Auto-named indexes and relationships keep their
stale names (only table-name columns update — §6 scope). One undo step;
advanced-mode only; closes the rename half of C1.
Tests: 8 Tier-3 e2e + rewrite-helper unit tests + parse-dispatch tests.
Full suite 1903 passing / 0 failing / 1 ignored; clippy clean.
25 KiB
Plan: ADR-0035 Phase 4, sub-phase 4h — ALTER TABLE … RENAME TO
Add the advanced-mode SQL form:
ALTER TABLE <old> RENAME TO <new>— rename a table. Advanced-mode only (no simple-mode rename-table verb; ADR-0035 §6). Closes the rename half ofC1for the advanced surface.
This is the one genuinely new low-level op in Phase 4 (ADR-0035 §6) —
not a reuse of an existing executor. Within one transaction it renames
the table in the database, renames its data/<old>.csv → data/<new>.csv
(via the persistence layer), and updates every metadata row that
names it.
User-confirmed scope (2026-05-26):
- Same-name rename (
rename T to T) → refuse with a friendly error (mirrors the existingrename columnidentical-rename refusal). - CHECK-text drift → rewrite the stored CHECK text (the §7 DA
finding). The engine rewrites table-qualified column references inside
the renamed table's own CHECKs in the live schema (
CHECK (T.age>0)→CHECK ("U".age>0)— confirmed empirically on SQLite 3.48). Our stored CHECK text (both__rdbms_playground_columns.check_exprand__rdbms_playground_table_checks.check_expr) must be rewritten the same way, or a fresh rebuild emitsCHECK (T.age>0)for a table now namedUand fails. Bounded problem: a CHECK constraint may reference only the table's own columns (SQLite forbids subqueries / other tables in CHECK), so the only table qualifier that can appear is the old table name — the rewrite target is unambiguous (old/"old"→new/"new"). Reuses/extends the 4e CHECK tokenizer (check_references_ column,db.rs:5489) which already skips string literals and is case-insensitive. - Auto-named labels (indexes and relationships) → left stale on
rename. ADR-0035 §6 lists only CSV + column/relationship/table-CHECK
metadata to update; auto-named indexes (
<table>_<cols>_idx) and auto-named relationships ({parent}_{pcol}_to_{child}_{ccol},db.rs:5982) keep their old names — functional, just cosmetically referencing the old table name. Documented caveat: index names are schema-global and relationship names areUNIQUE, so recreating a table under the old name later could collide with a stale label; this is an accepted consequence (cosmetic refresh is a possible 4i/follow-up, out of 4h).
Decided-and-noted (conventional defaults, no user fork):
- Rename to an existing other table → refuse "table
<new>already exists" (an explicit, engine-neutral pre-check before the native rename, which would otherwise surface engine wording). - Rename of an FK parent or child → allowed (unlike DROP, which refuses inbound FKs). The native rename rewrites child FK references in the live schema; we update both ends of the relationship metadata.
- Success feedback → auto-show the renamed table under its new
name (returns a
TableDescription, mirroringrename column). - Target name → parse-time
reject_internal_tablevalidator on the new-name slot (mirrors theCREATE TABLEname slot) + executorreject_internal_table_nameguard for defense in depth.
1. Baseline (at handoff 40)
- After 4g + the rebuild fix: 1885 passing, 0 failed, 0 skipped, 1
ignored (the
friendly/mod.rs```ignoredoctest); clippy clean (cargo clippy --all-targets -- -D warnings). Branchmain, HEAD6112859(the handoff-40 docs commit;6ff97f6/50a889e/6112859are local-only — normal). Re-verified this session: 1885 / 0 / 0 / 1.
2. Decisions (settled)
-
One new low-level executor,
do_rename_table. No existing reuse (§6 is explicit: rename is a genuinely new op). It uses the engine's nativeALTER TABLE <old> RENAME TO <new>(structure-preserving — no rebuild needed), then updates the three__rdbms_*metadata tables, then drives persistence, then commits the db last (ADR-0015 §6 ordering). Mirrors the shape ofdo_rename_column(src/db.rs:4314). -
CSV rename reuses the existing persistence machinery — no new method.
finalize_persistence(src/db.rs:2663) already (a) onschema_dirtyrewrites the entireproject.yamlfrom the live db schema — so the rename is reflected automatically — and (b) writes a CSV perrewritten_tablesentry (read in-tx by name) and deletes a CSV perdeleted_tablesentry. So:Changes { schema_dirty: true, rewritten_tables: vec![new.to_string()], // writes data/<new>.csv deleted_tables: vec![old.to_string()] } // removes data/<old>.csvThe renamed table is read by its new name (visible in-tx after the native rename), serialised to
data/<new>.csv, anddata/<old>.csvis deleted. Empty tables produce no CSV on either side (the existingwrite_table_dataempty-→-delete rule), preserving the ADR-0015 "empty tables → no CSV" invariant. NULL-vs-empty fidelity is preserved because the rows are re-serialised from the db (where NULL is NULL), not byte-copied. Norename_table_datamethod, noChangesfield added. (The Explore-suggested "add a file-rename method" is rejected in §5 R-alt.) -
Metadata updates — all three tables, both relationship ends, plus CHECK-text reconciliation. Within the tx, after the native rename:
__rdbms_playground_columns(META_TABLE):table_name old→new; and rewrite any column-levelcheck_exprwhose text qualifies a reference with the old table name (old./"old".→new./"new".— §2.9).__rdbms_playground_relationships(REL_TABLE):parent_table old→newandchild_table old→new(two UPDATEs — covers a table that is an FK parent, a child, or self-referential at once). The relationshipnameis not touched (left stale per the user decision; FK endpoints are stored as table-name values, not as expression text, so they need no rewrite — only the column UPDATE).__rdbms_playground_table_checks(CHECK_TABLE):table_name old→new; and rewrite each table-levelcheck_exprthe same way (§2.9). No index metadata table exists (indexes are PRAGMA-derived with auniqueflag; ADR-0025 Amd 1), so nothing to update there — indexes follow the renamed table natively and keep their (stale) names per the user decision. DEFAULT expressions do not drift (SQLite defaults cannot reference the table).
-
Grammar — split the
renameverb into an inner Choice (the §6.1 "no same-leading-keyword Choice siblings" rule). TodayAT_RENAME_COLUMNis the lonerename-led branch inAT_ACTION_CHOICES. Replace it with oneAT_RENAMEbranch (rename+ an innerChoice) whose two tails lead on distinct second keywords:column(→ rename column) andto(→ rename table). Mirrors the 4gadd/droprestructure. -
New-name ident slot is distinct from the target slot. The table being altered binds role
table_name(AT_TABLE_NAME,IdentSource::Tables). The rename target binds a new rolenew_table_name(IdentSource::NewName,validator: Some(reject_internal_table), allwrites_*: false, wrapped inNEW_NAME_HINT) — mirrors theCREATE TABLEname slot for parse-time__rdbms_*refusal and theNEW_COLUMN_NAMEhint treatment. Distinct roles keeprequire_ident(path, "new_table_name")unambiguous. -
Builder discrimination (
build_sql_alter_table,ddl.rs:2131): insert arenamebranch before the finalelse(DropConstraint). Order becomestype→column→add→rename→ elsedrop. By the time control reaches therenamecheck,columnis absent (caught earlier), sorenamepresent ⇒ table rename:AlterTableAction::RenameTable { new: require_ident(path, "new_table_name")? }. -
One undo step.
do_rename_tableis one user mutation carrying asource, snapshotted by the workersnapshot_thenhook (whole-project snapshot — db backup + yaml/csv copy), so a rename is exactly oneundostep. Same wiring as every otherSqlAlterTableaction. -
Replay / history.
finalize_persistenceappends the literal SQL line tohistory.log;alteris a schema-write entry word (not in the ADR-0034 Amd 1 app-lifecycle skip set), so the rename replays as a write with no replay-filter change. -
CHECK-text qualifier rewrite (§7 DA Finding 1). A new helper
rewrite_check_table_qualifier(check_expr, old, new) -> Stringrewrites every occurrence of the old table name used as a qualifier (immediately followed by.), in both the bare (old.) and quoted ("old".) forms, case-insensitively, skipping string literals — extending the 4e tokenizer thatcheck_references_columnalready uses. A bare token equal to the old name but not followed by.(e.g. a column literally named like the table) is left untouched, so the common unqualified CHECK (age > 0) is a no-op. Applied indo_rename_tableto every column-levelcheck_expr(META) and every table-levelcheck_expr(CHECK_TABLE) of the renamed table. Re-enters in advanced mode (ADR-0030 §11) — the rewritten text is still valid SQL the user could retype. -
Existence check is explicit (§7 DA Finding 2).
read_schemadoes not error on a missing table (pragma_table_inforeturns zero rows). The "no such table" guard uses an explicitdo_list_tables(conn)?.iter().any(|t| t == old)check, not a reliance onread_schemafailing.
3. Phase 1 — Requirements checklist (4h)
Grammar / dispatch
AlterTableActiongainsRenameTable { new: String }.NEW_TABLE_NAMEident node (rolenew_table_name,IdentSource::NewName,reject_internal_tablevalidator,NEW_NAME_HINT).AT_RENAME=Seq[rename, Choice[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL]]; the two tails lead on distinct keywords (column/to).AT_RENAME_COLUMN_TAIL=column <old> to <new>;AT_RENAME_TABLE_TAIL=to <new>.AT_ACTION_CHOICESswapsAT_RENAME_COLUMN→AT_RENAME.build_sql_alter_tableroutesrename(nocolumn) →RenameTable.- Existing four action branches still route (add/drop/rename/alter
column, alter-column-type, add/drop constraint); trailing
;tolerated;alterstays advanced-only; source table slot rejects__rdbms_*at parse (existingAT_TABLE_NAMEvalidator); target slot rejects__rdbms_*at parse (new validator).
Execution
do_rename_table(conn, persistence, source, old, new):reject_internal_table_name(old)+(new); existence — explicitdo_list_tablescontainsold(→ friendly "no such table", not a reliance onread_schemaerroring — §2.10); same-name (old==new) → friendly refusal; existing-target (do_list_tablescontainsnew) → friendly "already exists" refusal (pre-empts the engine's own collision wording); tx: nativeALTER TABLE … RENAME TO+ the metadata UPDATEs (§2.3) + the CHECK-text rewrite (§2.9, both META and CHECK_TABLEcheck_expr);do_describe_table(conn, new)for auto-show;finalize_persistencewith the §2.2Changes;tx.commit().rewrite_check_table_qualifierhelper (§2.9) + its own unit tests (bareT.age→U.age; quoted"T".age→"U".age; case-insensitive; string literal'T.x'untouched; bare column namedTuntouched; unqualifiedage > 0unchanged).- Worker
Request::RenameTable { name, new, source, reply };Database::rename_table(table, new, source)method; handler arm wrapped insnapshot_then(one undo step). runtime.rsSqlAlterTablematch:RenameTable { new }→database.rename_table(table, new, src)mapped like theRenameColumnarm.app.rsbuild_translate_context:RenameTable { .. }→(Operation::RenameTable, Some(table), None).Operation::RenameTableadded;keyword()arm →"rename table".
Testing
- Tier 1 (
sql_alter_table_testsinddl.rs): parsealter table T rename to U→RenameTable { new: "U" };alter table T rename column a to bstill →RenameColumn; the other four actions still route; target__rdbms_*refused at parse. - Tier 3 (
tests/sql_alter_table.rsviarun_replay):- rename a table with rows → the CSV follows (
data/<new>.csvpresent,data/<old>.csvgone), data intact incl. a NULL. - rename an FK parent → relationship metadata
parent_tableupdates; the child's FK still enforces (a violating child insert is rejected under the new name). - rename an FK child →
child_tableupdates; FK still enforces. - rename a self-referential table → both ends update, no PK
conflict on
REL_TABLE. - rename a table with a table-level CHECK →
table_checksrows follow; the CHECK still enforces. - rename a table with a table-qualified CHECK (both a column-level
CHECK (T.age > 0)and a table-levelCHECK (T.a <> T.b)) → the storedcheck_expris rewritten to the new name, the CHECK still enforces, and the project survives a fresh rebuild (the precise §7 Finding-1 regression — without the rewrite, rebuild fails with "no such table T"). - rename a table with an index → index still present + functional (name unchanged, per the user decision).
- survives a fresh rebuild — delete the
.db,rebuildfromproject.yaml/CSV: the renamed table + all its metadata round-trip (the §6.4 fresh-rebuild discipline). - one undo step: rename,
undo, the table is back under its old name with its rows/relationship/CHECK;redoreapplies. - refusals: rename to an existing other table; rename to the same
name; rename to an
__rdbms_*name (executor guard, in case the parse validator is bypassed by a synthesised command); rename a non-existent table.
- rename a table with rows → the CSV follows (
- Catalog lockstep + vocab audit for the refreshed
sql_alter_tableusage (now listingrename to <NewName>); the wording stays engine-neutral.
4. Architecture & change list (file by file)
src/dsl/command.rs:AlterTableAction::RenameTable { new: String }.src/dsl/grammar/ddl.rs:NEW_TABLE_NAME_IDENT/NEW_TABLE_NAMEnodes (≈ nearAT_RENAME_COLUMN, mirroringNEW_COLUMN_NAMEat line 501 + theCREATE TABLEname slot'sreject_internal_tablevalidator).AT_RENAME_COLUMN_TAIL(the existingcolumn …body minus the leadingrename),AT_RENAME_TABLE_TAIL(to <new>),AT_RENAME_TAIL(Choice),AT_RENAME(Seq[rename, tail]); swap intoAT_ACTION_CHOICES(line 1998).build_sql_alter_table(line 2131): therenamebranch + doc-comment update (discrimination nowtype → column → add → rename → drop).
src/db.rs:Request::RenameTablevariant (the worker request enum, ≈452–650).Database::rename_tablemethod (mirrorrename_column).- handler dispatch arm (≈ the
RenameColumnarm) wrapped insnapshot_then. do_rename_tableexecutor (model:do_rename_columnat 4314 +do_drop_tableat 3227 for the persistence-cleanup shape). Uses an explicitdo_list_tablesexistence/collision check (§2.10), notread_schema-erroring.rewrite_check_table_qualifierhelper near the 4e CHECK tokenizer (check_references_column,db.rs:5489); applied indo_rename_tableto META + CHECK_TABLEcheck_expr(§2.9).
src/runtime.rs:SqlAlterTableinner match →RenameTablearm.src/app.rs:build_translate_contextinner match →RenameTablearm (≈1595).src/friendly/translate.rs:Operation::RenameTable+keyword()arm"rename table".src/friendly/strings/en-US.yaml: add therename to <NewName>line to thesql_alter_tableusage; any new refusal-message keys (same-name / existing-target) — engine-neutral, vocab-audit-clean. (parse.usage.sql_alter_table/help.ddl.sql_alter_tablekeys already registered inkeys.rs.)
5. Phase 2 — Candidate approaches (key forks)
Rename mechanism. (M1) the engine's native ALTER TABLE … RENAME TO
- manual metadata UPDATEs (lead — structure-preserving, atomic, no data
movement; the engine also rewrites child FK references in the live schema
since
legacy_alter_tableis off by default). (M2) the ADR-0013 rebuild-table primitive (create new, copy rows, drop old) — rejected (heavyweight; rebuilds the whole table to change only its name; rename is not a structural change). (M3) drop + recreate — rejected (loses rows; absurd for a rename).
CSV persistence. (R1) reuse finalize_persistence with
rewritten_tables=[new] + deleted_tables=[old] (lead — zero new
machinery; re-serialises by the new name, deletes the old, handles
empty-table-no-CSV for free). (R-alt) add a Persistence::rename_table_data
- a
Changes.renamed_tablesfield andfs::renamethe file — rejected (new public method + new struct field for behaviour the existing rewrite+delete path already delivers; byte-copy buys nothing over re-serialisation since rows come from the db). (R3) leave the CSV under the old name and special-case the loader — rejected (breaks thedata/<table>.csvinvariant; confuses a human reading the project dir).
Grammar. (G1) split rename into one branch with an inner Choice
on the distinct second keyword (column / to) (lead — the established
§6.1 + 4g pattern; trap-safe). (G2) two sibling rename-led branches in
AT_ACTION_CHOICES — rejected (the walker Choice does not backtrack
between same-leading-keyword branches; this is exactly the 4g trap). (G3)
make the column keyword optional and disambiguate purely in the builder
— rejected (ambiguous grammar; the optional-keyword shape invites the
same backtracking trap and muddies completion).
Target __rdbms_* refusal. (V1) parse-time validator on the
new-name slot (mirrors CREATE TABLE) and an executor guard (lead —
earliest feedback + defense in depth; the worker is directly reachable by
synthesised commands/tests). (V2) executor guard only — weaker (loses
the pre-submit [ERR] indicator the CREATE name slot gives). (V3)
parse-only — rejected (a synthesised RenameTable command would slip a
metadata-table rename past the guard).
6. Phase 3 — Selection
M1 + R1 + G1 + V1. Satisfies every §3 item with the smallest faithful
change: native rename is the one new low-level op §6 calls for; the CSV
rename rides the existing rewrite+delete path (no new persistence surface);
the grammar mirrors the trap-safe 4g restructure; the target gets the same
parse-time __rdbms_* refusal as CREATE TABLE plus an executor guard.
The same-name and existing-target refusals (user-confirmed / conventional)
keep the surface honest; auto-named index and relationship names are
left as-is per the ADR §6 scope and the user decision; and the CHECK-text
rewrite (§2.9) keeps the stored metadata in step with the live schema so
a fresh rebuild round-trips.
7. Devil's Advocate review of this plan
Planning /runda pass (2026-05-26) — three findings, all resolved:
-
Finding 1 (BLOCKING, resolved → rewrite). CHECK-expression text drift: empirically confirmed that SQLite (3.48,
legacy_alter_tableoff) rewrites a table-qualified column reference in the renamed table's live CHECK (T.age→"U".age), while the storedcheck_exprwould stayT.ageand break a fresh rebuild. The original plan was silent on it. Resolved by §2.9 (rewrite the stored CHECK text in both metadata tables), user-confirmed; bounded because a CHECK can only reference its own table; regression test added (§3). This is the exact class as the50a889erebuild-metadata bug and the 4e column-CHECK drift — the/runda"probe, don't reason" pass earned its keep again. -
Finding 2 (correction, resolved).
read_schemadoes not error on a missing table; the existence/“no such table” guard is now an explicitdo_list_tablescheck (§2.10). -
Finding 3 (consistency, resolved → leave stale). Auto-named relationships embed the table name (
db.rs:5982) exactly like auto- named indexes; both are left stale on rename per the user decision, with the UNIQUE/global-name collision caveat documented. -
Forks escalated? All four genuine forks (same-name behaviour; auto-named-label handling; CHECK-text drift; relationship-vs-index consistency) were put to the user (2026-05-26) and answered. The conventional edges (existing-target refuse; FK-parent/child allowed; auto-show; target-name validation) are decided-and-noted with rationale, inviting correction. No silent autonomous design call. ✓
-
Grammar trap (the recurring 4g bite)? The two
renametails lead on distinct keywords (columnvsto) under onerenamebranch — exactly the §6.1 rule. A parse test for both tails + the four other actions guards it. ✓ -
New-name role collision? The target binds a distinct role (
new_table_name), sorequire_identcannot confuse it with thetable_nametarget slot. ✓ -
Fresh-rebuild metadata loss (the
50a889eclass)? 4h changes metadata values (renames), not the schema —do_rebuild_from_textalready wipes + repopulates META/REL/CHECK from yaml, and the yaml is rewritten under the new name byschema_dirty. A fresh-rebuild test is mandatory (§3) and is the precise probe for this class. ✓ -
Both relationship ends + self-ref? Two UPDATEs (
parent_table,child_table); a self-referential table updates both with noREL_TABLEPK(child_table, child_column)conflict (single row,newwas not previously a child_table). Explicit self-ref test. ✓ -
CSV fidelity (NULL vs empty, empty tables)? Re-serialised from the db, not byte-copied — NULL stays NULL; an empty renamed table writes no
<new>.csvand deletes<old>.csv(the existing empty-→-delete rule). Test renames a table with a NULL cell. ✓ -
FK enforcement after rename?
legacy_alter_tableis off, so the native rename rewrites child FK references in the live schema; the metadata UPDATE keepsREL_TABLEconsistent; rebuild regenerates FK DDL from the updated metadata. Tests assert enforcement under the new name + a rebuild round-trip. ✓ -
Engine neutrality? The same-name / existing-target refusals are authored engine-neutral ("table", "the database"); the native rename's own collision error is pre-empted by the explicit
do_list_tablescheck so the engine wording never surfaces. Vocab audit + catalog lockstep guard it. ✓ -
One undo step? One executor call = one
snapshot_then= one whole-project snapshot. e2e undo/redo test. ✓ -
Anything dropped? Auto-named index refresh (out of scope, user decision, noted as a possible 4i/follow-up). No silent drops.
8. Implementation sequence (test-first)
- Command + Operation + grammar + builder. Add
RenameTablevariant,Operation::RenameTable, theNEW_TABLE_NAMEnode + theAT_RENAMEsplit, the builder branch. Tier-1 parse tests (rename-table vs rename-column dispatch; the four other actions; target__rdbms_*refusal) → red, then exhaustive arms (compiler findsruntime.rs/app.rs/keyword()) → green (parse only). - CHECK-text rewrite helper.
rewrite_check_table_qualifier(§2.9) with unit tests first (bare/quoted/case/string-literal/bare-column/ unqualified) → red → green. Isolated, lands before the executor uses it. - Executor + worker wiring.
do_rename_table(explicit existence check; metadata UPDATEs; the CHECK-text rewrite over META + CHECK_TABLE),Request::RenameTable,Database::rename_table, thesnapshot_thenhandler arm, theruntime.rs+app.rsarms. Tier-3 e2e (rows/CSV, FK parent, FK child, self-ref, table-CHECK, table-qualified CHECK + fresh rebuild (the Finding-1 regression), index, fresh rebuild, undo/redo, all four refusals) → red where they exercise new behaviour, then green. - Catalog + docs. Refresh
sql_alter_tableusage (rename to); add refusal-message keys; ADR-0035 Status + §13 4h; README;requirements.mdQ1/C1— all lockstep. - Full sweep.
cargo test(no regression from 1885) +cargo clippy --all-targets -- -D warnings. - Finished-slice
/runda(per handoff §7 — budget at least one, covering 4h; it found the rebuild bug last session). Fix anything it surfaces, re-green. - Commit proposal — propose the message, wait for approval. No AI attribution. (Push is the user's step.)
9. Exit gate
- All §3 items satisfied; all tiers green, zero skips; no regression from
1885; written-DA /
/rundaPASS; clippy clean; ADR-0035 §13 4h + READMErequirements.mdlockstep. After 4h, only 4i (the verification sweep) remains to complete Phase 4.