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:
+108
-108
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user