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:
@@ -1608,6 +1608,12 @@ impl App {
|
||||
AlterTableAction::DropConstraint { .. } => {
|
||||
(Operation::DropConstraint, Some(table.as_str()), None)
|
||||
}
|
||||
// `RENAME TO <new>` — the failure concerns the table being
|
||||
// renamed (the old name); the executor authors the
|
||||
// existing-target / same-name refusals (ADR-0035 §6, 4h).
|
||||
AlterTableAction::RenameTable { .. } => {
|
||||
(Operation::RenameTable, Some(table.as_str()), None)
|
||||
}
|
||||
},
|
||||
C::SqlCreateTable { name, .. } => {
|
||||
(Operation::CreateTable, Some(name.as_str()), None)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -753,6 +753,12 @@ pub enum AlterTableAction {
|
||||
/// `DROP CONSTRAINT <name>` — drops a named table-level CHECK or a
|
||||
/// named FK (relationship), resolved by name (ADR-0035 §4g).
|
||||
DropConstraint { name: String },
|
||||
/// `RENAME TO <new>` — rename the table (ADR-0035 §6, sub-phase 4h).
|
||||
/// The one genuinely new low-level op in Phase 4: a native table
|
||||
/// rename plus reconciliation of the CSV file name and every metadata
|
||||
/// row that names the table (columns, both relationship ends, table
|
||||
/// CHECKs, and any table-qualified CHECK *text*). Advanced-mode only.
|
||||
RenameTable { new: String },
|
||||
}
|
||||
|
||||
/// A table-level constraint added via `ALTER TABLE … ADD [CONSTRAINT
|
||||
|
||||
+88
-5
@@ -1904,14 +1904,49 @@ const AT_ADD_COLUMN_TAIL: Node = Node::Seq(AT_ADD_COLUMN_TAIL_NODES);
|
||||
static AT_DROP_COLUMN_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("column")), COLUMN_NAME];
|
||||
const AT_DROP_COLUMN_TAIL: Node = Node::Seq(AT_DROP_COLUMN_TAIL_NODES);
|
||||
|
||||
static AT_RENAME_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("rename")),
|
||||
// New-table-name slot for `RENAME TO <new>` (ADR-0035 §6, sub-phase 4h).
|
||||
// Mirrors the `CREATE TABLE` name slot: `IdentSource::NewName` (a name
|
||||
// being introduced, not completed from existing tables) + the same
|
||||
// `reject_internal_table` parse-time validator, so an `__rdbms_*` target
|
||||
// is refused before submit. Wrapped in `NEW_NAME_HINT` like
|
||||
// `NEW_COLUMN_NAME`. `writes_table: false` — nothing downstream of
|
||||
// `rename to <new>` references the schema cache.
|
||||
const NEW_TABLE_NAME_IDENT: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "new_table_name",
|
||||
validator: Some(super::sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const NEW_TABLE_NAME: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
inner: &NEW_TABLE_NAME_IDENT,
|
||||
};
|
||||
|
||||
// The `rename` verb fans out (like `add`/`drop`, §6.1) to an inner
|
||||
// `Choice` whose two tails lead on DISTINCT second keywords: `column`
|
||||
// (rename column) and `to` (rename table — 4h). The walker `Choice`
|
||||
// selects by the leading token and never backtracks between branches, so
|
||||
// the distinct keywords keep them apart.
|
||||
static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("column")),
|
||||
COLUMN_NAME,
|
||||
Node::Word(Word::keyword("to")),
|
||||
NEW_COLUMN_NAME,
|
||||
];
|
||||
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
||||
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
|
||||
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
||||
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
|
||||
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
|
||||
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
|
||||
static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL];
|
||||
const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES);
|
||||
|
||||
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
|
||||
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
|
||||
@@ -1995,7 +2030,7 @@ const AT_DROP: Node = Node::Seq(AT_DROP_NODES);
|
||||
// concrete keywords, trap-safe. (The branch's `alter` is the action
|
||||
// word; the entry-word `alter` was already consumed by dispatch.) The
|
||||
// second-keyword fan-out happens in `AT_ADD` / `AT_DROP`'s inner Choice.
|
||||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME_COLUMN, AT_ALTER_COLUMN];
|
||||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME, AT_ALTER_COLUMN];
|
||||
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
|
||||
|
||||
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
|
||||
@@ -2127,7 +2162,12 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
|
||||
/// still routes to AddColumn.
|
||||
/// 3. **`add`** — a table-level constraint (CHECK / UNIQUE / FK / the
|
||||
/// refused PRIMARY KEY).
|
||||
/// 4. else **`drop`** — `drop constraint <name>`.
|
||||
/// 4. **`rename`** — `rename to <new>` (table rename, 4h). Reached only
|
||||
/// when `column` is absent (caught by step 2), so a lone `rename`
|
||||
/// means the table form. The new name binds a *distinct* role
|
||||
/// (`new_table_name`), so it never collides with the `table_name`
|
||||
/// target slot.
|
||||
/// 5. else **`drop`** — `drop constraint <name>`.
|
||||
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
let action = if path.contains_word("type") {
|
||||
@@ -2147,6 +2187,10 @@ fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, Va
|
||||
}
|
||||
} else if path.contains_word("add") {
|
||||
build_alter_add_table_constraint(path, source)?
|
||||
} else if path.contains_word("rename") {
|
||||
AlterTableAction::RenameTable {
|
||||
new: require_ident(path, "new_table_name")?,
|
||||
}
|
||||
} else {
|
||||
AlterTableAction::DropConstraint {
|
||||
name: require_ident(path, "constraint_name")?,
|
||||
@@ -2791,6 +2835,45 @@ mod sql_alter_table_tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_table() {
|
||||
// ADR-0035 §6 / 4h: `rename to <new>` — the `rename` verb fans out
|
||||
// on a distinct second keyword (`to` vs `column`).
|
||||
let (table, action) = alter("alter table Orders rename to Purchases");
|
||||
assert_eq!(table, "Orders");
|
||||
match action {
|
||||
AlterTableAction::RenameTable { new } => assert_eq!(new, "Purchases"),
|
||||
other => panic!("expected RenameTable, got {other:?}"),
|
||||
}
|
||||
// trailing semicolon tolerated
|
||||
assert!(matches!(
|
||||
alter("alter table Orders rename to Purchases;").1,
|
||||
AlterTableAction::RenameTable { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_table_does_not_steal_rename_column() {
|
||||
// The two `rename` tails coexist: `rename to` → table,
|
||||
// `rename column … to …` → column. Neither misroutes.
|
||||
assert!(matches!(
|
||||
alter("alter table T rename to U").1,
|
||||
AlterTableAction::RenameTable { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
alter("alter table T rename column a to b").1,
|
||||
AlterTableAction::RenameColumn { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_to_internal_target_refused_at_parse() {
|
||||
// The target slot carries the `reject_internal_table` validator
|
||||
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
|
||||
// before submit — engine-neutral, not a raw engine error.
|
||||
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alter_column_type_parses() {
|
||||
// ADR-0035 §4f: the fourth action, discriminated by the `type`
|
||||
|
||||
@@ -274,6 +274,7 @@ help:
|
||||
alter table <T> add column <col> <type> [not null] [unique] [default …] [check …]
|
||||
alter table <T> drop column <col>
|
||||
alter table <T> rename column <old> to <new>
|
||||
alter table <T> rename to <new>
|
||||
alter table <T> alter column <col> type <type>
|
||||
alter table <T> add [constraint <name>] check (<expr>) | unique (<col>, …) | foreign key (<col>) references <P>[(<col>)]
|
||||
alter table <T> drop constraint <name> — evolve a table's columns and constraints (advanced SQL)
|
||||
@@ -482,6 +483,7 @@ parse:
|
||||
alter table <Table> add column <Name> <Type> [not null] [unique] [default <expr>] [check (<expr>)]
|
||||
alter table <Table> drop column <Name>
|
||||
alter table <Table> rename column <Old> to <New>
|
||||
alter table <Table> rename to <NewName>
|
||||
alter table <Table> alter column <Name> type <Type>
|
||||
alter table <Table> add [constraint <Name>] check (<expr>) | unique (<col>, ...) | foreign key (<col>) references <Parent>[(<col>)]
|
||||
alter table <Table> drop constraint <Name>
|
||||
|
||||
@@ -62,6 +62,7 @@ pub enum Operation {
|
||||
AddColumn,
|
||||
DropColumn,
|
||||
RenameColumn,
|
||||
RenameTable,
|
||||
ChangeColumnType,
|
||||
AddRelationship,
|
||||
DropRelationship,
|
||||
@@ -93,6 +94,7 @@ impl Operation {
|
||||
Self::AddColumn => "add column",
|
||||
Self::DropColumn => "drop column",
|
||||
Self::RenameColumn => "rename column",
|
||||
Self::RenameTable => "rename table",
|
||||
Self::ChangeColumnType => "change column",
|
||||
Self::AddRelationship => "add relationship",
|
||||
Self::DropRelationship => "drop relationship",
|
||||
|
||||
@@ -2149,6 +2149,13 @@ async fn execute_command_typed(
|
||||
.alter_drop_constraint(table, name, src)
|
||||
.await
|
||||
.map(CommandOutcome::Schema),
|
||||
// `RENAME TO <new>` — the one genuinely new low-level op
|
||||
// (ADR-0035 §6, 4h): native table rename + CSV + metadata
|
||||
// reconciliation, one undo step.
|
||||
AlterTableAction::RenameTable { new } => database
|
||||
.rename_table(table, new, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
},
|
||||
Command::AddConstraint {
|
||||
table,
|
||||
|
||||
Reference in New Issue
Block a user