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.
This commit is contained in:
@@ -882,6 +882,30 @@ mod tests {
|
|||||||
assert!(f.headline.contains("`99`"));
|
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]
|
#[test]
|
||||||
fn fk_with_delete_op_renders_parent_side_wording() {
|
fn fk_with_delete_op_renders_parent_side_wording() {
|
||||||
let err = sqlite(
|
let err = sqlite(
|
||||||
|
|||||||
+24
-13
@@ -2017,23 +2017,34 @@ async fn enrich_fk_violation(
|
|||||||
};
|
};
|
||||||
facts.table = Some(table.clone());
|
facts.table = Some(table.clone());
|
||||||
for rel in outbound {
|
for rel in outbound {
|
||||||
// The friendly FK-error facts model is single-column
|
// Identify the violated FK by the first local column the
|
||||||
// (ADR-0019); for a compound FK (ADR-0043) we enrich
|
// user supplied a value for (SQLite names no column in the
|
||||||
// from the first column pair — the error still surfaces,
|
// error). The single-column facts slots then carry the
|
||||||
// richer multi-column enrichment is a later refinement.
|
// comma-joined lists so a compound FK (ADR-0043) names
|
||||||
let Some(local_col) = rel.local_columns.first().cloned() else {
|
// *every* child->parent column pair, not just the first.
|
||||||
|
let Some(first_local) = rel.local_columns.first().cloned() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let value =
|
let Some(first_val) =
|
||||||
user_value_for_column_with_schema(database, command, table, &local_col).await;
|
user_value_for_column_with_schema(database, command, table, &first_local).await
|
||||||
if let Some(v) = value {
|
else {
|
||||||
facts.column = Some(local_col);
|
continue;
|
||||||
facts.parent_table = Some(rel.other_table);
|
};
|
||||||
facts.parent_column = rel.other_columns.into_iter().next();
|
// Matched. Gather the remaining pairs' values in order.
|
||||||
facts.value = Some(v.to_string());
|
let mut values = vec![first_val.to_string()];
|
||||||
break;
|
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
|
// For UPDATE, if no outbound match was found we may
|
||||||
// be in the parent-side case (updating a column
|
// be in the parent-side case (updating a column
|
||||||
// children reference). Check inbound as a fallback.
|
// children reference). Check inbound as a fallback.
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
|
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
|
||||||
// Regression: `insert into Orders values (4, 11.99)` —
|
// Regression: `insert into Orders values (4, 11.99)` —
|
||||||
|
|||||||
Reference in New Issue
Block a user