From 5a33f2aeea74b8f2749cb0a28d542a729ecc768f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 11:59:14 +0000 Subject: [PATCH] fix(fk): compound-FK violation message names every column pair ADR-0043 residual: a compound-FK violation's friendly error named only the first child->parent column pair (the ADR-0019 facts model is single-column). enrich_fk_violation now gathers all pairs of the matched relationship and carries them comma-joined in the existing single-column facts slots, so the headline reads e.g. "no parent row in `Region` has `country, code` = `7, 8`." instead of naming just `country`. Single-column behaviour is unchanged (a one-element join is the element itself). No facts-model or catalog change -- the joined strings flow through the existing `{parent_column}` / `{value}` placeholders. Tests: enrichment facts (compound names every pair, single-column regression) + translate rendering (headline names both columns). 2211 pass / 0 fail / 1 ignored. Clippy clean. --- src/friendly/translate.rs | 24 +++++++++++ src/runtime.rs | 37 ++++++++++------ tests/it/friendly_enrichment.rs | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index cbbe194..74cdb00 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -882,6 +882,30 @@ mod tests { assert!(f.headline.contains("`99`")); } + #[test] + fn fk_child_side_renders_every_column_of_a_compound_key() { + // ADR-0043 residual: a compound-FK violation carries the + // comma-joined column + value lists in the single-column facts + // slots, so the headline names every pair, not just the first. + let err = sqlite( + "FOREIGN KEY constraint failed", + SqliteErrorKind::UniqueViolation, + ); + let mut ctx = ctx_with(Operation::Insert); + ctx.parent_table = Some("Region".to_string()); + ctx.parent_column = Some("country, code".to_string()); + ctx.value = Some("7, 8".to_string()); + let f = translate(&err, &ctx); + assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline); + assert!(f.headline.contains("Region")); + assert!( + f.headline.contains("country, code"), + "both parent columns must appear: {}", + f.headline + ); + assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline); + } + #[test] fn fk_with_delete_op_renders_parent_side_wording() { let err = sqlite( diff --git a/src/runtime.rs b/src/runtime.rs index 2ef09b4..0ec720b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2017,22 +2017,33 @@ async fn enrich_fk_violation( }; facts.table = Some(table.clone()); for rel in outbound { - // The friendly FK-error facts model is single-column - // (ADR-0019); for a compound FK (ADR-0043) we enrich - // from the first column pair — the error still surfaces, - // richer multi-column enrichment is a later refinement. - let Some(local_col) = rel.local_columns.first().cloned() else { + // Identify the violated FK by the first local column the + // user supplied a value for (SQLite names no column in the + // error). The single-column facts slots then carry the + // comma-joined lists so a compound FK (ADR-0043) names + // *every* child->parent column pair, not just the first. + let Some(first_local) = rel.local_columns.first().cloned() else { continue; }; - let value = - user_value_for_column_with_schema(database, command, table, &local_col).await; - if let Some(v) = value { - facts.column = Some(local_col); - facts.parent_table = Some(rel.other_table); - facts.parent_column = rel.other_columns.into_iter().next(); - facts.value = Some(v.to_string()); - break; + let Some(first_val) = + user_value_for_column_with_schema(database, command, table, &first_local).await + else { + continue; + }; + // Matched. Gather the remaining pairs' values in order. + let mut values = vec![first_val.to_string()]; + for local_col in rel.local_columns.iter().skip(1) { + if let Some(v) = + user_value_for_column_with_schema(database, command, table, local_col).await + { + values.push(v.to_string()); + } } + facts.column = Some(rel.local_columns.join(", ")); + facts.parent_table = Some(rel.other_table); + facts.parent_column = Some(rel.other_columns.join(", ")); + facts.value = Some(values.join(", ")); + break; } // For UPDATE, if no outbound match was found we may // be in the parent-side case (updating a column diff --git a/tests/it/friendly_enrichment.rs b/tests/it/friendly_enrichment.rs index ff289c9..30c46b0 100644 --- a/tests/it/friendly_enrichment.rs +++ b/tests/it/friendly_enrichment.rs @@ -464,6 +464,81 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { }); } +#[test] +fn enrich_fk_insert_compound_names_every_column_pair() { + // ADR-0043 residual: a compound-FK violation must name *every* + // child->parent column pair, not just the first. The single-column + // facts slots carry the comma-joined lists. + let db = db(); + rt().block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("code".to_string(), Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .unwrap(); + db.create_table( + "City".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("region_code".to_string(), Type::Int), + ], + vec![], + None, + ) + .await + .unwrap(); + db.add_relationship( + None, + "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 + .unwrap(); + + // Insert a City whose (country, region_code) has no parent Region. + let cmd = Command::Insert { + table: "City".to_string(), + columns: Some(vec!["country".to_string(), "region_code".to_string()]), + values: vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + }; + let err = db + .insert( + "City".to_string(), + Some(vec!["country".to_string(), "region_code".to_string()]), + vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + None, + ) + .await + .unwrap_err(); + + let facts = enrich_dsl_failure(&db, &cmd, &err).await; + assert_eq!(facts.table.as_deref(), Some("City")); + assert_eq!(facts.parent_table.as_deref(), Some("Region")); + // Both pairs named, not just the first. + assert_eq!(facts.column.as_deref(), Some("country, region_code")); + assert_eq!(facts.parent_column.as_deref(), Some("country, code")); + assert_eq!(facts.value.as_deref(), Some("7, 8")); + }); +} + #[test] fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { // Regression: `insert into Orders values (4, 11.99)` —