338dc8a4cf
The standard-first ALTER COLUMN constraint gap-fill advanced mode lacked: - ALTER COLUMN <c> SET DATA TYPE <ty> — ISO canonical synonym for the PostgreSQL TYPE shorthand (same AlterColumnType action + executor). - SET NOT NULL / DROP NOT NULL — reuse the ADR-0029 do_add_constraint / do_drop_constraint executors (dry-run + internal-table guards free). - SET DEFAULT <expr> / DROP DEFAULT — SET DEFAULT uses a dedicated raw-SQL executor (do_set_column_default); sql_expr yields no typed Value, so it can't go through do_add_constraint. DROP DEFAULT reuses do_drop_constraint. Grammar: AT_ALTER_COLUMN gains a tail Choice (type / set / drop), reusing SQL_TYPE and the CREATE TABLE DEFAULT_NODES; builder dispatch routes the new column-attribute forms; runtime decomposes to the executors. ADR-0035 Am2 corrected in-place: SET DEFAULT decomposes to do_set_column_default, not do_add_constraint (Value-based) — found during build. Tests (test-first): 6 parse + 7 Tier-3 execution via run_replay. Suite 1962/0/1; clippy clean.
1467 lines
56 KiB
Rust
1467 lines
56 KiB
Rust
//! Sub-phase 4e/4f Tier-3 end-to-end tests for advanced-mode SQL
|
|
//! `ALTER TABLE` (ADR-0035 §4e + §4f).
|
|
//!
|
|
//! These drive the **full advanced-mode pipeline** via `run_replay`: a
|
|
//! literal `alter table …` line is parsed in Advanced mode, routed to
|
|
//! `Command::SqlAlterTable`, decomposed by the runtime to the existing
|
|
//! column executor, and persisted. 4e proves the decomposition for
|
|
//! add/drop/rename column and the **raw-text DEFAULT/CHECK ADD COLUMN**
|
|
//! path; 4f adds `ALTER COLUMN <col> TYPE <type>`, decomposed to
|
|
//! `change_column_type` with `ChangeColumnMode::ForceConversion` — the
|
|
//! §7 advanced policy (lossy converts with a note, no force flag;
|
|
//! static-refused / incompatible still refuse). The drop/rename refusals
|
|
//! (PK / FK / index / table-CHECK) and the internal-table guard live in
|
|
//! the shared executors and are covered by `tests/column_op_guards.rs` —
|
|
//! the SQL surface reaches the same code.
|
|
|
|
use rdbms_playground::db::Database;
|
|
use rdbms_playground::dsl::{Type, Value};
|
|
use rdbms_playground::event::AppEvent;
|
|
use rdbms_playground::persistence::Persistence;
|
|
use rdbms_playground::project;
|
|
use rdbms_playground::runtime::run_replay;
|
|
|
|
fn rt() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("tokio rt")
|
|
}
|
|
|
|
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create tempdir");
|
|
let project =
|
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
|
let db = Database::open_with_persistence(
|
|
project.db_path(),
|
|
Persistence::new(project.path().to_path_buf()),
|
|
)
|
|
.expect("db");
|
|
(project, db, dir)
|
|
}
|
|
|
|
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create tempdir");
|
|
let project =
|
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
|
let db = Database::open_with_persistence_and_undo(
|
|
project.db_path(),
|
|
Persistence::new(project.path().to_path_buf()),
|
|
true,
|
|
)
|
|
.expect("db");
|
|
(project, db, dir)
|
|
}
|
|
|
|
/// Run a single-conversion script through the full pipeline and report
|
|
/// whether it aborted with a `ReplayFailed` (i.e. the command was
|
|
/// refused). Used to assert the SQL `ALTER COLUMN TYPE` path reaches the
|
|
/// shared executor's static / incompatible refusals.
|
|
fn replay_is_refused(script: &str) -> bool {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(project.path().join("conv.commands"), script).expect("write script");
|
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
|
matches!(events.last(), Some(AppEvent::ReplayFailed { .. }))
|
|
}
|
|
|
|
/// The current user-facing type of column `name` in table `T`.
|
|
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
|
|
r.block_on(db.describe_table("T".to_string(), None))
|
|
.expect("describe")
|
|
.columns
|
|
.into_iter()
|
|
.find(|c| c.name == name)
|
|
.and_then(|c| c.user_type)
|
|
}
|
|
|
|
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
|
r.block_on(db.describe_table("T".to_string(), None))
|
|
.expect("describe")
|
|
.columns
|
|
.into_iter()
|
|
.map(|c| c.name)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
// A script exercising all three actions through the full pipeline.
|
|
// `v` is added (simple) so there is a non-PK column to rename/drop;
|
|
// a row is inserted before the ADD so the DEFAULT backfill is
|
|
// exercised by the rebuild.
|
|
std::fs::write(
|
|
project.path().join("alter.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (text)\n\
|
|
insert into T (id, v) values (1, 'a')\n\
|
|
alter table T add column qty int default 0 check (qty >= 0)\n\
|
|
alter table T rename column v to label\n\
|
|
alter table T add column note text\n\
|
|
alter table T drop column note\n",
|
|
)
|
|
.expect("write script");
|
|
|
|
let events = r.block_on(run_replay(&db, project.path(), "alter.commands"));
|
|
match events.last().expect("at least one event") {
|
|
AppEvent::ReplayCompleted { count, .. } => {
|
|
assert_eq!(*count, 7, "all seven lines replayed; events: {events:?}");
|
|
}
|
|
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
|
}
|
|
|
|
// Final schema: id, label (renamed from v), qty; `note` added then
|
|
// dropped.
|
|
let cols = column_names(&db, &r);
|
|
assert_eq!(cols, vec!["id".to_string(), "label".to_string(), "qty".to_string()]);
|
|
|
|
// The DEFAULT backfilled the pre-existing row to qty = 0.
|
|
let rows = r
|
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
|
.expect("query")
|
|
.rows;
|
|
assert_eq!(rows.len(), 1);
|
|
// qty is the third column; the rebuild backfilled the default.
|
|
assert_eq!(rows[0][2].as_deref(), Some("0"), "DEFAULT 0 backfilled the existing row");
|
|
|
|
// The CHECK (qty >= 0) is enforced: a negative qty is refused.
|
|
assert!(
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number("2".to_string()), Value::Number("-1".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_err(),
|
|
"the raw-text CHECK (qty >= 0) added via ALTER is enforced"
|
|
);
|
|
// A non-negative qty is accepted.
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number("3".to_string()), Value::Number("7".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.expect("qty = 7 satisfies the CHECK");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_add_column_survives_rebuild() {
|
|
// The column added via SQL ALTER (with a raw CHECK) round-trips
|
|
// through the text artifacts and survives a rebuild.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("alter.commands"),
|
|
"create table T with pk id(int)\n\
|
|
alter table T add column qty int check (qty >= 0)\n",
|
|
)
|
|
.expect("write script");
|
|
r.block_on(run_replay(&db, project.path(), "alter.commands"));
|
|
assert!(column_names(&db, &r).contains(&"qty".to_string()));
|
|
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
|
.expect("rebuild");
|
|
// The CHECK survives the rebuild — a negative qty is still refused.
|
|
assert!(column_names(&db, &r).contains(&"qty".to_string()));
|
|
assert!(
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number("1".to_string()), Value::Number("-5".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_err(),
|
|
"the ALTER-added CHECK is intact after rebuild"
|
|
);
|
|
}
|
|
|
|
// --- 4f: ALTER COLUMN … TYPE (ADR-0035 §4f) -----------------------------
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_clean_and_lossy_convert() {
|
|
// The key 4f assertion: the SQL ALTER COLUMN TYPE path wires
|
|
// `ForceConversion`. A lossy `real → int` (3.7 → 3) is therefore
|
|
// *performed*, not refused — under `Default` mode the replay line
|
|
// would refuse and abort (count < 6). A clean `int → text` stringifies.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("conv.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (real)\n\
|
|
add column T: w (int)\n\
|
|
insert into T (id, v, w) values (1, 3.7, 42)\n\
|
|
alter table T alter column v type int\n\
|
|
alter table T alter column w type text\n",
|
|
)
|
|
.expect("write script");
|
|
|
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
|
match events.last().expect("at least one event") {
|
|
AppEvent::ReplayCompleted { count, .. } => {
|
|
assert_eq!(*count, 6, "all six lines replayed; events: {events:?}");
|
|
}
|
|
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
|
}
|
|
|
|
let rows = r
|
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
|
.expect("query")
|
|
.rows;
|
|
assert_eq!(rows.len(), 1);
|
|
// v (col 1): lossy real→int performed → 3.7 stored as 3.
|
|
assert_eq!(rows[0][1].as_deref(), Some("3"), "lossy real→int performed (3.7→3)");
|
|
// w (col 2): clean int→text stringified → "42".
|
|
assert_eq!(rows[0][2].as_deref(), Some("42"), "clean int→text stringified");
|
|
|
|
// The columns now carry the new user-facing types (round-tripped
|
|
// through the metadata).
|
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int));
|
|
assert_eq!(col_type(&db, &r, "w"), Some(Type::Text));
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_int_to_serial_is_allowed() {
|
|
// ADR-0035 §7's "static-refused (→serial …)" summary is looser than
|
|
// the code: `int → serial` IS allowed (ADR-0018 §8 — auto-fills nulls,
|
|
// adds UNIQUE on a non-PK column). The SQL path reaches that supported
|
|
// conversion; the pre-existing non-null value is preserved.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("conv.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: n (int)\n\
|
|
insert into T (id, n) values (1, 100)\n\
|
|
alter table T alter column n type serial\n",
|
|
)
|
|
.expect("write script");
|
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
|
match events.last().expect("at least one event") {
|
|
AppEvent::ReplayCompleted { count, .. } => {
|
|
assert_eq!(*count, 4, "all four lines replayed; events: {events:?}");
|
|
}
|
|
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
|
}
|
|
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
|
|
let rows = r
|
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
|
.expect("query")
|
|
.rows;
|
|
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_incompatible_is_refused() {
|
|
// text "abc" → int has no valid per-cell conversion → refused (no
|
|
// force flag overrides incompatibles). The SQL path reaches the
|
|
// shared executor's incompatible refusal.
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (text)\n\
|
|
insert into T (id, v) values (1, 'abc')\n\
|
|
alter table T alter column v type int\n",
|
|
),
|
|
"an incompatible text→int conversion is refused via the SQL path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_static_refusals() {
|
|
// Static refusals are shared by both modes (ADR-0017 §3); the SQL
|
|
// ALTER COLUMN TYPE path reaches them.
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (text)\n\
|
|
alter table T alter column v type serial\n",
|
|
),
|
|
"text→serial is refused (only int→serial is allowed)"
|
|
);
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (text)\n\
|
|
alter table T alter column v type blob\n",
|
|
),
|
|
"↔ blob is statically refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_on_fk_column_is_refused() {
|
|
// The column is the child side of a relationship (outbound FK);
|
|
// changing its type is refused for v1 (ADR-0017 §4.2). The SQL ALTER
|
|
// COLUMN TYPE path reaches the same executor precondition.
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table P with pk id(int)\n\
|
|
create table C with pk cid(int)\n\
|
|
add column C: pid (int)\n\
|
|
add 1:n relationship from P.id to C.pid\n\
|
|
alter table C alter column pid type text\n",
|
|
),
|
|
"changing the type of a child-side FK column is refused via the SQL path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_survives_rebuild() {
|
|
// The user_type metadata update is the existing path, so the
|
|
// converted type round-trips through the text artifacts and survives
|
|
// a rebuild.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("conv.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (real)\n\
|
|
alter table T alter column v type int\n",
|
|
)
|
|
.expect("write script");
|
|
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "converted before rebuild");
|
|
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
|
.expect("rebuild");
|
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the converted type survives rebuild");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_alter_column_type_is_one_undo_step() {
|
|
// The runtime decomposes SqlAlterTable::AlterColumnType into ONE
|
|
// change_column_type call, so the whole conversion is one undo step
|
|
// (the executor's rebuild is one snapshot) — like the simple
|
|
// `change column`. Driven through the full SQL pipeline (run_replay
|
|
// fires the worker snapshot hook per command), then undone in one.
|
|
let (project, db, _d) = open_with_undo();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("conv.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: v (real)\n\
|
|
insert into T (id, v) values (1, 3.7)\n\
|
|
alter table T alter column v type int\n",
|
|
)
|
|
.expect("write script");
|
|
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the SQL ALTER COLUMN TYPE converted v");
|
|
|
|
// A single undo reverts the whole conversion.
|
|
assert!(
|
|
r.block_on(db.undo()).expect("undo").is_some(),
|
|
"the conversion was one undo step"
|
|
);
|
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type");
|
|
}
|
|
|
|
// --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) -----------
|
|
|
|
/// True if inserting `(id, qty)` into table `T` succeeds.
|
|
fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64) -> bool {
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number(id.to_string()), Value::Number(qty.to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_ok()
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
|
|
// ADD a named table-CHECK; it is enforced; it round-trips through a
|
|
// rebuild *with its name* — proven by DROP CONSTRAINT <name> still
|
|
// resolving after the rebuild (the name reached project.yaml and back).
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("c.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
alter table T add constraint qty_positive check (qty >= 0)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "c.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 3),
|
|
"events: {events:?}"
|
|
);
|
|
// Enforced: qty = -1 refused, qty = 5 accepted.
|
|
assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1");
|
|
assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK");
|
|
|
|
// Rebuild from text, then DROP CONSTRAINT by name must still work →
|
|
// the name survived the round-trip.
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
|
.expect("rebuild");
|
|
assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild");
|
|
std::fs::write(
|
|
project.path().join("drop.commands"),
|
|
"alter table T drop constraint qty_positive\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "drop.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
|
|
"DROP CONSTRAINT resolved the name after rebuild; events: {events:?}"
|
|
);
|
|
// After the drop the CHECK no longer applies: qty = -1 is accepted.
|
|
assert!(insert_t_qty_ok(&db, &r, 4, -1), "the CHECK was dropped");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_check_with_violating_data_is_refused() {
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
insert into T (id, qty) values (1, -5)\n\
|
|
alter table T add check (qty >= 0)\n",
|
|
),
|
|
"adding a CHECK that existing rows violate is refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("u.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: a (int)\n\
|
|
add column T: b (int)\n\
|
|
insert into T (id, a, b) values (1, 1, 2)\n\
|
|
alter table T add unique (a, b)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
|
|
"events: {events:?}"
|
|
);
|
|
let dup_ok = |id: i64, a: i64, b: i64| {
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
|
|
vec![
|
|
Value::Number(id.to_string()),
|
|
Value::Number(a.to_string()),
|
|
Value::Number(b.to_string()),
|
|
],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_ok()
|
|
};
|
|
assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)");
|
|
assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted");
|
|
|
|
// Survives rebuild (the unique_constraints yaml path).
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
|
.expect("rebuild");
|
|
assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_unique_with_duplicate_data_is_refused() {
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: a (int)\n\
|
|
insert into T (id, a) values (1, 7)\n\
|
|
insert into T (id, a) values (2, 7)\n\
|
|
alter table T add unique (a)\n",
|
|
),
|
|
"adding a UNIQUE that existing rows violate is refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_drop_composite_unique_by_derived_name() {
|
|
// ADR-0035 Amendment 1: a composite UNIQUE is anonymous, addressed by
|
|
// its derived name `unique_<cols>`. DROP CONSTRAINT <derived-name>
|
|
// removes it via the rebuild primitive and the UNIQUE stops enforcing.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("u.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: a (int)\n\
|
|
add column T: b (int)\n\
|
|
alter table T add unique (a, b)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 4),
|
|
"events: {events:?}"
|
|
);
|
|
let dup_ok = |id: i64, a: i64, b: i64| {
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
|
|
vec![
|
|
Value::Number(id.to_string()),
|
|
Value::Number(a.to_string()),
|
|
Value::Number(b.to_string()),
|
|
],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_ok()
|
|
};
|
|
assert!(dup_ok(1, 1, 2), "first (1, 2) accepted");
|
|
assert!(!dup_ok(2, 1, 2), "duplicate (1, 2) rejected while the UNIQUE stands");
|
|
|
|
// Drop the UNIQUE by its derived name through the existing DROP
|
|
// CONSTRAINT grammar.
|
|
r.block_on(db.alter_drop_constraint(
|
|
"T".to_string(),
|
|
"unique_a_b".to_string(),
|
|
Some("alter table T drop constraint unique_a_b".to_string()),
|
|
))
|
|
.expect("drop constraint unique_a_b resolves the composite UNIQUE");
|
|
|
|
// The UNIQUE no longer enforces: the previously-rejected duplicate is
|
|
// now accepted.
|
|
assert!(dup_ok(3, 1, 2), "duplicate (1, 2) accepted after the UNIQUE was dropped");
|
|
|
|
// And it stays gone across a rebuild from text.
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
|
.expect("rebuild");
|
|
assert!(dup_ok(4, 1, 2), "still no UNIQUE after rebuild");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_drop_composite_unique_ambiguous_name_is_refused() {
|
|
// Two distinct composite UNIQUEs can derive the same name —
|
|
// `unique (a, b_c)` and `unique (a_b, c)` both → `unique_a_b_c`. The
|
|
// drop must refuse as ambiguous, never guess which to drop.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("u.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: a (int)\n\
|
|
add column T: b_c (int)\n\
|
|
add column T: a_b (int)\n\
|
|
add column T: c (int)\n\
|
|
alter table T add unique (a, b_c)\n\
|
|
alter table T add unique (a_b, c)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 7),
|
|
"setup events: {events:?}"
|
|
);
|
|
let err = r
|
|
.block_on(db.alter_drop_constraint(
|
|
"T".to_string(),
|
|
"unique_a_b_c".to_string(),
|
|
Some("alter table T drop constraint unique_a_b_c".to_string()),
|
|
))
|
|
.expect_err("an ambiguous derived name is refused, not guessed");
|
|
let msg = err.friendly_message();
|
|
assert!(
|
|
msg.to_lowercase().contains("ambiguous") || msg.to_lowercase().contains("more than one"),
|
|
"refusal explains the ambiguity; got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_drop_composite_unique_is_one_undo_step() {
|
|
// Dropping a composite UNIQUE rebuilds the table = one undo step; undo
|
|
// restores the constraint (ADR-0035 Amendment 1). The drop is the last
|
|
// mutation, so a single undo targets it (checked via describe, so no
|
|
// extra mutation shifts the undo target).
|
|
let (project, db, _d) = open_with_undo();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("u.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: a (int)\n\
|
|
add column T: b (int)\n\
|
|
alter table T add unique (a, b)\n",
|
|
)
|
|
.expect("write");
|
|
r.block_on(run_replay(&db, project.path(), "u.commands"));
|
|
let has_unique = || {
|
|
!r.block_on(db.describe_table("T".to_string(), None))
|
|
.expect("describe")
|
|
.unique_constraints
|
|
.is_empty()
|
|
};
|
|
assert!(has_unique(), "the composite UNIQUE exists before the drop");
|
|
|
|
r.block_on(db.alter_drop_constraint(
|
|
"T".to_string(),
|
|
"unique_a_b".to_string(),
|
|
Some("alter table T drop constraint unique_a_b".to_string()),
|
|
))
|
|
.expect("drop the composite UNIQUE");
|
|
assert!(!has_unique(), "the composite UNIQUE is gone after the drop");
|
|
|
|
assert!(
|
|
r.block_on(db.undo()).expect("undo").is_some(),
|
|
"the DROP CONSTRAINT was one undo step"
|
|
);
|
|
assert!(has_unique(), "one undo restored the composite UNIQUE");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_foreign_key_missing_child_column_refuses_without_dsl_flag() {
|
|
// Gap C (ADR-0035 Amendment 1): the SQL ADD FOREIGN KEY refusal for a
|
|
// missing child column must speak SQL — not suggest the DSL-only
|
|
// `--create-fk` flag (which `do_add_relationship` mentions for the
|
|
// simple `add relationship` surface).
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("fk.commands"),
|
|
"create table P with pk id(int)\n\
|
|
create table C with pk cid(int)\n\
|
|
alter table C add foreign key (pid) references P(id)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
|
|
let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else {
|
|
panic!("expected ReplayFailed; events: {events:?}");
|
|
};
|
|
assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}");
|
|
assert!(error.contains("pid"), "names the missing column; got: {error}");
|
|
assert!(
|
|
error.to_lowercase().contains("add it first")
|
|
|| error.to_lowercase().contains("does not exist"),
|
|
"actionable wording; got: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_foreign_key_creates_an_enforced_relationship() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("fk.commands"),
|
|
"create table P with pk id(int)\n\
|
|
create table C with pk cid(int)\n\
|
|
add column C: pid (int)\n\
|
|
insert into P (id) values (1)\n\
|
|
alter table C add constraint c_to_p foreign key (pid) references P(id)\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
|
|
"events: {events:?}"
|
|
);
|
|
// FK enforced: a child row referencing a missing parent is rejected;
|
|
// one referencing the existing parent (id = 1) is accepted.
|
|
let insert_c = |cid: i64, pid: i64| {
|
|
r.block_on(db.insert(
|
|
"C".to_string(),
|
|
Some(vec!["cid".to_string(), "pid".to_string()]),
|
|
vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
};
|
|
assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted");
|
|
assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_drop_constraint_removes_a_named_foreign_key() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("fk.commands"),
|
|
"create table P with pk id(int)\n\
|
|
create table C with pk cid(int)\n\
|
|
add column C: pid (int)\n\
|
|
alter table C add constraint c_to_p foreign key (pid) references P(id)\n\
|
|
alter table C drop constraint c_to_p\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
|
|
"events: {events:?}"
|
|
);
|
|
// The FK is gone: a child referencing a missing parent now succeeds.
|
|
assert!(
|
|
r.block_on(db.insert(
|
|
"C".to_string(),
|
|
Some(vec!["cid".to_string(), "pid".to_string()]),
|
|
vec![Value::Number("1".to_string()), Value::Number("999".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_ok(),
|
|
"after DROP CONSTRAINT the FK no longer applies"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_constraint_name_collision_is_refused() {
|
|
// A named CHECK cannot reuse a relationship (FK) name on the same
|
|
// table — both are `DROP CONSTRAINT <name>` targets, so a collision
|
|
// would make the drop ambiguous.
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table P with pk id(int)\n\
|
|
create table C with pk cid(int)\n\
|
|
add column C: pid (int)\n\
|
|
alter table C add constraint dup foreign key (pid) references P(id)\n\
|
|
alter table C add constraint dup check (cid > 0)\n",
|
|
),
|
|
"a CHECK reusing an existing FK name on the table is refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_drop_unknown_constraint_is_refused() {
|
|
assert!(
|
|
replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
alter table T drop constraint nope\n",
|
|
),
|
|
"dropping a non-existent constraint is refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_add_constraint_is_one_undo_step() {
|
|
// ADD CONSTRAINT CHECK is one rebuild = one undo step; driven through
|
|
// the full SQL pipeline, then undone in one.
|
|
let (project, db, _d) = open_with_undo();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("c.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
insert into T (id, qty) values (1, 5)\n\
|
|
alter table T add constraint qty_positive check (qty >= 0)\n",
|
|
)
|
|
.expect("write");
|
|
r.block_on(run_replay(&db, project.path(), "c.commands"));
|
|
assert!(!insert_t_qty_ok(&db, &r, 2, -1), "the CHECK is enforced");
|
|
|
|
assert!(
|
|
r.block_on(db.undo()).expect("undo").is_some(),
|
|
"the ADD CONSTRAINT was one undo step"
|
|
);
|
|
// After undo the CHECK is gone: qty = -1 is accepted.
|
|
assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK");
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
|
|
// A FRESH rebuild (deleted .db, reconstructed from project.yaml) must
|
|
// repopulate the table-CHECK metadata — not just re-emit the CHECK
|
|
// into the recreated DDL. Otherwise the CHECK is enforced but its
|
|
// metadata (incl. the name) is lost: `describe` / `DROP CONSTRAINT` /
|
|
// a later save would drop it (ADR-0035 §4g; fixes a latent 4a.3 gap).
|
|
use rdbms_playground::dsl::ColumnSpec;
|
|
use rdbms_playground::project::PLAYGROUND_DB;
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let r = rt();
|
|
let project_path = {
|
|
let project = project::open_or_create(None, Some(dir.path())).expect("open");
|
|
let path = project.path().to_path_buf();
|
|
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
|
.expect("db");
|
|
r.block_on(db.sql_create_table(
|
|
"T".to_string(),
|
|
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
|
|
vec!["id".to_string()],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
false,
|
|
Some("create table T (id int primary key, qty int)".to_string()),
|
|
))
|
|
.expect("create");
|
|
r.block_on(db.alter_add_table_check(
|
|
"T".to_string(),
|
|
Some("qty_positive".to_string()),
|
|
"qty >= 0".to_string(),
|
|
Some("alter table T add constraint qty_positive check (qty >= 0)".to_string()),
|
|
))
|
|
.expect("add named check");
|
|
drop(db);
|
|
path
|
|
};
|
|
// Delete the .db → the next open + rebuild reconstructs from yaml.
|
|
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
|
let project = project::Project::open(&project_path).unwrap();
|
|
let db = Database::open_with_persistence(
|
|
project.db_path(),
|
|
Persistence::new(project.path().to_path_buf()),
|
|
)
|
|
.unwrap();
|
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None)).expect("rebuild");
|
|
|
|
// The named CHECK metadata survived: DROP CONSTRAINT by name resolves.
|
|
r.block_on(db.alter_drop_constraint(
|
|
"T".to_string(),
|
|
"qty_positive".to_string(),
|
|
Some("drop".to_string()),
|
|
))
|
|
.expect("DROP CONSTRAINT after a fresh rebuild — the CHECK metadata was reconstructed");
|
|
}
|
|
|
|
// --- 4i (b): describe shows table-level constraints ---------------------
|
|
|
|
#[test]
|
|
fn e2e_describe_shows_table_level_constraints() {
|
|
// ADR-0035 §4i (b): `describe` surfaces composite UNIQUE and
|
|
// table-level CHECK constraints (named + unnamed) — the executor
|
|
// populates them on TableDescription from the metadata.
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("d.commands"),
|
|
"create table T (a integer primary key, b integer, unique (a, b), check (a < b))\n\
|
|
alter table T add constraint a_ne_b check (a <> b)\n",
|
|
)
|
|
.expect("write script");
|
|
let events = r.block_on(run_replay(&db, project.path(), "d.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { .. })),
|
|
"events: {events:?}"
|
|
);
|
|
|
|
let desc = r.block_on(db.describe_table("T".to_string(), None)).expect("describe");
|
|
assert_eq!(
|
|
desc.unique_constraints,
|
|
vec![vec!["a".to_string(), "b".to_string()]],
|
|
"composite UNIQUE surfaced"
|
|
);
|
|
let checks: Vec<(Option<String>, String)> = desc
|
|
.check_constraints
|
|
.iter()
|
|
.map(|c| (c.name.clone(), c.expr.clone()))
|
|
.collect();
|
|
assert!(
|
|
checks.iter().any(|(n, e)| n.is_none() && e.contains("a < b")),
|
|
"unnamed table CHECK surfaced: {checks:?}"
|
|
);
|
|
assert!(
|
|
checks
|
|
.iter()
|
|
.any(|(n, e)| n.as_deref() == Some("a_ne_b") && e.contains("a <> b")),
|
|
"named table CHECK surfaced with its name: {checks:?}"
|
|
);
|
|
}
|
|
|
|
// --- 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()));
|
|
}
|
|
|
|
// --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill -------------
|
|
//
|
|
// Full advanced-mode pipeline (parse → SqlAlterTable → runtime
|
|
// decomposition → ADR-0029 executors / the raw-default executor →
|
|
// persist), driven via run_replay. Behaviour-based assertions: the
|
|
// constraint is exercised by a follow-up insert, the way the 4e/4f tests
|
|
// exercise CHECK.
|
|
|
|
/// `ALTER COLUMN … SET NOT NULL` on a clean column succeeds and is then
|
|
/// enforced (a NULL insert is refused).
|
|
#[test]
|
|
fn e2e_alter_column_set_not_null_enforced() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("a.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
insert into T (id, qty) values (1, 5)\n\
|
|
alter table T alter column qty set not null\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
|
"set not null on a clean column succeeds; events: {events:?}"
|
|
);
|
|
assert!(
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number("2".to_string()), Value::Null],
|
|
Some("insert".to_string()),
|
|
))
|
|
.is_err(),
|
|
"SET NOT NULL is enforced — a NULL is refused"
|
|
);
|
|
}
|
|
|
|
/// The ADR-0029 §5 dry-run fires through the SQL surface: SET NOT NULL on
|
|
/// a column that already holds a NULL is refused (the replay aborts).
|
|
#[test]
|
|
fn e2e_alter_column_set_not_null_refused_on_existing_null() {
|
|
assert!(replay_is_refused(
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
insert into T (id) values (1)\n\
|
|
alter table T alter column qty set not null\n",
|
|
));
|
|
}
|
|
|
|
/// `DROP NOT NULL` reverses it — a NULL insert is accepted again.
|
|
#[test]
|
|
fn e2e_alter_column_drop_not_null_allows_nulls() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("a.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
alter table T alter column qty set not null\n\
|
|
alter table T alter column qty drop not null\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
|
"events: {events:?}"
|
|
);
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "qty".to_string()]),
|
|
vec![Value::Number("1".to_string()), Value::Null],
|
|
Some("insert".to_string()),
|
|
))
|
|
.expect("NULL qty accepted after DROP NOT NULL");
|
|
}
|
|
|
|
/// `ALTER COLUMN … SET DEFAULT <expr>` backfills an omitted insert.
|
|
#[test]
|
|
fn e2e_alter_column_set_default_applies() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("a.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
alter table T alter column qty set default 5\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })),
|
|
"events: {events:?}"
|
|
);
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string()]),
|
|
vec![Value::Number("1".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.expect("insert omitting qty");
|
|
let rows = r
|
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
|
.expect("query")
|
|
.rows;
|
|
assert_eq!(
|
|
rows[0][1].as_deref(),
|
|
Some("5"),
|
|
"SET DEFAULT 5 backfilled the omitted column"
|
|
);
|
|
}
|
|
|
|
/// `SET DEFAULT` on an auto-generated column is refused (ADR-0029 §6).
|
|
#[test]
|
|
fn e2e_alter_column_set_default_refused_on_serial() {
|
|
// `create table T with pk` → id serial; a default on it is refused.
|
|
assert!(replay_is_refused(
|
|
"create table T with pk\n\
|
|
alter table T alter column id set default 0\n",
|
|
));
|
|
}
|
|
|
|
/// `DROP DEFAULT` removes it — an omitted insert is then NULL.
|
|
#[test]
|
|
fn e2e_alter_column_drop_default_removes_it() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("a.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
alter table T alter column qty set default 5\n\
|
|
alter table T alter column qty drop default\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
|
"events: {events:?}"
|
|
);
|
|
r.block_on(db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string()]),
|
|
vec![Value::Number("1".to_string())],
|
|
Some("insert".to_string()),
|
|
))
|
|
.expect("insert omitting qty");
|
|
let rows = r
|
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
|
.expect("query")
|
|
.rows;
|
|
assert_eq!(
|
|
rows[0][1].as_deref(),
|
|
None,
|
|
"DROP DEFAULT — the omitted column is NULL"
|
|
);
|
|
}
|
|
|
|
/// The ISO `SET DATA TYPE` synonym executes the same conversion as the
|
|
/// PostgreSQL `TYPE` shorthand (ADR-0035 Amendment 2).
|
|
#[test]
|
|
fn e2e_alter_column_set_data_type_converts() {
|
|
let (project, db, _d) = open();
|
|
let r = rt();
|
|
std::fs::write(
|
|
project.path().join("a.commands"),
|
|
"create table T with pk id(int)\n\
|
|
add column T: qty (int)\n\
|
|
insert into T (id, qty) values (1, 7)\n\
|
|
alter table T alter column qty set data type text\n",
|
|
)
|
|
.expect("write");
|
|
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
|
assert!(
|
|
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
|
"events: {events:?}"
|
|
);
|
|
assert_eq!(
|
|
col_type(&db, &r, "qty"),
|
|
Some(Type::Text),
|
|
"SET DATA TYPE converted qty to text (same as the TYPE shorthand)"
|
|
);
|
|
}
|