feat: ADR-0035 4b — foreign keys in CREATE TABLE

Add foreign keys to advanced-mode SQL CREATE TABLE — the SQL spelling of
an ADR-0013 named relationship, created in the same transaction as the
table (one undo step).

- Grammar: inline `<col> … REFERENCES <parent>[(<col>)] [ON DELETE/UPDATE
  …]` (a new column constraint) and table-level `[CONSTRAINT <name>]
  FOREIGN KEY (<col>) REFERENCES …` (two new element branches — both
  start on a concrete keyword, never a leading Optional, which would
  abort the element Choice). Referential clauses reuse
  shared::REFERENTIAL_CLAUSES.
- Builder: greedy FK-clause consumption (parens consumed internally so
  they don't perturb the 4a.3 element-boundary depth tracker); inline FK
  auto-named, table FK takes an optional CONSTRAINT name.
- Worker: do_create_table resolves + validates each FK before building
  the DDL (self-ref validates against the in-statement columns/PK; bare
  REFERENCES resolves to the parent's single-column PK, composite ->
  error; PK-target + Type::fk_target_type compatibility), emits the
  FOREIGN KEY clause identically to schema_to_ddl, and writes the
  relationship metadata in the create transaction.
- Reuse: name/uniqueness/metadata-insert/type-compat factored into shared
  helpers; do_add_relationship refactored to use them.
- FKs round-trip via the existing relationship plumbing (no new
  persistence structures); describe surfaces the relationship.

Self-references and bare `REFERENCES <parent>` supported (user-confirmed).
Self-ref pre-submit indicator wrinkle deferred to 4i (tracked in ADR §13,
a code comment, and the plan).

DA/runda round added cross-cutting probes (FK survives the add-column
rebuild + a later rebuild_from_text; referential actions survive rebuild;
drop-child clears the relationship; drop-parent refused; bare self-ref
resolves to own PK) — all green, no fixes needed.

27 new tests (grammar/builder + Tier-3). Docs: ADR-0035 Status/§13,
README, requirements.md Q1.

Tests: 1795 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-25 15:35:48 +00:00
parent 60111f69d5
commit 76d60591bf
11 changed files with 1588 additions and 81 deletions
+123 -4
View File
@@ -14,7 +14,7 @@
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector,
RelationshipSelector,
RelationshipSelector, SqlForeignKey,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
@@ -1321,6 +1321,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut primary_key: Vec<String> = Vec::new();
let mut unique_constraints: Vec<Vec<String>> = Vec::new();
let mut check_constraints: Vec<String> = Vec::new();
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
let mut pending_name: Option<String> = None;
// Distinguish a table-level `CHECK (…)` from a column-level one
// (ADR-0035 §4a.3): both are spelled `check (`, and `Word` matches
@@ -1334,6 +1335,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// comma at the column-list interior, `depth == 1`.
let mut column_open = false;
let mut depth = 0usize;
// A `CONSTRAINT <name>` prefix stashes the name until the following
// table-level `FOREIGN KEY` consumes it (ADR-0035 §5, 4b).
let mut pending_fk_name: Option<String> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
@@ -1463,10 +1467,48 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
}
}
}
// `constraint <name>` — stash the name for the table-level
// `foreign key` that follows (ADR-0035 §5, 4b).
MatchedKind::Word("constraint") => {
if let Some(it) = items.next() {
pending_fk_name = Some(it.text.clone());
}
}
// Inline `references <parent> [(<col>)] [on …]` — a
// column-level FK on the current column (ADR-0035 §5, 4b).
// Auto-named at execution; the FK clause's own parens are
// consumed in `consume_fk_reference`, so they don't perturb
// the element-boundary `depth` tracker.
MatchedKind::Word("references") => {
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));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
items.next(); // `key`
}
// `( <child column> )`
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());
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
// `references <parent> …`
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);
foreign_keys.push(fk);
}
// Track paren depth for element-boundary detection. The
// column/`check`/`default`/table-`unique` arms consume their
// own parens, so the only parens reaching here are the outer
// column list, type length-args, and table-`PRIMARY KEY (…)`.
// column/`check`/`default`/table-`unique`/FK arms consume
// their own parens, so the only parens reaching here are the
// outer column list, type length-args, and
// table-`PRIMARY KEY (…)`.
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
// A comma at the column-list interior ends the current
@@ -1494,6 +1536,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
})
}
@@ -1564,6 +1607,82 @@ where
Some((inner_start, inner_end))
}
/// Consume the tail of a foreign-key reference from the matched-item
/// stream (ADR-0035 §5, sub-phase 4b): the parent table ident, an
/// optional `( <parent col> )`, and any `on <delete|update> <action>`
/// clauses. The next item must be the parent-table ident (the
/// `references` keyword was already consumed by the caller). The
/// reference's own parens are consumed here, so they never reach the
/// builder's element-boundary `depth` tracker.
fn consume_fk_reference<'a, I>(
items: &mut std::iter::Peekable<I>,
name: Option<String>,
child_column: 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(); // `)`
}
}
// `on <delete|update> <action>` clauses, in either order, 0..2.
let mut on_delete = ReferentialAction::default_action();
let mut on_update = ReferentialAction::default_action();
while matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("on"))) {
items.next(); // `on`
let target = items.next().map(|it| it.kind.clone());
let action = consume_referential_action(items);
match target {
Some(MatchedKind::Word("delete")) => on_delete = action,
Some(MatchedKind::Word("update")) => on_update = action,
_ => {}
}
}
SqlForeignKey {
name,
child_column,
parent_table,
parent_column,
on_delete,
on_update,
}
}
/// Read a single referential action (`cascade` / `restrict` /
/// `set null` / `no action`) from the matched-item stream — the
/// two-word forms (`set null`, `no action`) consume their second word.
fn consume_referential_action<'a, I>(items: &mut std::iter::Peekable<I>) -> ReferentialAction
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
match items.next().map(|it| it.kind.clone()) {
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
items.next();
}
ReferentialAction::NoAction
}
_ => ReferentialAction::default_action(),
}
}
pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
+276 -13
View File
@@ -24,7 +24,7 @@
//! `sql_insert::SQL_INSERT_SHAPE`, which starts at `INTO`).
use crate::dsl::grammar::sql_select::reject_internal_table;
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word, sql_expr};
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word, shared, sql_expr};
use crate::dsl::types::Type;
static COMMA: Node = Node::Punct(',');
@@ -139,6 +139,63 @@ static CHECK_NODES: &[Node] = &[
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
Node::Punct(')'),
];
// --- Foreign keys (ADR-0035 §5, sub-phase 4b) ---------------------
//
// Inline `REFERENCES <parent>[(<col>)] [ON DELETE/UPDATE …]` and
// table-level `[CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES …`.
// Each is the SQL spelling of an ADR-0013 named relationship. The
// referenced parent table/column use the `Tables`/`Columns` sources
// (completion + existence hints), matching the `add relationship`
// endpoints; the `( <col> )` is optional (the bare `REFERENCES
// <parent>` form resolves to the parent's PK at execution).
// NOTE (4i): `IdentSource::Tables` existence-checks the parent — good
// for the common case (a typo'd parent shows a pre-submit hint), but a
// self-referencing FK (`references <self>` while creating `<self>`)
// false-flags the not-yet-created table as unknown. Parse + execution
// are correct (the self-ref is validated against the in-statement
// columns); only the live typing indicator is briefly wrong. ADR-0035
// §13 4i: teach the schema-existence diagnostic about the CREATE TABLE
// target so the self-ref indicator stops lying.
const FK_PARENT_TABLE: Node = Node::Ident {
source: IdentSource::Tables,
role: "fk_parent_table",
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 FK_PARENT_COLUMN: Node = Node::Ident {
source: IdentSource::Columns,
role: "fk_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,
};
static FK_PARENT_COL_NODES: &[Node] = &[Node::Punct('('), FK_PARENT_COLUMN, Node::Punct(')')];
const FK_PARENT_COL_OPT: Node = Node::Optional(&Node::Seq(FK_PARENT_COL_NODES));
// `REFERENCES <parent> [ ( <col> ) ] [on delete/update …]` — the inline
// column-FK constraint. The referential clauses reuse the shared
// `on <delete|update> <action>` grammar (the DSL `add relationship`
// keywords are the SQL keywords).
static REFERENCES_NODES: &[Node] = &[
Node::Word(Word::keyword("references")),
FK_PARENT_TABLE,
FK_PARENT_COL_OPT,
shared::REFERENTIAL_CLAUSES,
];
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
// `Choice` never ambiguously commits.
@@ -148,6 +205,7 @@ static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(PRIMARY_KEY_NODES),
Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES),
REFERENCES_CLAUSE,
];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`).
@@ -250,13 +308,80 @@ static TABLE_CHECK_NODES: &[Node] = &[
];
const TABLE_CHECK: Node = Node::Seq(TABLE_CHECK_NODES);
// Table-level foreign key (ADR-0035 §5, sub-phase 4b):
// `[CONSTRAINT <name>] FOREIGN KEY ( <child col> ) REFERENCES
// <parent> [ ( <col> ) ] [on delete/update …]`. The child column is
// being defined in this statement (`NewName`); the optional
// `CONSTRAINT <name>` names the relationship (an inline `REFERENCES`
// is always auto-named instead).
const FK_CHILD_COLUMN: Node = Node::Ident {
source: IdentSource::NewName,
role: "fk_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 FK_NAME: Node = Node::Ident {
source: IdentSource::NewName,
role: "fk_name",
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,
};
// The `FOREIGN KEY (col) REFERENCES …` body, shared by the named and
// unnamed table-FK element branches. Each branch starts with a concrete
// keyword (`foreign` / `constraint`) — never a leading `Optional`,
// which would advance the Seq index and turn a later mismatch into a
// hard failure that aborts the enclosing element `Choice`.
static FOREIGN_KEY_BODY_NODES: &[Node] = &[
Node::Word(Word::keyword("foreign")),
Node::Word(Word::keyword("key")),
Node::Punct('('),
FK_CHILD_COLUMN,
Node::Punct(')'),
Node::Word(Word::keyword("references")),
FK_PARENT_TABLE,
FK_PARENT_COL_OPT,
shared::REFERENTIAL_CLAUSES,
];
const FOREIGN_KEY_BODY: Node = Node::Seq(FOREIGN_KEY_BODY_NODES);
// `FOREIGN KEY (…) …` — the unnamed table-level FK (auto-named).
const TABLE_FK: Node = FOREIGN_KEY_BODY;
// `CONSTRAINT <name> FOREIGN KEY (…) …` — the named table-level FK.
static TABLE_FK_NAMED_NODES: &[Node] = &[
Node::Word(Word::keyword("constraint")),
FK_NAME,
Node::Word(Word::keyword("foreign")),
Node::Word(Word::keyword("key")),
Node::Punct('('),
FK_CHILD_COLUMN,
Node::Punct(')'),
Node::Word(Word::keyword("references")),
FK_PARENT_TABLE,
FK_PARENT_COL_OPT,
shared::REFERENTIAL_CLAUSES,
];
const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// One element of the column list: a table-level `PRIMARY KEY (…)` /
// `UNIQUE (…)` / `CHECK (…)`, or a column definition. The table-level
// forms are tried first — each starts with a keyword (`primary` /
// `unique` / `check`) that disambiguates it from a column name. (A
// column literally named `primary`/`unique`/`check` is therefore
// unavailable, the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] = &[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, COLUMN_DEF];
// `UNIQUE (…)` / `CHECK (…)` / `[CONSTRAINT <name>] FOREIGN KEY (…)`,
// or a column definition. The table-level forms are tried first — each
// starts with a keyword (`primary` / `unique` / `check` / `constraint`
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
const ELEMENT: Node = Node::Choice(ELEMENT_CHOICES);
static COLUMN_LIST_NODES: &[Node] = &[
@@ -471,11 +596,23 @@ mod tests {
}
#[test]
fn foreign_key_still_rejected() {
// FK in CREATE TABLE is 4b — neither inline `REFERENCES` nor a
// table-level `FOREIGN KEY` shape exists in the grammar yet.
bad("table t (id int, ref int references other(id))");
bad("table t (id int, foreign key (id) references other(id))");
fn foreign_keys_accepted() {
// 4b: inline `REFERENCES` and table-level `FOREIGN KEY`, with
// optional `(col)`, `ON DELETE`/`ON UPDATE`, and `CONSTRAINT`.
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
"table t (id int, a int, b int, foreign key (a) references p(id), \
foreign key (b) references q(id))",
);
// FK alongside the other table elements (coexistence).
good("table t (id int primary key, ref int references other(id), check (id > 0))");
// self-reference (parent is the table being created).
good("table emp (id int primary key, mgr int references emp(id))");
}
}
@@ -487,7 +624,8 @@ mod tests {
#[cfg(test)]
mod builder_tests {
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ColumnSpec, Command, SqlForeignKey};
use crate::dsl::parser::{parse_command, parse_command_in_mode};
use crate::dsl::types::Type;
use crate::mode::Mode;
@@ -808,4 +946,129 @@ mod builder_tests {
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
// --- 4b: foreign keys (inline + table-level) ---
/// Parse and return the foreign keys.
fn parse_sct_fks(input: &str) -> Vec<SqlForeignKey> {
match parse_command(input).expect("should parse") {
Command::SqlCreateTable { foreign_keys, .. } => foreign_keys,
other => panic!("expected SqlCreateTable, got {other:?}"),
}
}
#[test]
fn inline_reference_captured() {
let fks = parse_sct_fks("create table t (id int, pid int references parent(id))");
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.parent_table, "parent");
assert_eq!(fk.parent_column.as_deref(), Some("id"));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
}
#[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_table, "parent");
assert_eq!(fks[0].child_column, "pid");
}
#[test]
fn inline_reference_with_referential_actions() {
let fks = parse_sct_fks(
"create table t (id int, pid int references parent(id) \
on delete cascade on update set null)",
);
assert_eq!(fks[0].on_delete, ReferentialAction::Cascade);
assert_eq!(fks[0].on_update, ReferentialAction::SetNull);
}
#[test]
fn referential_action_order_is_flexible() {
// `on update` before `on delete` — either order is accepted.
let fks = parse_sct_fks(
"create table t (id int, pid int references parent(id) \
on update restrict on delete no action)",
);
assert_eq!(fks[0].on_update, ReferentialAction::Restrict);
assert_eq!(fks[0].on_delete, ReferentialAction::NoAction);
}
#[test]
fn table_level_foreign_key_captured() {
let fks =
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].parent_table, "parent");
assert_eq!(fks[0].parent_column.as_deref(), Some("id"));
}
#[test]
fn table_level_foreign_key_with_constraint_name() {
let fks = parse_sct_fks(
"create table t (id int, pid int, \
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");
}
#[test]
fn multiple_foreign_keys_collected_in_order() {
let fks = parse_sct_fks(
"create table t (id int, a int, b int, \
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"));
}
#[test]
fn self_referencing_foreign_key_captured() {
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"));
}
#[test]
fn inline_fk_coexists_with_check_and_pk() {
// FK clause must not be confused with the column CHECK that
// follows, nor disturb the table-level PK / CHECK detection.
match parse_command(
"create table t (id int primary key, pid int references parent(id) check (pid > 0), \
check (id <> pid))",
)
.expect("parses")
{
Command::SqlCreateTable {
primary_key,
foreign_keys,
check_constraints,
columns,
..
} => {
assert_eq!(primary_key, vec!["id".to_string()]);
assert_eq!(foreign_keys.len(), 1);
assert_eq!(foreign_keys[0].child_column, "pid");
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
assert_eq!(check_constraints, vec!["id <> pid".to_string()]);
}
other => panic!("expected SqlCreateTable, got {other:?}"),
}
}
}