41b7e9a049
One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit.
576 lines
19 KiB
Rust
576 lines
19 KiB
Rust
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
|
|
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
|
|
//!
|
|
//! Covers parse, junction generation (columns / compound PK / two
|
|
//! enforced FKs), the `as <name>` override, a compound-PK parent,
|
|
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
|
|
//! parent guard.
|
|
|
|
use rdbms_playground::db::Database;
|
|
use rdbms_playground::dsl::command::RowFilter;
|
|
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
|
use rdbms_playground::persistence::Persistence;
|
|
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
|
|
|
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("tempdir");
|
|
let project = project::open_or_create(None, Some(dir.path())).expect("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("tempdir");
|
|
let project = project::open_or_create(None, Some(dir.path())).expect("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)
|
|
}
|
|
|
|
/// A parent table `(id serial PK, label text)` — the `label` gives an
|
|
/// insertable non-PK column (a serial-PK-only table has nothing to put
|
|
/// in a short-form INSERT).
|
|
async fn serial_pk_table(db: &Database, name: &str) {
|
|
db.create_table(
|
|
name.to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("label", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("create {name}: {e}"));
|
|
}
|
|
|
|
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
|
|
async fn add_row(db: &Database, table: &str, label: &str) {
|
|
db.insert(
|
|
table.to_string(),
|
|
Some(vec!["label".to_string()]),
|
|
vec![Value::Text(label.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
|
|
}
|
|
|
|
// ---- parse layer -----------------------------------------------
|
|
|
|
#[test]
|
|
fn parses_to_create_m2n_relationship() {
|
|
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
|
|
Command::CreateM2nRelationship { t1, t2, name } => {
|
|
assert_eq!(t1, "Students");
|
|
assert_eq!(t2, "Courses");
|
|
assert_eq!(name, None);
|
|
}
|
|
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_with_as_name() {
|
|
match parse_command("create m:n relationship from Students to Courses as Enrollments")
|
|
.expect("parses")
|
|
{
|
|
Command::CreateM2nRelationship { name, .. } => {
|
|
assert_eq!(name.as_deref(), Some("Enrollments"))
|
|
}
|
|
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- junction generation ---------------------------------------
|
|
|
|
#[test]
|
|
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
|
|
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.expect("create m:n");
|
|
|
|
// Auto-named `Students_Courses` exists.
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(
|
|
tables.contains(&"Students_Courses".to_string()),
|
|
"tables: {tables:?}"
|
|
);
|
|
|
|
// Two FK columns, both part of the compound PK.
|
|
let desc = db
|
|
.describe_table("Students_Courses".to_string())
|
|
.await
|
|
.unwrap();
|
|
let cols: Vec<(&str, bool)> = desc
|
|
.columns
|
|
.iter()
|
|
.map(|c| (c.name.as_str(), c.primary_key))
|
|
.collect();
|
|
assert_eq!(
|
|
cols,
|
|
vec![("Students_id", true), ("Courses_id", true)],
|
|
"expected two FK columns forming the compound PK"
|
|
);
|
|
// Two outbound relationships (one per parent).
|
|
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
|
|
|
|
// FK enforcement: a junction row needs existing parents.
|
|
add_row(&db, "Students", "s1").await;
|
|
add_row(&db, "Courses", "c1").await;
|
|
db.insert(
|
|
"Students_Courses".to_string(),
|
|
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
|
vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Number("1".to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("valid link");
|
|
// Duplicate link refused by the compound PK.
|
|
let dup = db
|
|
.insert(
|
|
"Students_Courses".to_string(),
|
|
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
|
vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Number("1".to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
dup.is_err(),
|
|
"duplicate (Students_id, Courses_id) must be refused"
|
|
);
|
|
// A link to a non-existent parent is refused by the FK.
|
|
let orphan = db
|
|
.insert(
|
|
"Students_Courses".to_string(),
|
|
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
|
vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Number("99".to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
orphan.is_err(),
|
|
"link to a non-existent Course must be refused"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn as_name_overrides_the_junction_table_name() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship(
|
|
"Students".to_string(),
|
|
"Courses".to_string(),
|
|
Some("Enrollments".to_string()),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create m:n as Enrollments");
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(
|
|
tables.contains(&"Enrollments".to_string()),
|
|
"tables: {tables:?}"
|
|
);
|
|
assert!(!tables.contains(&"Students_Courses".to_string()));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn compound_parent_pk_contributes_one_fk_column_each() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
// Sections has a 2-column PK (course_id, term).
|
|
db.create_table(
|
|
"Sections".to_string(),
|
|
vec![
|
|
ColumnSpec::new("course_id", Type::Int),
|
|
ColumnSpec::new("term", Type::Int),
|
|
],
|
|
vec!["course_id".to_string(), "term".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
serial_pk_table(&db, "Students").await;
|
|
|
|
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
|
|
.await
|
|
.expect("create m:n");
|
|
|
|
let desc = db
|
|
.describe_table("Students_Sections".to_string())
|
|
.await
|
|
.unwrap();
|
|
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
|
assert_eq!(
|
|
names,
|
|
vec!["Students_id", "Sections_course_id", "Sections_term"]
|
|
);
|
|
// All three form the compound PK.
|
|
assert!(
|
|
desc.columns.iter().all(|c| c.primary_key),
|
|
"all columns are PK: {names:?}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_a_parent_cascades_to_the_junction() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.unwrap();
|
|
add_row(&db, "Students", "s1").await;
|
|
add_row(&db, "Courses", "c1").await;
|
|
db.insert(
|
|
"Students_Courses".to_string(),
|
|
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
|
vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Number("1".to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
|
db.delete("Students".to_string(), RowFilter::AllRows, None)
|
|
.await
|
|
.unwrap();
|
|
let rows = db
|
|
.query_data("Students_Courses".to_string(), None, None)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
rows.rows.is_empty(),
|
|
"junction rows should cascade-delete, got {:?}",
|
|
rows.rows
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn create_m2n_is_one_undo_step() {
|
|
let (_p, db, _d) = open_with_undo();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
// A real source makes the command undoable (a source-less call is
|
|
// treated as an internal, non-undoable op).
|
|
db.create_m2n_relationship(
|
|
"Students".to_string(),
|
|
"Courses".to_string(),
|
|
None,
|
|
Some("create m:n relationship from Students to Courses".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
db.list_tables()
|
|
.await
|
|
.unwrap()
|
|
.contains(&"Students_Courses".to_string())
|
|
);
|
|
|
|
// One undo removes the junction table AND both relationships.
|
|
db.undo().await.unwrap();
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(
|
|
!tables.contains(&"Students_Courses".to_string()),
|
|
"undo should remove the junction: {tables:?}"
|
|
);
|
|
// The parents' relationships are gone too (the junction held them).
|
|
let students = db.describe_table("Students".to_string()).await.unwrap();
|
|
assert!(
|
|
students.inbound_relationships.is_empty(),
|
|
"no leftover relationship after undo"
|
|
);
|
|
});
|
|
}
|
|
|
|
// ---- guards ----------------------------------------------------
|
|
|
|
#[test]
|
|
fn self_referential_m2n_is_refused() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Users").await;
|
|
let err = db
|
|
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
|
|
.await
|
|
.expect_err("self m:n must be refused");
|
|
assert!(
|
|
format!("{err}").contains("two different tables"),
|
|
"got: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn missing_parent_table_is_refused() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
let err = db
|
|
.create_m2n_relationship(
|
|
"Students".to_string(),
|
|
"Nonexistent".to_string(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("a missing parent table must be refused");
|
|
// The standard "no such table" guard (require_canonical_table).
|
|
assert!(
|
|
format!("{err}").to_lowercase().contains("no such table"),
|
|
"got: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn junction_name_collision_is_refused() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.expect("first m:n");
|
|
// A second identical m:n collides on the auto-name `Students_Courses`.
|
|
let err = db
|
|
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.expect_err("a junction-name collision must be refused");
|
|
assert!(
|
|
format!("{err}").to_lowercase().contains("exist"),
|
|
"got: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
// ---- the junction is a normal table ----------------------------
|
|
|
|
#[test]
|
|
fn the_junction_can_be_renamed() {
|
|
// C4 requirement text: "an auto-named junction table the user can
|
|
// rename." It is a normal table, so `rename table` works.
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.unwrap();
|
|
db.rename_table(
|
|
"Students_Courses".to_string(),
|
|
"Enrollments".to_string(),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("rename the junction");
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(
|
|
tables.contains(&"Enrollments".to_string()),
|
|
"tables: {tables:?}"
|
|
);
|
|
assert!(!tables.contains(&"Students_Courses".to_string()));
|
|
// Both relationships survive the rename (rebuild-preserving).
|
|
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
|
|
assert_eq!(
|
|
desc.outbound_relationships.len(),
|
|
2,
|
|
"FKs preserved across rename"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn junction_survives_save_and_rebuild() {
|
|
// Persistence round-trip: the junction + both relationships are
|
|
// reconstructed from project.yaml after the .db is discarded.
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let project_path = {
|
|
let project = project::open_or_create(None, Some(dir.path())).unwrap();
|
|
let path = project.path().to_path_buf();
|
|
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
|
.unwrap();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship(
|
|
"Students".to_string(),
|
|
"Courses".to_string(),
|
|
None,
|
|
Some("create m:n relationship from Students to Courses".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
});
|
|
drop(db);
|
|
drop(project);
|
|
path
|
|
};
|
|
// Discard the derived .db so the next open rebuilds from text.
|
|
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();
|
|
rt().block_on(async {
|
|
db.rebuild_from_text(project.path().to_path_buf(), None)
|
|
.await
|
|
.expect("rebuild");
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(
|
|
tables.contains(&"Students_Courses".to_string()),
|
|
"junction survived: {tables:?}"
|
|
);
|
|
let desc = db
|
|
.describe_table("Students_Courses".to_string())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
desc.outbound_relationships.len(),
|
|
2,
|
|
"both FKs reconstructed"
|
|
);
|
|
assert!(
|
|
desc.columns.iter().all(|c| c.primary_key),
|
|
"compound PK reconstructed"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn as_an_internal_name_is_refused() {
|
|
// The junction must be a real, listable table — an `as __rdbms_*`
|
|
// name would be filtered out of `list_tables` (a hidden orphan).
|
|
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
let err = db
|
|
.create_m2n_relationship(
|
|
"Students".to_string(),
|
|
"Courses".to_string(),
|
|
Some("__rdbms_evil".to_string()),
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("an internal junction name must be refused");
|
|
assert!(format!("{err}").contains("no such table"), "got: {err}");
|
|
assert!(
|
|
!db.list_tables()
|
|
.await
|
|
.unwrap()
|
|
.contains(&"__rdbms_evil".to_string())
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn pk_less_parent_is_refused() {
|
|
let (_p, db, _d) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
// A PK-less table via the advanced SQL path.
|
|
db.sql_create_table(
|
|
"Loose".to_string(),
|
|
vec![ColumnSpec::new("a", Type::Int)],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
|
|
.await
|
|
.expect_err("a PK-less parent must be refused");
|
|
assert!(format!("{err}").contains("no primary key"), "got: {err}");
|
|
});
|
|
}
|
|
|
|
/// ADR-0046 DB2: the worker's `read_all_relationships` returns full
|
|
/// schema records (name, parent/child tables + columns, actions) — the
|
|
/// data source for the sidebar relationships panel. Exercised through
|
|
/// the real worker thread after an m:n junction creates two of them.
|
|
#[test]
|
|
fn read_all_relationships_returns_the_junction_relationships() {
|
|
let (_project, db, _dir) = open();
|
|
rt().block_on(async {
|
|
serial_pk_table(&db, "Students").await;
|
|
serial_pk_table(&db, "Courses").await;
|
|
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
|
.await
|
|
.expect("create m:n");
|
|
|
|
let rels = db
|
|
.read_all_relationships()
|
|
.await
|
|
.expect("read all relationships");
|
|
assert_eq!(
|
|
rels.len(),
|
|
2,
|
|
"the m:n junction creates two relationships: {rels:?}"
|
|
);
|
|
// Both have the junction (Students_Courses) as their child.
|
|
for r in &rels {
|
|
assert_eq!(
|
|
r.child_table, "Students_Courses",
|
|
"child is the junction: {r:?}"
|
|
);
|
|
}
|
|
// One points back to each parent.
|
|
let parents: std::collections::BTreeSet<&str> =
|
|
rels.iter().map(|r| r.parent_table.as_str()).collect();
|
|
assert!(
|
|
parents.contains("Students") && parents.contains("Courses"),
|
|
"one relationship per parent: {rels:?}"
|
|
);
|
|
});
|
|
}
|