6985a43f31
ADR-0043 D4 residual: an inline column-level FK (`<col> REFERENCES P(a,b)`)
is single-column by construction, so referencing a parent's compound PK
gave the generic arity error ("1 foreign-key column(s) on the child side,
but `P`'s key has 2..."). It now points the user at the table-level form:
"an inline column reference can only name one column ... Use the table-level
form instead: FOREIGN KEY (<columns>) REFERENCES P (a, b)".
- Adds `inline: bool` to SqlForeignKey, set by the grammar's single shared
builder consume_fk_reference (true for the inline path, false for the
table-level and ALTER paths).
- resolve_fk_parent_columns takes `inline` and tailors the arity-mismatch
message when an inline FK meets a compound key.
Tests: parse-layer (inline=true / table-level=false) + end-to-end worker
refusal wording. 2209 pass / 0 fail / 1 ignored. Clippy clean.
652 lines
22 KiB
Rust
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(), None).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(), None).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(), None).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(), None)
|
|
.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(), None)
|
|
.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()],
|
|
);
|
|
});
|
|
}
|