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:
@@ -652,3 +652,426 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
|
||||
))
|
||||
.expect("DROP CONSTRAINT after a fresh rebuild — the CHECK metadata was reconstructed");
|
||||
}
|
||||
|
||||
// --- 4h: ALTER TABLE … RENAME TO (ADR-0035 §6) --------------------------
|
||||
|
||||
/// Path to a table's CSV in the project data dir.
|
||||
fn csv_path(project: &project::Project, table: &str) -> std::path::PathBuf {
|
||||
project
|
||||
.path()
|
||||
.join(project::DATA_DIR)
|
||||
.join(format!("{table}.csv"))
|
||||
}
|
||||
|
||||
/// Drop the current db handle, delete the `.db`, reopen, and rebuild from
|
||||
/// the text artifacts (`project.yaml` + CSVs) only — the FRESH rebuild
|
||||
/// that re-emits DDL from stored metadata via `schema_to_ddl`. This is
|
||||
/// where the CHECK-text drift (Finding-1) and the FK / metadata
|
||||
/// reconciliation actually round-trip, unlike an in-place rebuild whose
|
||||
/// wipe leaves the user tables untouched.
|
||||
fn fresh_rebuild(
|
||||
old: Database,
|
||||
project: &project::Project,
|
||||
r: &tokio::runtime::Runtime,
|
||||
) -> Database {
|
||||
use rdbms_playground::project::PLAYGROUND_DB;
|
||||
// Drop only the db handle (release the .db file) and reuse the live
|
||||
// `project` — re-opening the Project would re-acquire its lock file,
|
||||
// which the still-alive `project` already holds. The Database does not
|
||||
// hold the project lock; only the Project does.
|
||||
drop(old);
|
||||
std::fs::remove_file(project.path().join(PLAYGROUND_DB)).expect("remove db");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.expect("db");
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None))
|
||||
.expect("rebuild");
|
||||
db
|
||||
}
|
||||
|
||||
fn table_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||
r.block_on(db.list_tables()).expect("list_tables")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
|
||||
// The CSV file follows the rename (data/<new>.csv written, <old>.csv
|
||||
// removed), rows are intact including a NULL (NULL-vs-empty fidelity),
|
||||
// and the renamed table round-trips through a FRESH rebuild.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table Orders with pk id(int)\n\
|
||||
add column Orders: note (text)\n\
|
||||
insert into Orders (id, note) values (1, 'first')\n\
|
||||
insert into Orders (id) values (2)\n\
|
||||
alter table Orders rename to Purchases\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, .. } => {
|
||||
assert_eq!(*count, 5, "all five lines replayed; events: {events:?}");
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
||||
}
|
||||
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(
|
||||
tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()),
|
||||
"the table is now Purchases, not Orders: {tables:?}"
|
||||
);
|
||||
assert!(csv_path(&project, "Purchases").exists(), "data/Purchases.csv written");
|
||||
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
|
||||
|
||||
let rows = r
|
||||
.block_on(db.query_data("Purchases".to_string(), None, None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][1].as_deref(), Some("first"));
|
||||
assert_eq!(rows[1][1], None, "the NULL note survived the rename");
|
||||
|
||||
// FRESH rebuild — the renamed table + its rows reconstruct from text.
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(
|
||||
tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()),
|
||||
"Purchases round-tripped through a fresh rebuild: {tables:?}"
|
||||
);
|
||||
let rows = r
|
||||
.block_on(db.query_data("Purchases".to_string(), None, None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[1][1], None, "NULL preserved across the rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_table_with_table_qualified_check_survives_fresh_rebuild() {
|
||||
// Finding-1 regression. A CHECK that qualifies a column with the table
|
||||
// name (`T.age`, and a table-level `T.lo < T.hi`) drifts on rename:
|
||||
// the engine rewrites the LIVE CHECK, but the STORED text would stay
|
||||
// `T.…` and break a FRESH rebuild (`schema_to_ddl` → "no such table
|
||||
// T"). The §2.9 rewrite keeps the stored text in step.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table T (id integer primary key, age integer check (T.age > 0), lo integer, hi integer, check (T.lo < T.hi))\n\
|
||||
insert into T (id, age, lo, hi) values (1, 5, 1, 9)\n\
|
||||
alter table T rename to U\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
||||
"create with table-qualified CHECKs + rename replayed; events: {events:?}"
|
||||
);
|
||||
|
||||
// The live CHECKs still enforce under the new name.
|
||||
let bad_age = r.block_on(db.insert(
|
||||
"U".to_string(),
|
||||
Some(vec!["id".into(), "age".into(), "lo".into(), "hi".into()]),
|
||||
vec![
|
||||
Value::Number("2".into()),
|
||||
Value::Number("0".into()),
|
||||
Value::Number("1".into()),
|
||||
Value::Number("9".into()),
|
||||
],
|
||||
Some("i".into()),
|
||||
));
|
||||
assert!(bad_age.is_err(), "age > 0 still enforced after rename");
|
||||
|
||||
// The headline: a FRESH rebuild reconstructs from the stored CHECK
|
||||
// text — which must now reference U, not T — and still enforces.
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
assert!(
|
||||
table_names(&db, &r).contains(&"U".to_string()),
|
||||
"U rebuilt from the text artifacts (would fail on 'no such table T' without the rewrite)"
|
||||
);
|
||||
let bad_after = r.block_on(db.insert(
|
||||
"U".to_string(),
|
||||
Some(vec!["id".into(), "age".into(), "lo".into(), "hi".into()]),
|
||||
vec![
|
||||
Value::Number("3".into()),
|
||||
Value::Number("-1".into()),
|
||||
Value::Number("1".into()),
|
||||
Value::Number("9".into()),
|
||||
],
|
||||
Some("i".into()),
|
||||
));
|
||||
assert!(bad_after.is_err(), "the rewritten CHECK enforces after a fresh rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
|
||||
// Renaming an FK *parent* updates the relationship's parent end; the
|
||||
// child FK still enforces, and the metadata is consistent enough that
|
||||
// a fresh rebuild (which re-emits the FK DDL from metadata) succeeds.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table P with pk id(int)\n\
|
||||
create table C (id integer primary key, p_id integer references P(id))\n\
|
||||
insert into P (id) values (1)\n\
|
||||
alter table P rename to Parent\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
// The child's outbound relationship now points at the new parent name.
|
||||
let c = r.block_on(db.describe_table("C".to_string(), None)).expect("describe C");
|
||||
assert_eq!(c.outbound_relationships.len(), 1);
|
||||
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
|
||||
|
||||
// FK still enforces: a child row referencing a missing parent fails.
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"C".to_string(),
|
||||
Some(vec!["id".into(), "p_id".into()]),
|
||||
vec![Value::Number("9".into()), Value::Number("99".into())],
|
||||
Some("i".into()),
|
||||
))
|
||||
.is_err(),
|
||||
"FK to the renamed parent still enforces"
|
||||
);
|
||||
|
||||
// Fresh rebuild re-emits the FK from metadata (parent_table = Parent).
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(tables.contains(&"Parent".to_string()) && tables.contains(&"C".to_string()));
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"C".to_string(),
|
||||
Some(vec!["id".into(), "p_id".into()]),
|
||||
vec![Value::Number("8".into()), Value::Number("77".into())],
|
||||
Some("i".into()),
|
||||
))
|
||||
.is_err(),
|
||||
"FK still enforces after a fresh rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
|
||||
// Renaming an FK *child* updates the relationship's child end.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table P with pk id(int)\n\
|
||||
create table C (id integer primary key, p_id integer references P(id))\n\
|
||||
insert into P (id) values (1)\n\
|
||||
alter table C rename to Child\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
// The parent's inbound relationship now names the renamed child.
|
||||
let p = r.block_on(db.describe_table("P".to_string(), None)).expect("describe P");
|
||||
assert_eq!(p.inbound_relationships.len(), 1);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
|
||||
// FK still enforces under the new child name; survives a fresh rebuild.
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"Child".to_string(),
|
||||
Some(vec!["id".into(), "p_id".into()]),
|
||||
vec![Value::Number("9".into()), Value::Number("99".into())],
|
||||
Some("i".into()),
|
||||
))
|
||||
.is_err(),
|
||||
"FK from the renamed child still enforces"
|
||||
);
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
assert!(table_names(&db, &r).contains(&"Child".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_self_referential_table_updates_both_ends() {
|
||||
// A self-referential FK has parent_table == child_table; both ends
|
||||
// must update on rename without a relationship-metadata PK conflict.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table N (id integer primary key, parent_id integer references N(id))\n\
|
||||
insert into N (id, parent_id) values (1, null)\n\
|
||||
alter table N rename to Tree\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
// Both ends of the self-reference now name `Tree`.
|
||||
let t = r.block_on(db.describe_table("Tree".to_string(), None)).expect("describe Tree");
|
||||
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
|
||||
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
|
||||
|
||||
// The self-FK still enforces and survives a fresh rebuild.
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"Tree".to_string(),
|
||||
Some(vec!["id".into(), "parent_id".into()]),
|
||||
vec![Value::Number("2".into()), Value::Number("99".into())],
|
||||
Some("i".into()),
|
||||
))
|
||||
.is_err(),
|
||||
"self-FK to a missing parent row is rejected"
|
||||
);
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
assert!(table_names(&db, &r).contains(&"Tree".to_string()));
|
||||
// A valid self-reference (parent_id = 1, which exists) is accepted.
|
||||
r.block_on(db.insert(
|
||||
"Tree".to_string(),
|
||||
Some(vec!["id".into(), "parent_id".into()]),
|
||||
vec![Value::Number("3".into()), Value::Number("1".into())],
|
||||
Some("i".into()),
|
||||
))
|
||||
.expect("valid self-reference accepted after rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
|
||||
// Auto-named indexes embed the old table name and are left STALE on
|
||||
// rename (user decision); the index stays functional and survives a
|
||||
// fresh rebuild.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
add column T: email (text)\n\
|
||||
create index on T (email)\n\
|
||||
alter table T rename to Users\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
|
||||
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
|
||||
assert_eq!(
|
||||
u.indexes[0].name, "T_email_idx",
|
||||
"the auto-name is left stale (embeds the old table name) per the user decision"
|
||||
);
|
||||
assert_eq!(u.indexes[0].columns, vec!["email".to_string()]);
|
||||
|
||||
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
|
||||
assert_eq!(u.indexes.len(), 1);
|
||||
assert_eq!(u.indexes[0].name, "T_email_idx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_table_is_one_undo_step() {
|
||||
// The rename is one user mutation = one whole-project snapshot = one
|
||||
// undo step. Undo restores the old name and its rows; redo reapplies.
|
||||
let (project, db, _d) = open_with_undo();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("rn.commands"),
|
||||
"create table Orders with pk id(int)\n\
|
||||
insert into Orders (id) values (1)\n\
|
||||
alter table Orders rename to Purchases\n",
|
||||
)
|
||||
.expect("write script");
|
||||
r.block_on(run_replay(&db, project.path(), "rn.commands"));
|
||||
assert!(table_names(&db, &r).contains(&"Purchases".to_string()));
|
||||
|
||||
// One undo reverts the rename.
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "rename was one undo step");
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(
|
||||
tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()),
|
||||
"undo restored the old table name: {tables:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
r.block_on(db.query_data("Orders".to_string(), None, None, None)).expect("query").rows.len(),
|
||||
1,
|
||||
"the row is back under the old name"
|
||||
);
|
||||
|
||||
// Redo reapplies the rename.
|
||||
assert!(r.block_on(db.redo()).expect("redo").is_some());
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(
|
||||
tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()),
|
||||
"redo reapplied the rename: {tables:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_rename_table_refusals() {
|
||||
// The executor's guards: existing-target, same-name, non-existent
|
||||
// source, and an internal `__rdbms_*` target (defense in depth — the
|
||||
// parse validator also refuses it, but a synthesised command reaches
|
||||
// the worker directly).
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("setup.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
create table X with pk id(int)\n",
|
||||
)
|
||||
.expect("write");
|
||||
r.block_on(run_replay(&db, project.path(), "setup.commands"));
|
||||
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into()))).is_err(),
|
||||
"rename to an existing other table is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into()))).is_err(),
|
||||
"rename to the same name is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into()))).is_err(),
|
||||
"rename of a non-existent table is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into()))).is_err(),
|
||||
"rename to an internal table name is refused at the executor"
|
||||
);
|
||||
|
||||
// Case-insensitive collisions are refused with engine-neutral wording
|
||||
// (not the raw engine "already another table" error) — the database
|
||||
// matches names case-insensitively (ADR-0035 §9).
|
||||
let case_only = r.block_on(db.rename_table("T".into(), "t".into(), Some("rn".into())));
|
||||
assert!(case_only.is_err(), "a case-only rename is refused");
|
||||
if let Err(e) = case_only {
|
||||
let msg = e.to_string();
|
||||
assert!(
|
||||
!msg.to_lowercase().contains("another table") && !msg.to_lowercase().contains("index"),
|
||||
"the refusal is engine-neutral, not a raw engine collision error: {msg}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into()))).is_err(),
|
||||
"rename to a name colliding case-insensitively with another table (X) is refused"
|
||||
);
|
||||
|
||||
// The failed renames left the schema untouched.
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(tables.contains(&"T".to_string()) && tables.contains(&"X".to_string()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user