//! Integration tests for the m:n convenience command (C4 / ADR-0045): //! `create m:n relationship from to [as ]`. //! //! Covers parse, junction generation (columns / compound PK / two //! enforced FKs), the `as ` 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::{parse_command, ColumnSpec, Command, Type, Value}; 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(), None).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(), None).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, 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(), None).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(), None).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(), None).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:?}" ); }); }