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:
+123
-4
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user