feat: compound-PK foreign-key references — grammar + tests (ADR-0043)

Multi-column FK parsing on both surfaces: DSL from P.(a, b) to
C.(x, y) (parenthesized endpoint; single bare form unchanged) and
SQL FOREIGN KEY (a, b) REFERENCES P(x, y) incl. bare-reference
auto-expand. consume_fk_reference + the table-level/ALTER FK
parsers collect column lists; the from P. completion now offers
( (snapshots updated). 12 integration tests in
tests/it/compound_fk.rs cover parse (both surfaces), engine-enforced
FK, arity + partial-PK + per-pair-type-mismatch refusal,
--create-fk per-column, save->rebuild round-trip, undo (one step),
and single-column preservation. Mark T3 [x]; ADR-0043 implemented.
This commit is contained in:
claude@clouddev1
2026-06-09 18:44:37 +00:00
parent b14f0199e9
commit 4752ba29a0
9 changed files with 737 additions and 81 deletions
+114 -52
View File
@@ -385,6 +385,36 @@ const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot narrows to that table's columns (handoff-13
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
// A single FK-endpoint column ident (narrows to the endpoint
// table's columns via the table ident's `writes_table: true`).
const AR_PARENT_COL: Node = Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// Compound endpoint: `( a, b, … )` — a comma-separated column list
// in parens (ADR-0043). Same role as the single form, so the
// builder collects either shape uniformly.
const AR_PARENT_COL_LIST: Node = Node::Repeated {
inner: &AR_PARENT_COL,
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_PARENT_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_PARENT_COL_LIST, Node::Punct(')')];
const AR_PARENT_COLS_PAREN: Node = Node::Seq(AR_PARENT_COLS_PAREN_NODES);
// `from P.(a, b)` (compound) or `from P.col` (single) — Choice on
// the first post-`.` token (`(` vs an ident), so order is safe.
const AR_PARENT_COLS_CHOICES: &[Node] = &[AR_PARENT_COLS_PAREN, AR_PARENT_COL];
const AR_PARENT_COLS: Node = Node::Choice(AR_PARENT_COLS_CHOICES);
const AR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
@@ -394,25 +424,37 @@ const AR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
AR_PARENT_COLS,
];
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
const AR_CHILD_COL: Node = Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
};
const AR_CHILD_COL_LIST: Node = Node::Repeated {
inner: &AR_CHILD_COL,
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
const AR_CHILD_NODES: &[Node] = &[
Node::Ident {
@@ -423,23 +465,12 @@ const AR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
AR_CHILD_COLS,
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
@@ -1523,8 +1554,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// consumed in `consume_fk_reference`, so they don't perturb
// the element-boundary `depth` tracker.
MatchedKind::Word("references") => {
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, child_column));
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column]));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
@@ -1532,11 +1565,21 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
items.next(); // `key`
}
// `( <child column> )`
// `( <child column> [, <child column>]* )` — a compound
// FK lists multiple child columns (ADR-0043).
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next();
}
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
let mut child_columns = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Punct(')') => break,
MatchedKind::Punct(',') => {
items.next();
}
_ => child_columns.push(items.next().expect("peeked").text.clone()),
}
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
@@ -1544,7 +1587,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
items.next();
}
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_column);
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_columns);
foreign_keys.push(fk);
}
// Track paren depth for element-boundary detection. The
@@ -1660,23 +1703,35 @@ where
fn consume_fk_reference<'a, I>(
items: &mut std::iter::Peekable<I>,
name: Option<String>,
child_column: String,
child_columns: Vec<String>,
) -> SqlForeignKey
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
// Optional `( <parent column> )`.
let mut parent_column = None;
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next(); // `(`
if let Some(it) = items.next() {
parent_column = Some(it.text.clone());
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next(); // `)`
}
}
// Optional `( <parent column> [, <parent column>]* )` — a
// compound FK references multiple parent columns (ADR-0043).
// `None` for the bare `REFERENCES <parent>` form.
let parent_columns: Option<Vec<String>> =
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next(); // `(`
let mut cols = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Punct(')') => break,
MatchedKind::Punct(',') => {
items.next();
}
_ => cols.push(items.next().expect("peeked").text.clone()),
}
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next(); // `)`
}
Some(cols)
} else {
None
};
// `on <delete|update> <action>` clauses, in either order, 0..2.
let mut on_delete = ReferentialAction::default_action();
let mut on_update = ReferentialAction::default_action();
@@ -1692,12 +1747,9 @@ where
}
SqlForeignKey {
name,
// 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],
child_columns,
parent_table,
parent_columns: parent_column.map(|c| vec![c]),
parent_columns,
on_delete,
on_update,
}
@@ -2385,14 +2437,24 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next();
}
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
// `( <child column> [, <child column>]* )` — compound FK (ADR-0043).
let mut child_columns = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Punct(')') => break,
MatchedKind::Punct(',') => {
items.next();
}
_ => child_columns.push(items.next().expect("peeked").text.clone()),
}
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
items.next();
}
consume_fk_reference(&mut items, None, child_column)
consume_fk_reference(&mut items, None, child_columns)
}
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {