diff --git a/src/db.rs b/src/db.rs index fc730d0..df090fa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -7110,6 +7110,7 @@ fn resolve_fk_parent_columns( parent_pk: &[String], explicit: Option<&[String]>, child_arity: usize, + inline: bool, ) -> Result, DbError> { if child_arity == 0 { return Err(DbError::Unsupported( @@ -7142,6 +7143,20 @@ fn resolve_fk_parent_columns( } }; if parent_columns.len() != child_arity { + // An inline column-level FK (` REFERENCES …`) can only carry + // the one column it sits on, so it can never satisfy a compound + // key — point the user at the table-level form rather than the + // generic arity message (ADR-0043 D4). + if inline && parent_columns.len() > 1 { + return Err(DbError::Unsupported(format!( + "an inline column reference can only name one column, but \ + `{parent_table}`'s key has {n}. Use the table-level form \ + instead: `FOREIGN KEY () REFERENCES \ + {parent_table} ({pk})`.", + n = parent_columns.len(), + pk = parent_columns.join(", "), + ))); + } return Err(DbError::Unsupported(format!( "{child_arity} foreign-key column(s) on the child side, but \ `{parent_table}`'s key has {n}. A foreign key references every \ @@ -7210,6 +7225,7 @@ fn resolve_create_table_fks( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, )?; // Each child column must be one of the columns being defined, @@ -7295,6 +7311,7 @@ fn do_add_relationship( &parent_schema.primary_key, Some(parent_columns), child_columns.len(), + false, // DSL `add relationship` is never an inline column FK )?; // 2. Read child schema; refuse missing columns unless --create-fk. @@ -7824,6 +7841,7 @@ fn do_alter_add_foreign_key( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level) )?; // Every child column must already exist for `ALTER … ADD FOREIGN // KEY` — there is no SQL spelling to auto-create one (`--create-fk` diff --git a/src/dsl/command.rs b/src/dsl/command.rs index a5b6b07..9378448 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -45,6 +45,13 @@ pub struct SqlForeignKey { pub parent_columns: Option>, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, + /// `true` for an inline column-level FK (` REFERENCES …`), + /// `false` for the table-level `FOREIGN KEY (…)` and `ALTER …` + /// forms. An inline FK is single-column by construction, so when + /// it references a compound key the resolver points the user at + /// the table-level form rather than emitting the generic arity + /// error (ADR-0043 D4). + pub inline: bool, } /// A column at table-creation time: a name, a user-facing diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 35b15b5..022521c 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1557,7 +1557,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result] foreign key () // references [()] [on …]` (ADR-0035 §5, 4b). @@ -1587,7 +1587,8 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result( items: &mut std::iter::Peekable, name: Option, child_columns: Vec, + inline: bool, ) -> SqlForeignKey where I: Iterator, @@ -1752,6 +1754,7 @@ where parent_columns, on_delete, on_update, + inline, } } @@ -2454,7 +2457,8 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { items.next(); } - consume_fk_reference(&mut items, None, child_columns) + // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. + consume_fk_reference(&mut items, None, child_columns, false) } pub static SQL_ALTER_TABLE: CommandNode = CommandNode { diff --git a/src/dsl/grammar/sql_create_table.rs b/src/dsl/grammar/sql_create_table.rs index 1a09883..49415c3 100644 --- a/src/dsl/grammar/sql_create_table.rs +++ b/src/dsl/grammar/sql_create_table.rs @@ -1004,6 +1004,16 @@ mod builder_tests { assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction); + assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)"); + } + + #[test] + fn table_level_fk_is_not_inline() { + // The table-level `FOREIGN KEY (...)` form is not inline, so it can + // carry a multi-column reference and never triggers the inline + // "use the table-level form" hint (ADR-0043 D4). + let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); + assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline"); } #[test] diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index e8b539f..3ca22df 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -137,6 +137,7 @@ fn sql_create_table_compound_fk_executes_and_enforces() { parent_columns: Some(vec!["country".to_string(), "code".to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, }], false, None, @@ -363,6 +364,65 @@ fn compound_fk_arity_mismatch_is_refused() { }); } +#[test] +fn inline_fk_referencing_compound_pk_points_at_table_level_form() { + // ADR-0043 D4 residual: an *inline* single-column FK cannot express a + // multi-column reference, so referencing a parent's compound PK must + // refuse with a pointer to the table-level `FOREIGN KEY (...)` form — + // not the generic arity message. The grammar marks the FK `inline`. + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country", Type::Int), + ColumnSpec::new("code", Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .expect("create Region"); + + // Parse the inline form so the `inline` flag is set by the grammar. + let cmd = parse_command( + "create table City (country int references Region(country, code))", + ) + .expect("parses"); + let Command::SqlCreateTable { + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + } = cmd + else { + panic!("expected SqlCreateTable"); + }; + let err = db + .sql_create_table( + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + None, + ) + .await + .expect_err("inline FK referencing a compound PK must be refused"); + let msg = format!("{err}"); + assert!( + msg.contains("FOREIGN KEY"), + "expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}" + ); + }); +} + #[test] fn compound_fk_type_mismatch_per_pair_is_refused() { let (_p, db, _dir) = open_project_db(); diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 2d0b097..0d07f54 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -839,6 +839,7 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq parent_columns: parent_column.map(|c| vec![c.to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, } } diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index b71be7a..a94195f 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -109,6 +109,7 @@ fn dropping_a_referenced_parent_is_refused() { parent_columns: Some(vec!["id".to_string()]), on_delete: rdbms_playground::dsl::ReferentialAction::NoAction, on_update: rdbms_playground::dsl::ReferentialAction::NoAction, + inline: true, }], false, Some("create table child (id serial primary key, pid int references parent(id))".to_string()),