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:
+114
-52
@@ -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 {
|
||||
|
||||
@@ -182,7 +182,15 @@ const FK_PARENT_COLUMN: Node = Node::Ident {
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
static FK_PARENT_COL_NODES: &[Node] = &[Node::Punct('('), FK_PARENT_COLUMN, Node::Punct(')')];
|
||||
// `( a [, b]* )` — a compound FK references multiple parent columns
|
||||
// (ADR-0043). The `Repeated` separator handles the commas; a
|
||||
// single-column FK is the one-element case.
|
||||
const FK_PARENT_COL_LIST: Node = Node::Repeated {
|
||||
inner: &FK_PARENT_COLUMN,
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
static FK_PARENT_COL_NODES: &[Node] = &[Node::Punct('('), FK_PARENT_COL_LIST, Node::Punct(')')];
|
||||
const FK_PARENT_COL_OPT: Node = Node::Optional(&Node::Seq(FK_PARENT_COL_NODES));
|
||||
|
||||
// `REFERENCES <parent> [ ( <col> ) ] [on delete/update …]` — the inline
|
||||
@@ -333,6 +341,13 @@ const FK_CHILD_COLUMN: Node = Node::Ident {
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
// `( a [, b]* )` — a compound FK lists multiple child columns
|
||||
// (ADR-0043); single-column is the one-element case.
|
||||
const FK_CHILD_COL_LIST: Node = Node::Repeated {
|
||||
inner: &FK_CHILD_COLUMN,
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
const FK_NAME: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "fk_name",
|
||||
@@ -354,7 +369,7 @@ static FOREIGN_KEY_BODY_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("foreign")),
|
||||
Node::Word(Word::keyword("key")),
|
||||
Node::Punct('('),
|
||||
FK_CHILD_COLUMN,
|
||||
FK_CHILD_COL_LIST,
|
||||
Node::Punct(')'),
|
||||
Node::Word(Word::keyword("references")),
|
||||
FK_PARENT_TABLE,
|
||||
@@ -374,7 +389,7 @@ static TABLE_FK_NAMED_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("foreign")),
|
||||
Node::Word(Word::keyword("key")),
|
||||
Node::Punct('('),
|
||||
FK_CHILD_COLUMN,
|
||||
FK_CHILD_COL_LIST,
|
||||
Node::Punct(')'),
|
||||
Node::Word(Word::keyword("references")),
|
||||
FK_PARENT_TABLE,
|
||||
|
||||
Reference in New Issue
Block a user