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),