feat: ADR-0035 4h — ALTER TABLE … RENAME TO

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.
This commit is contained in:
claude@clouddev1
2026-05-26 08:38:39 +00:00
parent 6112859660
commit f7e77a86f8
12 changed files with 1379 additions and 15 deletions
+356
View File
@@ -528,6 +528,13 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
RenameTable {
table: String,
new: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
ChangeColumnType {
table: String,
column: String,
@@ -1209,6 +1216,24 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
pub async fn rename_table(
&self,
table: String,
new: String,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RenameTable {
table,
new,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn change_column_type(
&self,
table: String,
@@ -2050,6 +2075,20 @@ fn handle_request(
&new,
));
}
Request::RenameTable {
table,
new,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table(
conn,
persistence,
source.as_deref(),
&table,
&new,
));
}
Request::ChangeColumnType {
table,
column,
@@ -4414,6 +4453,188 @@ fn do_rename_column(
Ok(description)
}
/// Rename a table (ADR-0035 §6, sub-phase 4h) — the one genuinely new
/// low-level op in Phase 4.
///
/// Uses SQLite's native `ALTER TABLE … RENAME TO` (structure-preserving,
/// so no rebuild), which also rewrites references to the old name in
/// other tables' FK declarations and inside the renamed table's own
/// CHECK / self-FK definitions (the engine's modern, non-legacy
/// behaviour). We then reconcile everything the engine does *not* know
/// about, all in one transaction (commit-db-last, ADR-0015 §6):
///
/// 1. every metadata row that names the table — `__rdbms_playground_columns`
/// (`table_name`), **both ends** of `__rdbms_playground_relationships`
/// (`parent_table` *and* `child_table`, covering a self-referential
/// table), and `__rdbms_playground_table_checks` (`table_name`);
/// 2. CHECK *text* that qualifies a column with the old table name
/// (`T.age` → `U.age`), in both metadata tables — the live schema was
/// already rewritten, so the stored text must match or a rebuild fails;
/// 3. the CSV file, via the existing persistence rewrite+delete path
/// (`rewritten_tables = [new]`, `deleted_tables = [old]`).
///
/// Auto-named indexes and relationships keep their (now stale but
/// functional) names — only the table-name *columns* update (ADR-0035 §6
/// scope; user-confirmed). One undo step (the worker's whole-project
/// snapshot).
fn do_rename_table(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
old: &str,
new: &str,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(old)?;
reject_internal_table_name(new)?;
// Existence + collision: `read_schema` does not error on a missing
// table (`pragma_table_info` returns no rows), so check explicitly —
// and pre-empt the engine's own collision error so the refusal stays
// engine-neutral (ADR-0035 §9).
let tables = do_list_tables(conn)?;
if !tables.iter().any(|t| t == old) {
return Err(DbError::Sqlite {
message: format!("no such table: {old}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
if old == new {
return Err(DbError::Unsupported(format!(
"rename: new name is identical to the existing one (`{old}`)."
)));
}
// The database matches table names case-insensitively, so collision
// checks must be case-insensitive too — otherwise the native rename
// surfaces a raw engine collision error (ADR-0035 §9). A target that
// differs from the source only in capitalization is a no-op the engine
// cannot perform; a target colliding with a *different* table is the
// ordinary "already exists" refusal.
if old.eq_ignore_ascii_case(new) {
return Err(DbError::Unsupported(format!(
"`{old}` and `{new}` differ only in capitalization; the database \
treats them as the same table, so there is nothing to rename."
)));
}
if tables.iter().any(|t| t.eq_ignore_ascii_case(new)) {
return Err(DbError::Unsupported(format!(
"table `{new}` already exists; pick a different name."
)));
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let ddl = format!(
"ALTER TABLE {old_t} RENAME TO {new_t};",
old_t = quote_ident(old),
new_t = quote_ident(new),
);
debug!(ddl = %ddl, "rename_table");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
// (1) Rename the table name in every metadata row that names it.
tx.execute(
&format!("UPDATE {META_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
// Both relationship ends — parent and child — so an FK parent, an FK
// child, and a self-referencing table are all covered. The
// relationship `name` is left as-is (auto-names embed the old table
// name; stale-but-functional per the user decision, like index names).
tx.execute(
&format!("UPDATE {REL_TABLE} SET parent_table = ?1 WHERE parent_table = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("UPDATE {REL_TABLE} SET child_table = ?1 WHERE child_table = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("UPDATE {CHECK_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
// (2) Reconcile CHECK *text* that qualifies a reference with the old
// table name. Read the rows (now keyed by `new`), rewrite, write back
// only when changed — the common unqualified CHECK is a no-op.
let col_checks: Vec<(String, String)> = {
let mut stmt = tx
.prepare(&format!(
"SELECT column_name, check_expr FROM {META_TABLE} \
WHERE table_name = ?1 AND check_expr IS NOT NULL;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([new], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.map_err(DbError::from_rusqlite)?;
let mut v = Vec::new();
for row in rows {
v.push(row.map_err(DbError::from_rusqlite)?);
}
v
};
for (column_name, expr) in col_checks {
let rewritten = rewrite_check_table_qualifier(&expr, old, new);
if rewritten != expr {
tx.execute(
&format!(
"UPDATE {META_TABLE} SET check_expr = ?1 \
WHERE table_name = ?2 AND column_name = ?3;"
),
rusqlite::params![rewritten, new, column_name],
)
.map_err(DbError::from_rusqlite)?;
}
}
let table_checks: Vec<(i64, String)> = {
let mut stmt = tx
.prepare(&format!(
"SELECT seq, check_expr FROM {CHECK_TABLE} WHERE table_name = ?1;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([new], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(DbError::from_rusqlite)?;
let mut v = Vec::new();
for row in rows {
v.push(row.map_err(DbError::from_rusqlite)?);
}
v
};
for (seq, expr) in table_checks {
let rewritten = rewrite_check_table_qualifier(&expr, old, new);
if rewritten != expr {
tx.execute(
&format!(
"UPDATE {CHECK_TABLE} SET check_expr = ?1 \
WHERE table_name = ?2 AND seq = ?3;"
),
rusqlite::params![rewritten, new, seq],
)
.map_err(DbError::from_rusqlite)?;
}
}
let description = do_describe_table(conn, new)?;
// (3) CSV follows the table: write `data/<new>.csv` from the renamed
// table (read in-tx by its new name) and delete `data/<old>.csv` — the
// existing rewrite+delete path; an empty table writes no CSV on either
// side. `schema_dirty` rewrites `project.yaml`, which reflects the new
// name automatically.
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![new.to_string()],
deleted_tables: vec![old.to_string()],
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Change a column's type.
///
/// Change a column's user-facing type, per ADR-0017's per-cell
@@ -5541,6 +5762,141 @@ mod check_references_column_tests {
}
}
/// Rewrite the old table name where it is used as a **qualifier** in a
/// CHECK expression (the identifier immediately followed by `.`) to the
/// new name — both the bare (`old.col`) and double-quoted (`"old".col`)
/// forms, case-insensitively — leaving everything else byte-for-byte
/// intact (ADR-0035 §6, sub-phase 4h).
///
/// **Why this is needed.** A native `ALTER TABLE old RENAME TO new`
/// rewrites table-qualified column references inside the renamed table's
/// *live* CHECK (`CHECK (T.age>0)` → `CHECK ("U".age>0)`), but our
/// *stored* CHECK text would keep the old name and break a later rebuild
/// (`schema_to_ddl` would emit `CHECK (T.age>0)` for a table now named
/// `U` → "no such table T"). This keeps the stored text in step with the
/// live schema. **Bounded problem:** a CHECK may reference only its own
/// table's columns (SQLite forbids subqueries / other tables in a CHECK),
/// so the only table qualifier that can appear is the renamed table's
/// name — the rewrite target is unambiguous.
///
/// Extends the [`check_references_column`] tokenizer: skips single-quoted
/// string literals (a literal containing the old name is untouched), and
/// a bare identifier equal to the old name but **not** used as a qualifier
/// (not followed by `.` — e.g. a column literally named like the table)
/// is left alone, so the common unqualified CHECK (`age > 0`) is a no-op.
fn rewrite_check_table_qualifier(check_expr: &str, old: &str, new: &str) -> String {
use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal};
let bytes = check_expr.as_bytes();
// Whether the next byte at `pos` begins a `.` qualifier separator.
let dot_follows = |pos: usize| bytes.get(pos) == Some(&b'.');
let mut out = String::with_capacity(check_expr.len());
let mut i = 0;
while i < check_expr.len() {
// Single-quoted string literal — copy verbatim, never rewrite.
if let Some(((start, end), _)) = consume_string_literal(check_expr, i) {
out.push_str(&check_expr[start..end]);
i = end;
continue;
}
// Double-quoted identifier — scan to the closing quote (`""` is an
// escaped quote). Rewrite when it is the old name used as a
// qualifier; table names never contain quotes, so the raw inner
// text suffices for comparison.
if bytes[i] == b'"' {
let mut j = i + 1;
while j < check_expr.len() {
if bytes[j] == b'"' {
if bytes.get(j + 1) == Some(&b'"') {
j += 2; // escaped quote inside the identifier
continue;
}
break; // closing quote
}
j += 1;
}
let end = (j + 1).min(check_expr.len()); // byte after closing `"`
let inner = &check_expr[i + 1..j.min(check_expr.len())];
if inner.eq_ignore_ascii_case(old) && dot_follows(end) {
out.push('"');
out.push_str(new);
out.push('"');
} else {
out.push_str(&check_expr[i..end]);
}
i = end;
continue;
}
// Bare identifier.
if let Some((start, end)) = consume_ident(check_expr, i) {
if check_expr[start..end].eq_ignore_ascii_case(old) && dot_follows(end) {
out.push_str(new);
} else {
out.push_str(&check_expr[start..end]);
}
i = end;
continue;
}
// Operator / paren / number / punctuation — copy one char.
let ch_len = check_expr[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&check_expr[i..i + ch_len]);
i += ch_len;
}
out
}
#[cfg(test)]
mod rewrite_check_table_qualifier_tests {
use super::rewrite_check_table_qualifier;
#[test]
fn rewrites_a_bare_qualifier() {
assert_eq!(rewrite_check_table_qualifier("T.age > 0", "T", "U"), "U.age > 0");
// multiple occurrences, table-level CHECK shape
assert_eq!(rewrite_check_table_qualifier("T.a <> T.b", "T", "U"), "U.a <> U.b");
}
#[test]
fn is_case_insensitive_on_the_qualifier() {
assert_eq!(rewrite_check_table_qualifier("t.age > 0", "T", "U"), "U.age > 0");
}
#[test]
fn rewrites_a_quoted_qualifier() {
assert_eq!(
rewrite_check_table_qualifier("\"T\".age > 0", "T", "U"),
"\"U\".age > 0"
);
}
#[test]
fn leaves_string_literals_untouched() {
assert_eq!(
rewrite_check_table_qualifier("note <> 'T.x' AND T.age > 0", "T", "U"),
"note <> 'T.x' AND U.age > 0"
);
}
#[test]
fn leaves_a_bare_name_that_is_not_a_qualifier() {
// A column literally named like the table, not followed by `.`.
assert_eq!(rewrite_check_table_qualifier("T > 0", "T", "U"), "T > 0");
}
#[test]
fn unqualified_check_is_a_no_op() {
assert_eq!(rewrite_check_table_qualifier("age > 0", "T", "U"), "age > 0");
}
#[test]
fn does_not_match_a_longer_identifier() {
// `Total` merely starts with `T`; not the qualifier `T`.
assert_eq!(
rewrite_check_table_qualifier("Total.x > 0", "T", "U"),
"Total.x > 0"
);
}
}
/// Whether any CHECK constraint on `table` references `column` — both the
/// table-level CHECKs (`read_table_checks`) and the column-level CHECKs
/// (`schema.columns[].check`), the guard for drop/rename column