feat: compound-PK foreign-key references — grammar + tests (ADR-0043)

Multi-column FK parsing on both surfaces: DSL from P.(a, b) to
C.(x, y) (parenthesized endpoint; single bare form unchanged) and
SQL FOREIGN KEY (a, b) REFERENCES P(x, y) incl. bare-reference
auto-expand. consume_fk_reference + the table-level/ALTER FK
parsers collect column lists; the from P. completion now offers
( (snapshots updated). 12 integration tests in
tests/it/compound_fk.rs cover parse (both surfaces), engine-enforced
FK, arity + partial-PK + per-pair-type-mismatch refusal,
--create-fk per-column, save->rebuild round-trip, undo (one step),
and single-column preservation. Mark T3 [x]; ADR-0043 implemented.
This commit is contained in:
claude@clouddev1
2026-06-09 18:44:37 +00:00
parent b14f0199e9
commit 4752ba29a0
9 changed files with 737 additions and 81 deletions
+549
View File
@@ -0,0 +1,549 @@
//! 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,
}],
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 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)");
});
}
+1
View File
@@ -9,6 +9,7 @@
mod case_insensitive_names;
mod column_op_guards;
mod compound_fk;
mod engine_vocabulary_audit;
mod friendly_enrichment;
mod help_command;