Files
rdbms-playground/tests/it/compound_fk.rs
T
claude@clouddev1 e8fa859ab9 refactor(db): unwind vestigial worker source plumbing (ADR-0052 follow-up)
ADR-0052 moved success journaling out of the worker to the dispatch
layer, leaving the `source` that handlers threaded purely for the
worker's old history.log write dead. Remove it:

- drop `_source` from finalize_persistence and do_rebuild_from_text
- inline + delete the three read-only *_request wrappers
- drop the now-unused `source` param from the ~30 forwarding worker
  handlers (leaf + composite), compiler-guided
- remove the `source` field from the DescribeTable/QueryData/RunSelect
  requests and their DatabaseHandle methods (call sites updated)

The only worker `source` left is the snapshot/undo label
(snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical,
no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean.
2026-06-14 13:47:49 +00:00

652 lines
22 KiB
Rust

//! Integration tests for compound-primary-key foreign-key
//! references (T3 / ADR-0043) — the DSL `add 1:n relationship`
//! surface end to end.
//!
//! Covers: parse of the parenthesized multi-column endpoint;
//! worker round-trip (declare a 2-column FK, FK is enforced,
//! per-pair type-mismatch refused, arity mismatch refused);
//! persistence round-trip (`columns: [a, b]`); display; and undo.
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
// ---- parse layer ------------------------------------------------
#[test]
fn parenthesized_compound_endpoint_parses_to_column_lists() {
let cmd = parse_command(
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
)
.expect("parses");
match cmd {
Command::AddRelationship {
parent_table,
parent_columns,
child_table,
child_columns,
..
} => {
assert_eq!(parent_table, "Parent");
assert_eq!(parent_columns, vec!["a".to_string(), "b".to_string()]);
assert_eq!(child_table, "Child");
assert_eq!(child_columns, vec!["x".to_string(), "y".to_string()]);
}
other => panic!("expected AddRelationship, got {other:?}"),
}
}
#[test]
fn single_column_endpoint_still_parses_unparenthesized() {
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
.expect("parses");
match cmd {
Command::AddRelationship {
parent_columns,
child_columns,
..
} => {
assert_eq!(parent_columns, vec!["id".to_string()]);
assert_eq!(child_columns, vec!["pid".to_string()]);
}
other => panic!("expected AddRelationship, got {other:?}"),
}
}
// ---- SQL surface (advanced mode) --------------------------------
#[test]
fn sql_table_level_compound_fk_parses_to_lists() {
let cmd = parse_command(
"create table City (country int, region_code int, \
foreign key (country, region_code) references Region(country, code))",
)
.expect("parses");
match cmd {
Command::SqlCreateTable { foreign_keys, .. } => {
assert_eq!(foreign_keys.len(), 1);
assert_eq!(
foreign_keys[0].child_columns,
vec!["country".to_string(), "region_code".to_string()],
);
assert_eq!(
foreign_keys[0].parent_columns,
Some(vec!["country".to_string(), "code".to_string()]),
);
}
other => panic!("expected SqlCreateTable, got {other:?}"),
}
}
#[test]
fn sql_bare_compound_reference_parses_with_no_parent_columns() {
// `FOREIGN KEY (a, b) REFERENCES P` (no parent cols) — auto-expanded
// to the parent's full PK at execution (F-D).
let cmd = parse_command(
"create table City (country int, region_code int, \
foreign key (country, region_code) references Region)",
)
.expect("parses");
match cmd {
Command::SqlCreateTable { foreign_keys, .. } => {
assert_eq!(
foreign_keys[0].child_columns,
vec!["country".to_string(), "region_code".to_string()],
);
assert_eq!(foreign_keys[0].parent_columns, None);
}
other => panic!("expected SqlCreateTable, got {other:?}"),
}
}
#[test]
fn sql_create_table_compound_fk_executes_and_enforces() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
// Parent with a compound PK.
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
// Child via the SQL path with a multi-column FK referencing the
// full compound PK (resolve_create_table_fks).
db.sql_create_table(
"City".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("region_code", Type::Int),
],
vec![],
vec![],
vec![],
vec![SqlForeignKey {
name: None,
child_columns: vec!["country".to_string(), "region_code".to_string()],
parent_table: "Region".to_string(),
parent_columns: Some(vec!["country".to_string(), "code".to_string()]),
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
inline: false,
}],
false,
None,
)
.await
.expect("create City with compound FK");
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
None,
)
.await
.expect("insert parent");
let bad = db
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
None,
)
.await;
assert!(bad.is_err(), "compound FK violation refused by the engine");
});
}
// ---- worker round-trip ------------------------------------------
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open_project_db() -> (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 persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
(project, db, dir)
}
/// `Region(country int, code int)` compound PK + `City(country int,
/// region_code int, name text)` — the child FK columns matching the
/// parent PK by type (int → int).
async fn seed_compound(db: &Database) {
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
db.create_table(
"City".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("region_code", Type::Int),
ColumnSpec::new("name", Type::Text),
],
vec!["country".to_string()],
None,
)
.await
.expect("create City");
}
#[test]
fn compound_fk_declares_enforces_and_round_trips() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
// Declare the compound FK: City.(country, region_code) →
// Region.(country, code).
db.add_relationship(
Some("city_region".to_string()),
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.expect("add compound relationship");
// The FK is enforced: a parent row exists for (1, 10); a
// child referencing it inserts, one referencing (9, 9) is
// refused by the engine.
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
None,
)
.await
.expect("insert parent row");
db.insert(
"City".to_string(),
Some(vec![
"country".to_string(),
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
None,
)
.await
.expect("child row referencing an existing parent key inserts");
let violation = db
.insert(
"City".to_string(),
Some(vec![
"country".to_string(),
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
None,
)
.await;
assert!(
violation.is_err(),
"a child row with no matching compound parent key must be refused",
);
// describe shows the compound endpoints symmetrically.
let city = db.describe_table("City".to_string()).await.unwrap();
let outbound = &city.outbound_relationships[0];
assert_eq!(
outbound.local_columns,
vec!["country".to_string(), "region_code".to_string()],
);
assert_eq!(
outbound.other_columns,
vec!["country".to_string(), "code".to_string()],
);
});
}
#[test]
fn compound_fk_create_fk_makes_both_child_columns() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
// Region(country, code) compound PK; City has neither FK column.
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
db.create_table(
"City".to_string(),
vec![ColumnSpec::new("name", Type::Text)],
vec![],
None,
)
.await
.expect("create City");
// --create-fk creates both missing child columns, typed to the
// matching parent PK columns' fk_target_type (int → int).
db.add_relationship(
None,
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["c_country".to_string(), "c_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
true,
None,
)
.await
.expect("add compound relationship with --create-fk");
let city = db.describe_table("City".to_string()).await.unwrap();
for col in ["c_country", "c_code"] {
assert!(
city.columns.iter().any(|c| c.name == col),
"--create-fk created `{col}`: {:?}",
city.columns.iter().map(|c| &c.name).collect::<Vec<_>>(),
);
}
});
}
#[test]
fn compound_fk_arity_mismatch_is_refused() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
// Two parent columns, one child column → arity mismatch.
let err = db
.add_relationship(
None,
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await;
assert!(err.is_err(), "mismatched child/parent arity must be refused");
});
}
#[test]
fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
// ADR-0043 D4 residual: an *inline* single-column FK cannot express a
// multi-column reference, so referencing a parent's compound PK must
// refuse with a pointer to the table-level `FOREIGN KEY (...)` form —
// not the generic arity message. The grammar marks the FK `inline`.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
// Parse the inline form so the `inline` flag is set by the grammar.
let cmd = parse_command(
"create table City (country int references Region(country, code))",
)
.expect("parses");
let Command::SqlCreateTable {
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
} = cmd
else {
panic!("expected SqlCreateTable");
};
let err = db
.sql_create_table(
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
None,
)
.await
.expect_err("inline FK referencing a compound PK must be refused");
let msg = format!("{err}");
assert!(
msg.contains("FOREIGN KEY"),
"expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}"
);
});
}
#[test]
fn compound_fk_type_mismatch_per_pair_is_refused() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
// `bad` is `text` — incompatible with the `int` PK column it
// would pair with (per-pair type-compat, ADR-0011).
db.create_table(
"City".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("bad", Type::Text),
],
vec![],
None,
)
.await
.expect("create City");
let err = db
.add_relationship(
None,
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "bad".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await;
assert!(err.is_err(), "a type-incompatible column pair must be refused");
});
}
#[test]
fn compound_fk_survives_rebuild_from_text() {
// The riskiest round-trip: comma-encoded metadata + yaml
// `columns: [a, b]` → rebuild reconstructs the compound FK DDL.
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("open project");
let path = project.path().to_path_buf();
let rt = rt();
{
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.expect("open db");
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
Some("city_region".to_string()),
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
Some("add 1:n relationship".to_string()),
)
.await
.expect("add compound relationship");
});
}
// Reopen and rebuild the database purely from the persisted
// project.yaml + data/.
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.expect("reopen db");
rt.block_on(async {
db.rebuild_from_text(path.clone(), None)
.await
.expect("rebuild from text");
// The compound FK is reconstructed and still enforced.
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
None,
)
.await
.expect("insert parent after rebuild");
let bad = db
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
None,
)
.await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
// Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!(
city.outbound_relationships[0].other_columns,
vec!["country".to_string(), "code".to_string()],
);
});
}
#[test]
fn compound_fk_undo_removes_the_relationship() {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("open project");
let db = Database::open_with_persistence_and_undo(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
true,
)
.expect("open db with undo");
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
Some("city_region".to_string()),
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
// A user-command source records one undo snapshot.
Some("add 1:n relationship".to_string()),
)
.await
.expect("add compound relationship");
assert_eq!(
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
.len(),
1,
);
// One undo step removes the whole relationship (ADR-0013/0006).
db.undo().await.unwrap().expect("undo applied");
assert!(
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
.is_empty(),
"undo removed the compound relationship in one step",
);
});
}
#[test]
fn compound_fk_partial_pk_reference_is_refused() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
// Referencing only one column of Region's 2-column PK (F-A:
// must reference the full PK).
let err = db
.add_relationship(
None,
"Region".to_string(),
vec!["country".to_string()],
"City".to_string(),
vec!["country".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await;
assert!(err.is_err(), "a partial-PK reference must be refused (F-A)");
});
}
#[test]
fn show_relationship_carries_compound_columns_into_diagram_data() {
// ADR-0044 §2.4: the `show relationship` diagram payload carries
// both paired columns on each side so the renderer can route the
// bus + pairing line.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
Some("city_region".to_string()),
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.expect("add compound relationship");
let data = db
.show_relationship("city_region".to_string())
.await
.expect("ok")
.expect("found");
// child = FK holder (City), parent = referenced (Region).
assert_eq!(data.child.name, "City");
assert_eq!(data.parent.name, "Region");
assert_eq!(
data.rel.child_columns,
vec!["country".to_string(), "region_code".to_string()],
);
assert_eq!(
data.rel.parent_columns,
vec!["country".to_string(), "code".to_string()],
);
});
}