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
+36 -11
View File
@@ -29,15 +29,20 @@ pub struct SqlForeignKey {
/// FK or an unnamed table FK (auto-named at execution per
/// ADR-0013).
pub name: Option<String>,
/// The column in the table being created that holds the FK.
pub child_column: String,
/// The column(s) in the table being created that hold the FK.
/// One element for a single-column FK; ordered list for a
/// compound FK (ADR-0043). Positionally paired with
/// `parent_columns`.
pub child_columns: Vec<String>,
/// The referenced (parent) table — may be the table being created
/// (a self-referencing FK).
pub parent_table: String,
/// The referenced parent column. `None` for the bare
/// `REFERENCES <parent>` form, resolved at execution to the
/// parent's single-column primary key (ADR-0035 §4b, user-confirmed).
pub parent_column: Option<String>,
/// The referenced parent column(s), positionally paired with
/// `child_columns`. `None` for the bare `REFERENCES <parent>`
/// form, resolved at execution to the parent's primary key —
/// the single-column PK, or (ADR-0043 F-D) the full compound PK
/// when the child arity matches.
pub parent_columns: Option<Vec<String>>,
pub on_delete: ReferentialAction,
pub on_update: ReferentialAction,
}
@@ -253,9 +258,14 @@ pub enum Command {
AddRelationship {
name: Option<String>,
parent_table: String,
parent_column: String,
/// Parent (referenced) PK column(s); one element for a
/// single-column FK, ordered list for a compound FK
/// (ADR-0043). Positionally paired with `child_columns`.
parent_columns: Vec<String>,
child_table: String,
child_column: String,
/// Child (referencing) column(s), positionally paired with
/// `parent_columns`; equal, non-zero length.
child_columns: Vec<String>,
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
@@ -1032,11 +1042,26 @@ impl Command {
match self {
Self::AddRelationship {
parent_table,
parent_column,
parent_columns,
child_table,
child_column,
child_columns,
..
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
} => {
// `from P.col to C.col` (single) or `from P.(a, b) to
// C.(x, y)` (compound — ADR-0043), mirroring the DSL.
let fmt = |cols: &[String]| {
if cols.len() == 1 {
cols[0].clone()
} else {
format!("({})", cols.join(", "))
}
};
format!(
"from {parent_table}.{} to {child_table}.{}",
fmt(parent_columns),
fmt(child_columns),
)
}
Self::DropRelationship { selector } => match selector {
RelationshipSelector::Named { name } => name.clone(),
RelationshipSelector::Endpoints {
+22 -7
View File
@@ -785,12 +785,24 @@ fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result<Command,
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
// Collect every matched `parent_column` / `child_column` ident, in
// order — one each for the single-column `from P.col to C.col`
// form, or the full lists for the parenthesized compound form
// `from P.(a, b) to C.(x, y)` (ADR-0043).
let parent_columns = collect_idents(path, "parent_column");
let child_columns = collect_idents(path, "child_column");
if parent_columns.is_empty() || child_columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "a relationship needs both endpoints".to_string())],
});
}
Ok(Command::AddRelationship {
name: ident(path, "relationship_name").map(str::to_string),
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
parent_columns,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
child_columns,
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
create_fk,
@@ -1680,9 +1692,12 @@ where
}
SqlForeignKey {
name,
child_column,
// Single-column for now; the parenthesized multi-column parse
// (`FOREIGN KEY (a, b) REFERENCES P(x, y)`) lands with the
// grammar-node change (ADR-0043).
child_columns: vec![child_column],
parent_table,
parent_column,
parent_columns: parent_column.map(|c| vec![c]),
on_delete,
on_update,
}
@@ -3202,9 +3217,9 @@ mod sql_alter_table_tests {
assert_eq!(name, None);
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.child_column, "pid");
assert_eq!(fk.child_columns, vec!["pid".to_string()]);
assert_eq!(fk.parent_table, "P");
assert_eq!(fk.parent_column.as_deref(), Some("id"));
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
}
other => panic!("expected ForeignKey, got {other:?}"),
}
@@ -3216,7 +3231,7 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_column, None, "bare reference resolves at execution");
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+12 -12
View File
@@ -984,9 +984,9 @@ mod builder_tests {
assert_eq!(fks.len(), 1);
let fk = &fks[0];
assert_eq!(fk.name, None, "inline FK is auto-named at execution");
assert_eq!(fk.child_column, "pid");
assert_eq!(fk.child_columns, vec!["pid".to_string()]);
assert_eq!(fk.parent_table, "parent");
assert_eq!(fk.parent_column.as_deref(), Some("id"));
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
}
@@ -994,9 +994,9 @@ mod builder_tests {
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_column, None, "bare REFERENCES — resolved at execution");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_column, "pid");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
#[test]
@@ -1026,9 +1026,9 @@ mod builder_tests {
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_column, "pid");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].parent_column.as_deref(), Some("id"));
assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()]));
}
#[test]
@@ -1038,7 +1038,7 @@ mod builder_tests {
constraint fk_parent foreign key (pid) references parent(id))",
);
assert_eq!(fks[0].name.as_deref(), Some("fk_parent"));
assert_eq!(fks[0].child_column, "pid");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
#[test]
@@ -1048,8 +1048,8 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_column.as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_column.as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
}
#[test]
@@ -1057,8 +1057,8 @@ mod builder_tests {
let fks =
parse_sct_fks("create table emp (id int primary key, mgr int references emp(id))");
assert_eq!(fks[0].parent_table, "emp", "self-reference");
assert_eq!(fks[0].child_column, "mgr");
assert_eq!(fks[0].parent_column.as_deref(), Some("id"));
assert_eq!(fks[0].child_columns, vec!["mgr".to_string()]);
assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()]));
}
#[test]
@@ -1080,7 +1080,7 @@ mod builder_tests {
} => {
assert_eq!(primary_key, vec!["id".to_string()]);
assert_eq!(foreign_keys.len(), 1);
assert_eq!(foreign_keys[0].child_column, "pid");
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
+2 -2
View File
@@ -937,9 +937,9 @@ mod tests {
Command::AddRelationship {
name: name.map(String::from),
parent_table: parent.0.to_string(),
parent_column: parent.1.to_string(),
parent_columns: vec![parent.1.to_string()],
child_table: child.0.to_string(),
child_column: child.1.to_string(),
child_columns: vec![child.1.to_string()],
on_delete,
on_update,
create_fk,
+4 -4
View File
@@ -3514,9 +3514,9 @@ mod tests {
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: "customer_id".to_string(),
child_columns: vec!["customer_id".to_string()],
on_delete: ReferentialAction::default_action(),
on_update: ReferentialAction::default_action(),
create_fk: false,
@@ -3535,9 +3535,9 @@ mod tests {
Command::AddRelationship {
name: Some("cust_orders".to_string()),
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_column: "customer_id".to_string(),
child_columns: vec!["customer_id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::SetNull,
create_fk: true,