refactor: relationship model to column lists for compound FK (ADR-0043)

Move the FK column fields String->Vec<String> through all six
layers (AddRelationship/SqlForeignKey AST, RelationshipSchema,
metadata, project.yaml, ReadForeignKey, RelationshipEnd). Metadata
stores comma-joined lists in the existing TEXT cells; project.yaml
endpoints now columns: [a, b] (house style). Executor logic is
multi-column ready: resolve_fk_parent_columns (full-PK F-A +
auto-expand F-D), per-pair type-compat, schema_to_ddl multi-column
emission, pragma FK read grouped by id, auto-name + --create-fk
per-column, multi-column teaching echo. Single-column behaviour
preserved (one-element vecs); all 2181 tests green. The grammar to
parse multi-column input lands next.
This commit is contained in:
claude@clouddev1
2026-06-09 18:25:40 +00:00
parent b688592b4c
commit b14f0199e9
23 changed files with 721 additions and 507 deletions
+108 -108
View File
@@ -1591,16 +1591,16 @@ struct EchoLookups {
/// teaching playground).
drop_relationship: Option<(String, String)>,
/// For `Command::AddRelationship { create_fk: true, .. }` — the
/// type of the child column the `--create-fk` flag will create, *if*
/// the column did not already exist (`Some(ty)` → newly created →
/// multi-line echo; `None` → already existed → single-line echo).
/// The type is derived from the parent's PK column type via
/// `Type::fk_target_type` (ADR-0011: `serial → int`, `shortid →
/// text`, others identity). The outer `Option` is `None` for
/// not-applicable commands (not a `--create-fk` add, or simple mode,
/// or a pre-execution lookup failed); the inner option encodes the
/// existed-vs-created distinction.
add_rel_create_fk_new_column_type: Option<Option<crate::dsl::types::Type>>,
/// child columns the `--create-fk` flag will newly create, each with
/// its type (ADR-0043: one per child column that did **not** already
/// exist, typed to the matching parent PK column's `fk_target_type` —
/// ADR-0011: `serial → int`, `shortid → text`, others identity). An
/// **empty** vec means every child column already existed →
/// single-line echo; a non-empty vec → multi-line (one `ADD COLUMN`
/// per element). The outer `Option` is `None` for not-applicable
/// commands (not a `--create-fk` add, simple mode, or a
/// pre-execution lookup failed).
add_rel_create_fk_new_columns: Option<Vec<(String, crate::dsl::types::Type)>>,
}
/// Resolve drop-target names and `--create-fk` pre-state **before**
@@ -1638,9 +1638,13 @@ async fn collect_echo_lookups(
} => {
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
// The Endpoints drop selector is single-column
// (ADR-0043 keeps DROP by-endpoints single-column;
// compound relationships drop by name) — match a
// one-column relationship by its sole columns.
r.other_table == *parent_table
&& r.other_column == *parent_column
&& r.local_column == *child_column
&& r.other_columns.as_slice() == std::slice::from_ref(parent_column)
&& r.local_columns.as_slice() == std::slice::from_ref(child_column)
})
{
out.drop_relationship = Some((rel.name.clone(), child_table.clone()));
@@ -1668,41 +1672,37 @@ async fn collect_echo_lookups(
Command::AddRelationship {
create_fk: true,
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
..
} => {
// Two pre-state facts feed the multi-line `--create-fk` echo
// (ADR-0038 §7 Bucket B, category 2): whether the child
// column already exists (determines single- vs multi-line)
// and the parent PK column's user type (determines the
// newly-created child column's type via
// `Type::fk_target_type`). Both are looked up post-exec from
// the description for `add relationship` (no `--create-fk`),
// but the `--create-fk` multi-line case needs them *before*
// execution to know whether to emit an `ADD COLUMN` line.
let parent_pk_type = database
.describe_table(parent_table.clone(), None)
.await
.ok()
.and_then(|d| {
d.columns
// Pre-state for the multi-line `--create-fk` echo (ADR-0038
// §7 Bucket B, category 2 / ADR-0043): the subset of child
// columns that do NOT already exist, each typed to the
// matching parent PK column's `fk_target_type`. Needed
// *before* execution to know which `ADD COLUMN` lines to
// emit. The parent columns here are the explicit DSL list,
// paired positionally with the child list.
let parent_desc = database.describe_table(parent_table.clone(), None).await;
let child_desc = database.describe_table(child_table.clone(), None).await;
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
let already = child_desc.columns.iter().any(|c| c.name == *child_col);
if already {
continue;
}
if let Some(parent_ty) = parent_desc
.columns
.iter()
.find(|c| c.name == *parent_column)
.find(|c| c.name == *parent_col)
.and_then(|c| c.user_type)
});
let child_column_existed = database
.describe_table(child_table.clone(), None)
.await
.ok()
.map(|d| d.columns.iter().any(|c| c.name == *child_column));
if let (Some(parent_ty), Some(existed)) = (parent_pk_type, child_column_existed) {
out.add_rel_create_fk_new_column_type = Some(if existed {
None
} else {
Some(parent_ty.fk_target_type())
});
{
new_columns.push((child_col.clone(), parent_ty.fk_target_type()));
}
}
out.add_rel_create_fk_new_columns = Some(new_columns);
}
}
_ => {}
@@ -1755,9 +1755,9 @@ fn build_schema_echo(
Command::AddRelationship {
name,
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
on_delete,
on_update,
create_fk,
@@ -1766,57 +1766,55 @@ fn build_schema_echo(
// relationships (target_table for AddRelationship is the
// parent — `database.add_relationship` returns the parent's
// description per ADR-0013), falling back to the command's
// explicit `name` when the description is unavailable.
// explicit `name` when the description is unavailable. Match
// on the column lists (ADR-0043), the child as `other` and
// the parent as `local` from the parent's perspective.
let resolved = description
.and_then(|d| {
d.inbound_relationships.iter().find(|r| {
r.other_table == *child_table
&& r.other_column == *child_column
&& r.local_column == *parent_column
&& r.other_columns == *child_columns
&& r.local_columns == *parent_columns
})
})
.map(|r| r.name.clone())
.or_else(|| name.clone())?;
if *create_fk {
// Multi-line iff the child column was newly created
// (`--create-fk`'s pre-state, captured pre-execution
// into `add_rel_create_fk_new_column_type`). When the
// column already existed the echo collapses to the
// single-line FK form — the SQL `ADD COLUMN` would be
// a no-op-with-error otherwise, and the catalogue is
// explicit: "one line if the column already existed".
Some(lookups.add_rel_create_fk_new_column_type?.map_or_else(
|| {
vec![crate::echo::render_add_relationship(
&resolved,
parent_table,
parent_column,
child_table,
child_column,
*on_delete,
*on_update,
)]
},
|new_ty| {
crate::echo::render_add_relationship_create_fk(
&resolved,
parent_table,
parent_column,
child_table,
child_column,
*on_delete,
*on_update,
new_ty,
)
},
))
// The pre-execution lookup captured which child columns
// `--create-fk` newly creates (ADR-0043). An empty list
// → every column existed → single-line FK echo; a
// non-empty list → one `ADD COLUMN` per new column then
// the FK line.
let new_columns = lookups.add_rel_create_fk_new_columns.as_ref()?;
if new_columns.is_empty() {
Some(vec![crate::echo::render_add_relationship(
&resolved,
parent_table,
parent_columns,
child_table,
child_columns,
*on_delete,
*on_update,
)])
} else {
Some(crate::echo::render_add_relationship_create_fk(
&resolved,
parent_table,
parent_columns,
child_table,
child_columns,
*on_delete,
*on_update,
new_columns,
))
}
} else {
Some(vec![crate::echo::render_add_relationship(
&resolved,
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
*on_delete,
*on_update,
)])
@@ -2013,17 +2011,19 @@ async fn enrich_fk_violation(
};
facts.table = Some(table.clone());
for rel in outbound {
let value = user_value_for_column_with_schema(
database,
command,
table,
&rel.local_column,
)
.await;
// 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 {
continue;
};
let value =
user_value_for_column_with_schema(database, command, table, &local_col).await;
if let Some(v) = value {
facts.column = Some(rel.local_column);
facts.column = Some(local_col);
facts.parent_table = Some(rel.other_table);
facts.parent_column = Some(rel.other_column);
facts.parent_column = rel.other_columns.into_iter().next();
facts.value = Some(v.to_string());
break;
}
@@ -2615,9 +2615,9 @@ async fn execute_command_typed(
Command::AddRelationship {
name,
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
on_delete,
on_update,
create_fk,
@@ -2625,9 +2625,9 @@ async fn execute_command_typed(
.add_relationship(
name,
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
on_delete,
on_update,
create_fk,
@@ -3193,9 +3193,9 @@ mod tests {
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
"CustId".to_string(),
vec!["CustId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
@@ -3206,9 +3206,9 @@ mod tests {
let add_rel_cmd = Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
child_columns: vec!["CustId".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
@@ -3366,9 +3366,9 @@ mod tests {
let add_fk_cmd = Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
child_columns: vec!["CustId".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: true,
@@ -3378,17 +3378,17 @@ mod tests {
let pre_lookups =
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
assert_eq!(
pre_lookups.add_rel_create_fk_new_column_type,
Some(Some(Type::Int)),
pre_lookups.add_rel_create_fk_new_columns,
Some(vec![("CustId".to_string(), Type::Int)]),
"pre-exec captures `serial → int` for the newly-created child column",
);
let parent_desc = db
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
"CustId".to_string(),
vec!["CustId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
@@ -3435,17 +3435,17 @@ mod tests {
let pre_lookups =
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
assert_eq!(
pre_lookups.add_rel_create_fk_new_column_type,
Some(None),
pre_lookups.add_rel_create_fk_new_columns,
Some(vec![]),
"pre-exec records the child column already existed → single-line echo",
);
let parent_desc = db
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
"CustId".to_string(),
vec!["CustId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,