From b14f0199e9c019ce1ee9661f518edc54f2a95233 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 9 Jun 2026 18:25:40 +0000 Subject: [PATCH] refactor: relationship model to column lists for compound FK (ADR-0043) Move the FK column fields String->Vec through all six layers (AddRelationship/SqlForeignKey AST, RelationshipSchema, metadata, project.yaml, ReadForeignKey, RelationshipEnd). Metadata stores comma-joined lists in the existing TEXT cells; project.yaml endpoints now columns: [a, b] (house style). Executor logic is multi-column ready: resolve_fk_parent_columns (full-PK F-A + auto-expand F-D), per-pair type-compat, schema_to_ddl multi-column emission, pragma FK read grouped by id, auto-name + --create-fk per-column, multi-column teaching echo. Single-column behaviour preserved (one-element vecs); all 2181 tests green. The grammar to parse multi-column input lands next. --- ...0043-compound-pk-foreign-key-references.md | 11 +- src/app.rs | 6 +- src/db.rs | 660 +++++++++++------- src/dsl/command.rs | 47 +- src/dsl/grammar/ddl.rs | 29 +- src/dsl/grammar/sql_create_table.rs | 24 +- src/dsl/parser.rs | 4 +- src/dsl/walker/mod.rs | 8 +- src/echo.rs | 67 +- src/output_render.rs | 22 +- src/persistence/mod.rs | 8 +- src/persistence/yaml.rs | 40 +- src/runtime.rs | 216 +++--- tests/it/column_op_guards.rs | 8 +- tests/it/friendly_enrichment.rs | 12 +- tests/it/iteration2_persistence.rs | 8 +- tests/it/iteration3_rebuild.rs | 4 +- tests/it/show_list.rs | 4 +- tests/it/sql_create_table.rs | 10 +- tests/it/sql_delete.rs | 16 +- tests/it/sql_dml_e2e.rs | 4 +- tests/it/sql_drop_table.rs | 4 +- tests/it/walking_skeleton.rs | 16 +- 23 files changed, 721 insertions(+), 507 deletions(-) diff --git a/docs/adr/0043-compound-pk-foreign-key-references.md b/docs/adr/0043-compound-pk-foreign-key-references.md index b996a19..e2404f2 100644 --- a/docs/adr/0043-compound-pk-foreign-key-references.md +++ b/docs/adr/0043-compound-pk-foreign-key-references.md @@ -159,9 +159,14 @@ convention `project.yaml` already uses for `primary_key` and index - **Metadata** (`__rdbms_playground_relationships`): no `CREATE TABLE` change (the `TEXT` columns and `PRIMARY KEY (child_table, child_column)` are untouched). - `parent_column` / `child_column` store the list as a JSON array - string — uniformly, including `["id"]` for a single column - (SQLite has no array type, so a text cell is where a list lives). + `parent_column` / `child_column` store the list **comma-joined** + in the same text cell (`a,b`; a single column is just its bare + name). *As-built note:* the ADR first said "JSON array"; the + implementation uses a comma delimiter, which is safe because + column identifiers are `[A-Za-z0-9_]+` (no commas — `parser.rs`) + and simpler (no `serde_json` dependency). This is an internal + encoding detail below fork F-B — the user-visible `project.yaml` + is still the `columns: [a, b]` list. The actual enforced FK lives on the rebuilt child table's DDL (`FOREIGN KEY (a, b) REFERENCES P(x, y)`), emitted by `schema_to_ddl`, exactly as the single-column FK is today via the diff --git a/src/app.rs b/src/app.rs index bfa77d6..02c890b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1968,12 +1968,14 @@ impl App { ), C::AddRelationship { parent_table, - parent_column, + parent_columns, .. } => ( Operation::AddRelationship, Some(parent_table.as_str()), - Some(parent_column.as_str()), + // Single-column facts model (ADR-0019): the first PK + // column for a compound FK (ADR-0043). + parent_columns.first().map(String::as_str), ), C::DropRelationship { selector } => match selector { RelationshipSelector::Endpoints { diff --git a/src/db.rs b/src/db.rs index 7340533..4adc288 100644 --- a/src/db.rs +++ b/src/db.rs @@ -114,10 +114,11 @@ pub struct RelationshipEnd { pub name: String, /// The other table involved. pub other_table: String, - /// The column on the other table. - pub other_column: String, - /// The column on *this* table. - pub local_column: String, + /// The column(s) on the other table (ordered, paired with + /// `local_columns`; one element for single-column — ADR-0043). + pub other_columns: Vec, + /// The column(s) on *this* table. + pub local_columns: Vec, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, } @@ -573,9 +574,9 @@ enum Request { AddRelationship { name: Option, parent_table: String, - parent_column: String, + parent_columns: Vec, child_table: String, - child_column: String, + child_columns: Vec, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, @@ -1360,9 +1361,9 @@ impl Database { &self, name: Option, parent_table: String, - parent_column: String, + parent_columns: Vec, child_table: String, - child_column: String, + child_columns: Vec, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, @@ -1372,9 +1373,9 @@ impl Database { self.send(Request::AddRelationship { name, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, create_fk, @@ -2286,9 +2287,9 @@ fn handle_request( Request::AddRelationship { name, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, create_fk, @@ -2301,9 +2302,9 @@ fn handle_request( source.as_deref(), name.as_deref(), &parent_table, - &parent_column, + &parent_columns, &child_table, - &child_column, + &child_columns, on_delete, on_update, create_fk, @@ -3003,9 +3004,9 @@ fn read_all_relationships(conn: &Connection) -> Result, Ok(RelationshipSchema { name: row.get(0)?, parent_table: row.get(1)?, - parent_column: row.get(2)?, + parent_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()), child_table: row.get(3)?, - child_column: row.get(4)?, + child_columns: decode_rel_columns(row.get::<_, String>(4)?.as_str()), on_delete: parse_action_from_sqlite(row.get::<_, String>(5)?.as_str()), on_update: parse_action_from_sqlite(row.get::<_, String>(6)?.as_str()), }) @@ -3157,6 +3158,15 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result String { + cols.iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", ") +} + fn quote_ident(name: &str) -> String { let mut out = String::with_capacity(name.len() + 2); out.push('"'); @@ -3456,9 +3466,9 @@ fn do_create_table( for fk in &resolved_fks { ddl.push_str(&format!( ", FOREIGN KEY ({child}) REFERENCES {parent}({pcol}) ON DELETE {od} ON UPDATE {ou}", - child = quote_ident(&fk.child_column), + child = quote_cols(&fk.child_columns), parent = quote_ident(&fk.parent_table), - pcol = quote_ident(&fk.parent_column), + pcol = quote_cols(&fk.parent_columns), od = fk.on_delete.sql_clause(), ou = fk.on_update.sql_clause(), )); @@ -3497,9 +3507,9 @@ fn do_create_table( &tx, &fk.name, &fk.parent_table, - &fk.parent_column, + &fk.parent_columns, name, - &fk.child_column, + &fk.child_columns, fk.on_delete, fk.on_update, )?; @@ -5897,8 +5907,10 @@ fn do_show_list( lines.push(format!("Relationships ({}):", rels.len())); for r in rels { let mut line = format!( - " {}: {}.{} → {}.{}", - r.name, r.parent_table, r.parent_column, r.child_table, r.child_column + " {}: {} → {}", + r.name, + fmt_rel_endpoint(&r.parent_table, &r.parent_columns), + fmt_rel_endpoint(&r.child_table, &r.child_columns), ); if r.on_delete != ReferentialAction::default_action() { line.push_str(&format!(" on delete {}", r.on_delete.keyword())); @@ -5958,8 +5970,9 @@ fn do_show_one( Some(r) => { lines.push(format!("Relationship `{}`:", r.name)); lines.push(format!( - " {}.{} → {}.{}", - r.parent_table, r.parent_column, r.child_table, r.child_column + " {} → {}", + fmt_rel_endpoint(&r.parent_table, &r.parent_columns), + fmt_rel_endpoint(&r.child_table, &r.child_columns), )); lines.push(format!(" on delete {}", r.on_delete.keyword())); lines.push(format!(" on update {}", r.on_update.keyword())); @@ -6046,8 +6059,11 @@ struct ReadColumn { #[derive(Debug, Clone)] struct ReadForeignKey { parent_table: String, - parent_column: String, - child_column: String, + /// Parent + child column lists, positionally paired; one + /// element for single-column, ordered list for a compound FK + /// (ADR-0043). + parent_columns: Vec, + child_columns: Vec, on_delete: ReferentialAction, on_update: ReferentialAction, } @@ -6108,30 +6124,50 @@ fn read_schema(conn: &Connection, table: &str) -> Result { } } - // Foreign keys from pragma_foreign_key_list. + // Foreign keys from pragma_foreign_key_list. A *compound* FK + // (ADR-0043) is reported as several rows sharing one `id`, in + // `seq` order — so we group consecutive same-`id` rows into one + // `ReadForeignKey` with positionally-ordered column lists. let mut fk_stmt = conn .prepare( - "SELECT \"table\", \"from\", \"to\", on_delete, on_update \ + "SELECT id, \"table\", \"from\", \"to\", on_delete, on_update \ FROM pragma_foreign_key_list(?1) \ ORDER BY id, seq;", ) .map_err(DbError::from_rusqlite)?; let fk_rows = fk_stmt .query_map([table], |row| { - let on_delete_str: String = row.get(3)?; - let on_update_str: String = row.get(4)?; - Ok(ReadForeignKey { - parent_table: row.get(0)?, - child_column: row.get(1)?, - parent_column: row.get(2)?, - on_delete: parse_action_from_sqlite(&on_delete_str), - on_update: parse_action_from_sqlite(&on_update_str), - }) + Ok(( + row.get::<_, i64>(0)?, // id — groups a compound FK's columns + row.get::<_, String>(1)?, // parent table + row.get::<_, String>(2)?, // child column (`from`) + row.get::<_, String>(3)?, // parent column (`to`) + parse_action_from_sqlite(&row.get::<_, String>(4)?), + parse_action_from_sqlite(&row.get::<_, String>(5)?), + )) }) .map_err(DbError::from_rusqlite)?; - let mut foreign_keys = Vec::new(); + let mut foreign_keys: Vec = Vec::new(); + let mut current_id: Option = None; for row in fk_rows { - foreign_keys.push(row.map_err(DbError::from_rusqlite)?); + let (id, parent_table, child_col, parent_col, on_delete, on_update) = + row.map_err(DbError::from_rusqlite)?; + if current_id == Some(id) { + let fk = foreign_keys + .last_mut() + .expect("a same-id group always follows its first row"); + fk.child_columns.push(child_col); + fk.parent_columns.push(parent_col); + } else { + current_id = Some(id); + foreign_keys.push(ReadForeignKey { + parent_table, + child_columns: vec![child_col], + parent_columns: vec![parent_col], + on_delete, + on_update, + }); + } } // Table-level CHECK constraints (ADR-0035 §4a.3) come from their @@ -6653,12 +6689,15 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String { } for fk in &schema.foreign_keys { + // Multi-column FK (ADR-0043): emit the positionally-paired + // column lists `FOREIGN KEY (a, b) REFERENCES P(x, y)`. A + // single-column FK is the one-element case. clauses.push(format!( "FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \ ON DELETE {od} ON UPDATE {ou}", - child = quote_ident(&fk.child_column), + child = quote_cols(&fk.child_columns), parent_table = quote_ident(&fk.parent_table), - parent_col = quote_ident(&fk.parent_column), + parent_col = quote_cols(&fk.parent_columns), od = fk.on_delete.sql_clause(), ou = fk.on_update.sql_clause(), )); @@ -6842,12 +6881,21 @@ where fn resolve_relationship_name( name: Option<&str>, parent_table: &str, - parent_column: &str, + parent_columns: &[String], child_table: &str, - child_column: &str, + child_columns: &[String], ) -> String { + // Auto-name joins each side's column list with `_` (ADR-0043): + // a compound FK becomes `Parent_a_b_to_Child_x_y`; a + // single-column FK is unchanged (`Parent_a_to_Child_x`). name.map_or_else( - || format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"), + || { + format!( + "{parent_table}_{p}_to_{child_table}_{c}", + p = parent_columns.join("_"), + c = child_columns.join("_"), + ) + }, ToString::to_string, ) } @@ -6880,9 +6928,9 @@ fn insert_relationship_metadata( conn: &Connection, name: &str, parent_table: &str, - parent_column: &str, + parent_columns: &[String], child_table: &str, - child_column: &str, + child_columns: &[String], on_delete: ReferentialAction, on_update: ReferentialAction, ) -> Result<(), DbError> { @@ -6895,9 +6943,9 @@ fn insert_relationship_metadata( [ name, parent_table, - parent_column, + &encode_rel_columns(parent_columns), child_table, - child_column, + &encode_rel_columns(child_columns), on_delete.keyword(), on_update.keyword(), ], @@ -6906,6 +6954,31 @@ fn insert_relationship_metadata( Ok(()) } +/// Encode a relationship's column list into a metadata `TEXT` cell +/// (ADR-0043 D5): comma-joined. Safe because column identifiers are +/// `[A-Za-z0-9_]+` (no commas — parser.rs); a single-column FK +/// stores just the bare name, a compound one stores `a,b`. +fn encode_rel_columns(cols: &[String]) -> String { + cols.join(",") +} + +/// Inverse of [`encode_rel_columns`]: split a metadata cell back +/// into the ordered column list (one element for single-column). +fn decode_rel_columns(s: &str) -> Vec { + s.split(',').map(str::to_string).collect() +} + +/// Format a relationship endpoint for display (ADR-0043): `T.col` +/// for a single column, `T.(a, b)` for a compound one — mirroring +/// the DSL `from P.(a, b)` syntax. +fn fmt_rel_endpoint(table: &str, cols: &[String]) -> String { + if cols.len() == 1 { + format!("{table}.{}", cols[0]) + } else { + format!("{table}.({})", cols.join(", ")) + } +} + /// Validate that an FK child column's type is compatible with the /// referenced parent column's type — it must equal the parent type's /// `fk_target_type()` (ADR-0011). Engine-neutral mismatch error. @@ -6929,15 +7002,74 @@ fn check_fk_type_compat( Ok(()) } +/// Resolve a foreign key's parent (referenced) column list and check +/// it pairs with the child column list (ADR-0043). +/// +/// - **F-A (full PK):** an *explicit* `REFERENCES P(...)` list must be +/// exactly the parent's primary-key column **set** (any order; +/// paired positionally with the child list). A subset, super-set, +/// or non-PK column is refused (UNIQUE-target / subset FKs are OOS). +/// - **F-D (auto-expand):** a *bare* `REFERENCES P` (`explicit == +/// None`) expands to the parent's full PK in PK order, requiring the +/// child arity to match. +/// - Arity: the returned parent list and the child list must be +/// equal, non-zero length. +fn resolve_fk_parent_columns( + parent_table: &str, + parent_pk: &[String], + explicit: Option<&[String]>, + child_arity: usize, +) -> Result, DbError> { + if child_arity == 0 { + return Err(DbError::Unsupported( + "a foreign key needs at least one column.".to_string(), + )); + } + let parent_columns = match explicit { + None => { + if parent_pk.is_empty() { + return Err(DbError::Unsupported(format!( + "`{parent_table}` has no primary key to reference." + ))); + } + parent_pk.to_vec() + } + Some(cols) => { + use std::collections::BTreeSet; + let pk: BTreeSet<&String> = parent_pk.iter().collect(); + let given: BTreeSet<&String> = cols.iter().collect(); + if pk != given { + return Err(DbError::Unsupported(format!( + "a foreign key must reference `{parent_table}`'s full primary \ + key ({pk_list}); got ({given_list}). Referencing a subset, or \ + a non-primary-key column, is not supported.", + pk_list = parent_pk.join(", "), + given_list = cols.join(", "), + ))); + } + cols.to_vec() + } + }; + if parent_columns.len() != child_arity { + 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 \ + column of the key, paired in order.", + n = parent_columns.len(), + ))); + } + Ok(parent_columns) +} + /// A `CREATE TABLE` foreign key after resolution + validation /// (ADR-0035 §5, sub-phase 4b): the bare-`REFERENCES` parent column is /// resolved, the relationship name is decided, and PK-target / /// type-compat are checked. struct ResolvedFk { name: String, - child_column: String, + child_columns: Vec, parent_table: String, - parent_column: String, + parent_columns: Vec, on_delete: ReferentialAction, on_update: ReferentialAction, } @@ -6979,73 +7111,58 @@ fn resolve_create_table_fks( ) }; - // Explicit referenced column, or the parent's single-column PK - // for the bare `REFERENCES ` form. - let parent_column = match &fk.parent_column { - Some(c) => c.clone(), - None => { - if parent_pk.len() == 1 { - parent_pk[0].clone() - } else { - return Err(DbError::Unsupported(format!( - "`{parent}` has a composite primary key, so a bare \ - reference is ambiguous — name the referenced column, \ - e.g. `REFERENCES {parent}()`.", - parent = fk.parent_table, - ))); - } - } - }; - - // The referenced column must be a primary key (ADR-0011/0013). - if !parent_pk.contains(&parent_column) { - return Err(DbError::Unsupported(format!( - "column `{}.{}` is not a primary key. Foreign keys must \ - reference a primary key (UNIQUE-target FKs land in a later \ - iteration).", - fk.parent_table, parent_column - ))); - } - let parent_type = parent_cols - .iter() - .find(|(n, _)| n == &parent_column) - .and_then(|(_, t)| *t) - .ok_or_else(|| DbError::Sqlite { - message: format!("no such column: {}.{}", fk.parent_table, parent_column), - kind: SqliteErrorKind::NoSuchColumn, - })?; - - // The child column must be one of the columns being defined. - let child = columns - .iter() - .find(|c| c.name == fk.child_column) - .ok_or_else(|| DbError::Sqlite { - message: format!("no such column: {}.{}", table_name, fk.child_column), - kind: SqliteErrorKind::NoSuchColumn, - })?; - check_fk_type_compat( + // Resolve the parent column list: explicit must be the full PK + // set (F-A); bare auto-expands to the PK (F-D). Arity-checked + // against the child column count (ADR-0043). + let parent_columns = resolve_fk_parent_columns( &fk.parent_table, - &parent_column, - parent_type, - table_name, - &fk.child_column, - child.ty, + &parent_pk, + fk.parent_columns.as_deref(), + fk.child_columns.len(), )?; + // Each child column must be one of the columns being defined, + // and each pair must be type-compatible (ADR-0011, per pair). + for (child_col, parent_col) in fk.child_columns.iter().zip(&parent_columns) { + let child = columns + .iter() + .find(|c| &c.name == child_col) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {table_name}.{child_col}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let parent_type = parent_cols + .iter() + .find(|(n, _)| n == parent_col) + .and_then(|(_, t)| *t) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {}.{parent_col}", fk.parent_table), + kind: SqliteErrorKind::NoSuchColumn, + })?; + check_fk_type_compat( + &fk.parent_table, + parent_col, + parent_type, + table_name, + child_col, + child.ty, + )?; + } + let resolved_name = resolve_relationship_name( fk.name.as_deref(), &fk.parent_table, - &parent_column, + &parent_columns, table_name, - &fk.child_column, + &fk.child_columns, ); ensure_relationship_name_unique(conn, &resolved_name)?; out.push(ResolvedFk { name: resolved_name, - child_column: fk.child_column.clone(), + child_columns: fk.child_columns.clone(), parent_table: fk.parent_table.clone(), - parent_column, + parent_columns, on_delete: fk.on_delete, on_update: fk.on_update, }); @@ -7060,9 +7177,9 @@ fn do_add_relationship( source: Option<&str>, name: Option<&str>, parent_table: &str, - parent_column: &str, + parent_columns: &[String], child_table: &str, - child_column: &str, + child_columns: &[String], on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, @@ -7077,102 +7194,115 @@ fn do_add_relationship( let parent_table = canonical_parent.as_str(); let canonical_child = require_canonical_table(conn, child_table)?; let child_table = canonical_child.as_str(); - // 1. Read parent schema; verify the referenced column is a PK. + // 1. Read parent schema; resolve+validate the referenced columns + // are the parent's full PK (F-A), arity-matched to the child + // list (ADR-0043). The DSL always supplies explicit columns. let parent_schema = read_schema(conn, parent_table)?; - let parent_col = parent_schema - .columns - .iter() - .find(|c| c.name == parent_column) - .ok_or_else(|| DbError::Sqlite { - message: format!("no such column: {parent_table}.{parent_column}"), - kind: SqliteErrorKind::NoSuchColumn, - })?; - if !parent_col.primary_key { - return Err(DbError::Unsupported(format!( - "column `{parent_table}.{parent_column}` is not a primary key. \ - Foreign keys must reference a primary key (UNIQUE-target FKs \ - land in a later iteration)." - ))); - } + let parent_columns = resolve_fk_parent_columns( + parent_table, + &parent_schema.primary_key, + Some(parent_columns), + child_columns.len(), + )?; - // 2. Read child schema; verify the FK column or auto-create. + // 2. Read child schema; refuse missing columns unless --create-fk. let mut child_schema = read_schema(conn, child_table)?; - let needs_create_column = child_schema - .columns + let missing: Vec<&String> = child_columns .iter() - .all(|c| c.name != child_column); - if needs_create_column && !create_fk { + .filter(|c| child_schema.columns.iter().all(|col| &col.name != *c)) + .collect(); + if !missing.is_empty() && !create_fk { + let list = missing + .iter() + .map(|c| format!("`{child_table}.{c}`")) + .collect::>() + .join(", "); return Err(DbError::Unsupported(format!( - "column `{child_table}.{child_column}` does not exist. \ - Add it first, or use `--create-fk` to create it automatically." + "column(s) {list} do not exist. Add them first, or use \ + `--create-fk` to create them automatically." ))); } - // 3. Determine child column type. Either the existing one's - // user_type, or the parent's fk_target_type for auto-create. - let parent_user_type = parent_col.user_type.ok_or_else(|| DbError::Unsupported( - "parent column has no user type metadata".to_string(), - ))?; - let expected_child_type = parent_user_type.fk_target_type(); + // 3. Per pair: resolve the parent column's user type, then either + // validate the existing child column's type (ADR-0011 per pair) + // or synthesise a new child column typed to the parent's + // fk_target_type (--create-fk, one per missing column). + let mut created: Vec<(String, Type)> = Vec::new(); + for (child_col, parent_col) in child_columns.iter().zip(&parent_columns) { + let pcol = parent_schema + .columns + .iter() + .find(|c| &c.name == parent_col) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {parent_table}.{parent_col}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let parent_user_type = pcol.user_type.ok_or_else(|| { + DbError::Unsupported("parent column has no user type metadata".to_string()) + })?; + match child_schema.columns.iter().find(|c| &c.name == child_col) { + Some(child_col_row) => { + let actual = child_col_row.user_type.ok_or_else(|| { + DbError::Unsupported("child column has no user type metadata".to_string()) + })?; + check_fk_type_compat( + parent_table, + parent_col, + parent_user_type, + child_table, + child_col, + actual, + )?; + } + None => created.push((child_col.clone(), parent_user_type.fk_target_type())), + } + } - if needs_create_column { - // Synthesise the column row for the new schema. + // Synthesise the new child columns into the old schema (so rebuild + // builds the new table with them), mirroring the single-column path. + for (col, ty) in &created { child_schema.columns.push(ReadColumn { - name: child_column.to_string(), - sqlite_type: expected_child_type.sqlite_strict_type().to_string(), + name: col.clone(), + sqlite_type: ty.sqlite_strict_type().to_string(), notnull: false, primary_key: false, unique: false, default_sql: None, check: None, - user_type: Some(expected_child_type), + user_type: Some(*ty), }); - } else { - // Validate type compatibility against the existing column. - let child_col = child_schema - .columns - .iter() - .find(|c| c.name == child_column) - .expect("checked above"); - let actual = child_col.user_type.ok_or_else(|| DbError::Unsupported( - "child column has no user type metadata".to_string(), - ))?; - check_fk_type_compat( - parent_table, - parent_column, - parent_user_type, - child_table, - child_column, - actual, - )?; } // 4. Determine relationship name (auto-gen or supplied) and // check uniqueness against the metadata table. - let resolved_name = - resolve_relationship_name(name, parent_table, parent_column, child_table, child_column); + let resolved_name = resolve_relationship_name( + name, + parent_table, + &parent_columns, + child_table, + child_columns, + ); ensure_relationship_name_unique(conn, &resolved_name)?; - // 5. Build the new schema with the FK appended. + // 5. Build the new schema with the (single, multi-column) FK appended. let mut new_schema = child_schema.clone(); new_schema.foreign_keys.push(ReadForeignKey { parent_table: parent_table.to_string(), - parent_column: parent_column.to_string(), - child_column: child_column.to_string(), + parent_columns: parent_columns.clone(), + child_columns: child_columns.to_vec(), on_delete, on_update, }); // 6. Rebuild, with metadata updates inside the transaction. - let column_user_type_kw = expected_child_type.keyword(); rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| { - if needs_create_column { + for (col, ty) in &created { tx.execute( &format!( "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ VALUES (?1, ?2, ?3);" ), - [child_table, child_column, column_user_type_kw], + [child_table, col.as_str(), ty.keyword()], ) .map_err(DbError::from_rusqlite)?; } @@ -7180,9 +7310,9 @@ fn do_add_relationship( tx, &resolved_name, parent_table, - parent_column, + &parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, )?; @@ -7253,7 +7383,9 @@ fn do_drop_relationship( // Read child schema; build new schema without the FK. let old_schema = read_schema(conn, &child_table)?; let mut new_schema = old_schema.clone(); - new_schema.foreign_keys.retain(|fk| fk.child_column != child_column); + new_schema + .foreign_keys + .retain(|fk| fk.child_columns != decode_rel_columns(&child_column)); let child_table_for_persist = child_table.clone(); rebuild_table(conn, &child_table, &old_schema, &new_schema, |tx| { @@ -7586,37 +7718,35 @@ fn do_alter_add_foreign_key( ) -> Result { reject_internal_table_name(child_table)?; reject_internal_table_name(&fk.parent_table)?; - let parent_column = match &fk.parent_column { - Some(c) => c.clone(), - None => { - let ps = read_schema(conn, &fk.parent_table)?; - if ps.primary_key.len() == 1 { - ps.primary_key[0].clone() - } else { - return Err(DbError::Unsupported(format!( - "`{parent}` has a composite primary key, so a bare reference \ - is ambiguous — name the referenced column, e.g. \ - `REFERENCES {parent}()`.", - parent = fk.parent_table, - ))); - } + // Resolve the parent columns: explicit must be the full PK (F-A); + // a bare `REFERENCES P` auto-expands to the full PK in PK order + // (F-D), arity-matched to the child list (ADR-0043). + let parent_pk = read_schema(conn, &fk.parent_table)?.primary_key; + let parent_columns = resolve_fk_parent_columns( + &fk.parent_table, + &parent_pk, + fk.parent_columns.as_deref(), + fk.child_columns.len(), + )?; + // Every child column must already exist for `ALTER … ADD FOREIGN + // KEY` — there is no SQL spelling to auto-create one (`--create-fk` + // is the simple-mode `add relationship` surface only). Pre-check + // here so the refusal speaks SQL, not the DSL flag (ADR-0035 + // Amendment 1, gap C). A missing child *table* is left to + // `do_add_relationship`'s own "no such table". + if let Ok(child_schema) = read_schema(conn, child_table) { + let missing: Vec<&String> = fk + .child_columns + .iter() + .filter(|c| child_schema.columns.iter().all(|col| &col.name != *c)) + .collect(); + if let Some(child) = missing.first() { + return Err(DbError::Unsupported(format!( + "column `{child_table}.{child}` does not exist — add it first \ + (`alter table {child_table} add column {child} `), then \ + add the foreign key." + ))); } - }; - // The child column must already exist for `ALTER … ADD FOREIGN KEY` — - // there is no SQL spelling to auto-create it (the `--create-fk` option - // is the simple-mode `add relationship` surface only). Pre-check here - // so the refusal speaks SQL, not the DSL flag (ADR-0035 Amendment 1, - // gap C). A missing child *table* is left to `do_add_relationship`'s - // own "no such table". - if let Ok(child_schema) = read_schema(conn, child_table) - && child_schema.columns.iter().all(|c| c.name != fk.child_column) - { - return Err(DbError::Unsupported(format!( - "column `{child_table}.{child}` does not exist — add it first \ - (`alter table {child_table} add column {child} `), then \ - add the foreign key.", - child = fk.child_column, - ))); } do_add_relationship( conn, @@ -7624,9 +7754,9 @@ fn do_alter_add_foreign_key( source, name, &fk.parent_table, - &parent_column, + &parent_columns, child_table, - &fk.child_column, + &fk.child_columns, fk.on_delete, fk.on_update, false, @@ -9577,8 +9707,8 @@ fn read_relationships_outbound( Ok(RelationshipEnd { name: row.get(0)?, other_table: row.get(1)?, - other_column: row.get(2)?, - local_column: row.get(3)?, + other_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()), + local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()), on_delete: on_delete .parse::() .unwrap_or(ReferentialAction::NoAction), @@ -9614,8 +9744,8 @@ fn read_relationships_inbound( Ok(RelationshipEnd { name: row.get(0)?, other_table: row.get(1)?, - other_column: row.get(2)?, - local_column: row.get(3)?, + other_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()), + local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()), on_delete: on_delete .parse::() .unwrap_or(ReferentialAction::NoAction), @@ -9757,12 +9887,14 @@ fn do_rebuild_from_text( )) .map_err(DbError::from_rusqlite)?; for rel in &snapshot.relationships { + let parent_cols = encode_rel_columns(&rel.parent_columns); + let child_cols = encode_rel_columns(&rel.child_columns); stmt.execute([ rel.name.as_str(), rel.parent_table.as_str(), - rel.parent_column.as_str(), + parent_cols.as_str(), rel.child_table.as_str(), - rel.child_column.as_str(), + child_cols.as_str(), rel.on_delete.keyword(), rel.on_update.keyword(), ]) @@ -9893,8 +10025,8 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) .filter(|r| r.child_table == table.name) .map(|r| ReadForeignKey { parent_table: r.parent_table.clone(), - parent_column: r.parent_column.clone(), - child_column: r.child_column.clone(), + parent_columns: r.parent_columns.clone(), + child_columns: r.child_columns.clone(), on_delete: r.on_delete, on_update: r.on_update, }) @@ -10547,9 +10679,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -10907,9 +11039,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -10934,7 +11066,7 @@ mod tests { let outbound = orders .outbound_relationships .iter() - .find(|r| r.local_column == "buyer_id"); + .find(|r| r.local_columns == vec!["buyer_id".to_string()]); assert!( outbound.is_some(), "expected outbound rel on `buyer_id`, got {:?}", @@ -10949,7 +11081,7 @@ mod tests { let inbound = customers .inbound_relationships .iter() - .find(|r| r.other_column == "buyer_id"); + .find(|r| r.other_columns == vec!["buyer_id".to_string()]); assert!( inbound.is_some(), "expected inbound rel referencing `buyer_id`, got {:?}", @@ -11067,9 +11199,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11111,9 +11243,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11490,9 +11622,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11799,9 +11931,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "cust_id".to_string(), + vec!["cust_id".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11883,9 +12015,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11895,9 +12027,9 @@ mod tests { let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); assert_eq!(orders.outbound_relationships.len(), 1); let rel = &orders.outbound_relationships[0]; - assert_eq!(rel.local_column, "CustId"); + assert_eq!(rel.local_columns, vec!["CustId".to_string()]); assert_eq!(rel.other_table, "Customers"); - assert_eq!(rel.other_column, "id"); + assert_eq!(rel.other_columns, vec!["id".to_string()]); assert_eq!(rel.name, "Customers_id_to_Orders_CustId"); } @@ -11908,9 +12040,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -11920,9 +12052,9 @@ mod tests { let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); assert_eq!(customers.inbound_relationships.len(), 1); let rel = &customers.inbound_relationships[0]; - assert_eq!(rel.local_column, "id"); + assert_eq!(rel.local_columns, vec!["id".to_string()]); assert_eq!(rel.other_table, "Orders"); - assert_eq!(rel.other_column, "CustId"); + assert_eq!(rel.other_columns, vec!["CustId".to_string()]); } #[tokio::test] @@ -11932,9 +12064,9 @@ mod tests { db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::SetNull, false, @@ -11970,9 +12102,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, true, // --create-fk @@ -12013,9 +12145,9 @@ mod tests { .add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12051,9 +12183,9 @@ mod tests { .add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12094,9 +12226,9 @@ mod tests { .add_relationship( None, "Customers".to_string(), - "Name".to_string(), + vec!["Name".to_string()], "Orders".to_string(), - "CustName".to_string(), + vec!["CustName".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12104,7 +12236,7 @@ mod tests { .await .unwrap_err(); match err { - DbError::Unsupported(msg) => assert!(msg.contains("not a primary key"), "{msg}"), + DbError::Unsupported(msg) => assert!(msg.contains("primary key"), "{msg}"), other => panic!("unexpected error: {other:?}"), } } @@ -12116,9 +12248,9 @@ mod tests { db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12143,9 +12275,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12171,9 +12303,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12196,9 +12328,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12222,9 +12354,9 @@ mod tests { db.add_relationship( Some("dup".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12235,9 +12367,9 @@ mod tests { .add_relationship( Some("dup".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "OtherCust".to_string(), + vec!["OtherCust".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -12262,9 +12394,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -13492,9 +13624,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -13535,9 +13667,9 @@ mod tests { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -13590,9 +13722,9 @@ mod tests { db.add_relationship( Some("parent_of".to_string()), "T".to_string(), - "id".to_string(), + vec!["id".to_string()], "T".to_string(), - "ParentId".to_string(), + vec!["ParentId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -14102,9 +14234,9 @@ mod tests { db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], crate::dsl::ReferentialAction::NoAction, crate::dsl::ReferentialAction::NoAction, false, diff --git a/src/dsl/command.rs b/src/dsl/command.rs index ed2d85f..a5b6b07 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -29,15 +29,20 @@ pub struct SqlForeignKey { /// FK or an unnamed table FK (auto-named at execution per /// ADR-0013). pub name: Option, - /// The column in the table being created that holds the FK. - pub child_column: String, + /// The column(s) in the table being created that hold the FK. + /// One element for a single-column FK; ordered list for a + /// compound FK (ADR-0043). Positionally paired with + /// `parent_columns`. + pub child_columns: Vec, /// The referenced (parent) table — may be the table being created /// (a self-referencing FK). pub parent_table: String, - /// The referenced parent column. `None` for the bare - /// `REFERENCES ` form, resolved at execution to the - /// parent's single-column primary key (ADR-0035 §4b, user-confirmed). - pub parent_column: Option, + /// The referenced parent column(s), positionally paired with + /// `child_columns`. `None` for the bare `REFERENCES ` + /// form, resolved at execution to the parent's primary key — + /// the single-column PK, or (ADR-0043 F-D) the full compound PK + /// when the child arity matches. + pub parent_columns: Option>, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, } @@ -253,9 +258,14 @@ pub enum Command { AddRelationship { name: Option, parent_table: String, - parent_column: String, + /// Parent (referenced) PK column(s); one element for a + /// single-column FK, ordered list for a compound FK + /// (ADR-0043). Positionally paired with `child_columns`. + parent_columns: Vec, child_table: String, - child_column: String, + /// Child (referencing) column(s), positionally paired with + /// `parent_columns`; equal, non-zero length. + child_columns: Vec, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, @@ -1032,11 +1042,26 @@ impl Command { match self { Self::AddRelationship { parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, .. - } => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"), + } => { + // `from P.col to C.col` (single) or `from P.(a, b) to + // C.(x, y)` (compound — ADR-0043), mirroring the DSL. + let fmt = |cols: &[String]| { + if cols.len() == 1 { + cols[0].clone() + } else { + format!("({})", cols.join(", ")) + } + }; + format!( + "from {parent_table}.{} to {child_table}.{}", + fmt(parent_columns), + fmt(child_columns), + ) + } Self::DropRelationship { selector } => match selector { RelationshipSelector::Named { name } => name.clone(), RelationshipSelector::Endpoints { diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 7c16ca8..1f37142 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -785,12 +785,24 @@ fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result { - assert_eq!(fk.child_column, "pid"); + assert_eq!(fk.child_columns, vec!["pid".to_string()]); assert_eq!(fk.parent_table, "P"); - assert_eq!(fk.parent_column.as_deref(), Some("id")); + assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); } other => panic!("expected ForeignKey, got {other:?}"), } @@ -3216,7 +3231,7 @@ mod sql_alter_table_tests { assert_eq!(name.as_deref(), Some("fk_p")); match *constraint { TableConstraint::ForeignKey(fk) => { - assert_eq!(fk.parent_column, None, "bare reference resolves at execution"); + assert_eq!(fk.parent_columns, None, "bare reference resolves at execution"); } other => panic!("expected ForeignKey, got {other:?}"), } diff --git a/src/dsl/grammar/sql_create_table.rs b/src/dsl/grammar/sql_create_table.rs index 0cfb9f7..2db970d 100644 --- a/src/dsl/grammar/sql_create_table.rs +++ b/src/dsl/grammar/sql_create_table.rs @@ -984,9 +984,9 @@ mod builder_tests { 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.child_columns, vec!["pid".to_string()]); assert_eq!(fk.parent_table, "parent"); - assert_eq!(fk.parent_column.as_deref(), Some("id")); + assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction); } @@ -994,9 +994,9 @@ mod builder_tests { #[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_columns, None, "bare REFERENCES — resolved at execution"); assert_eq!(fks[0].parent_table, "parent"); - assert_eq!(fks[0].child_column, "pid"); + assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); } #[test] @@ -1026,9 +1026,9 @@ mod builder_tests { 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].child_columns, vec!["pid".to_string()]); assert_eq!(fks[0].parent_table, "parent"); - assert_eq!(fks[0].parent_column.as_deref(), Some("id")); + assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()])); } #[test] @@ -1038,7 +1038,7 @@ mod builder_tests { 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"); + assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); } #[test] @@ -1048,8 +1048,8 @@ mod builder_tests { 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")); + assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p")); + assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q")); } #[test] @@ -1057,8 +1057,8 @@ mod builder_tests { 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")); + assert_eq!(fks[0].child_columns, vec!["mgr".to_string()]); + assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()])); } #[test] @@ -1080,7 +1080,7 @@ mod builder_tests { } => { assert_eq!(primary_key, vec!["id".to_string()]); assert_eq!(foreign_keys.len(), 1); - assert_eq!(foreign_keys[0].child_column, "pid"); + assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]); // the column-level CHECK still attaches to `pid` assert_eq!( columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(), diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 5b9ecab..2a1e650 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -937,9 +937,9 @@ mod tests { Command::AddRelationship { name: name.map(String::from), parent_table: parent.0.to_string(), - parent_column: parent.1.to_string(), + parent_columns: vec![parent.1.to_string()], child_table: child.0.to_string(), - child_column: child.1.to_string(), + child_columns: vec![child.1.to_string()], on_delete, on_update, create_fk, diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index e8dcd05..d22e750 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -3514,9 +3514,9 @@ mod tests { Command::AddRelationship { name: None, parent_table: "Customers".to_string(), - parent_column: "id".to_string(), + parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), - child_column: "customer_id".to_string(), + child_columns: vec!["customer_id".to_string()], on_delete: ReferentialAction::default_action(), on_update: ReferentialAction::default_action(), create_fk: false, @@ -3535,9 +3535,9 @@ mod tests { Command::AddRelationship { name: Some("cust_orders".to_string()), parent_table: "Customers".to_string(), - parent_column: "id".to_string(), + parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), - child_column: "customer_id".to_string(), + child_columns: vec!["customer_id".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::SetNull, create_fk: true, diff --git a/src/echo.rs b/src/echo.rs index 81d5eff..e320cb7 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -264,12 +264,16 @@ pub(crate) fn render_drop_index(name: &str) -> String { pub(crate) fn render_add_relationship( name: &str, parent_table: &str, - parent_column: &str, + parent_columns: &[String], child_table: &str, - child_column: &str, + child_columns: &[String], on_delete: ReferentialAction, on_update: ReferentialAction, ) -> String { + // Multi-column FK (ADR-0043): comma-join each side; a + // single-column FK is the one-element case. + let child_column = child_columns.join(", "); + let parent_column = parent_columns.join(", "); let mut s = format!( "ALTER TABLE {child_table} ADD CONSTRAINT {name} FOREIGN KEY ({child_column}) REFERENCES {parent_table} ({parent_column})" ); @@ -325,28 +329,31 @@ pub(crate) fn render_drop_column_cascade( pub(crate) fn render_add_relationship_create_fk( name: &str, parent_table: &str, - parent_column: &str, + parent_columns: &[String], child_table: &str, - child_column: &str, + child_columns: &[String], on_delete: ReferentialAction, on_update: ReferentialAction, - new_child_column_type: crate::dsl::types::Type, + // The child columns `--create-fk` newly creates, with their types + // (ADR-0043: one per missing column, typed to the matching parent + // PK column's `fk_target_type`). Columns that already existed are + // omitted — no `ADD COLUMN` line for them. + new_columns: &[(String, crate::dsl::types::Type)], ) -> Vec { - vec![ - format!( - "ALTER TABLE {child_table} ADD COLUMN {child_column} {}", - new_child_column_type.keyword() - ), - render_add_relationship( - name, - parent_table, - parent_column, - child_table, - child_column, - on_delete, - on_update, - ), - ] + let mut lines: Vec = new_columns + .iter() + .map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword())) + .collect(); + lines.push(render_add_relationship( + name, + parent_table, + parent_columns, + child_table, + child_columns, + on_delete, + on_update, + )); + lines } /// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint @@ -953,9 +960,9 @@ mod tests { let sql = render_add_relationship( "Orders_CustId_to_Customers_id", "Customers", - "id", + &["id".to_string()], "Orders", - "CustId", + &["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, ); @@ -971,9 +978,9 @@ mod tests { let sql = render_add_relationship( "places", "Customers", - "id", + &["id".to_string()], "Orders", - "CustId", + &["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::SetNull, ); @@ -1029,14 +1036,14 @@ mod tests { let lines = render_add_relationship_create_fk( "Customers_id_to_Orders_CustId", "Customers", - "id", + &["id".to_string()], "Orders", - "CustId", + &["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, // Parent PK is `serial` → child FK column is `int` // (`Type::fk_target_type` strips auto-gen semantics; ADR-0011). - crate::dsl::types::Type::Int, + &[("CustId".to_string(), crate::dsl::types::Type::Int)], ); assert_eq!( lines.as_slice(), @@ -1055,12 +1062,12 @@ mod tests { let lines = render_add_relationship_create_fk( "Items_code_to_Lines_code", "Items", - "code", + &["code".to_string()], "Lines", - "code", + &["code".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, - crate::dsl::types::Type::Text, + &[("code".to_string(), crate::dsl::types::Type::Text)], ); assert_eq!(lines[0], "ALTER TABLE Lines ADD COLUMN code text"); // No referential clauses when both default. diff --git a/src/output_render.rs b/src/output_render.rs index f318604..a3251b0 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -78,6 +78,16 @@ pub fn render_data_table(data: &DataResult) -> Vec { /// — `References:` / `Referenced by:` blocks below as plain /// indented text (relationship visualization is its own /// future ADR per §5 OOS-1). +/// Display a relationship-endpoint column list (ADR-0043): the bare +/// column for a single-column FK, `(a, b)` for a compound one. +fn cols_disp(cols: &[String]) -> String { + if cols.len() == 1 { + cols[0].clone() + } else { + format!("({})", cols.join(", ")) + } +} + #[must_use] pub fn render_structure(desc: &TableDescription) -> Vec { let mut out: Vec = Vec::new(); @@ -112,9 +122,9 @@ pub fn render_structure(desc: &TableDescription) -> Vec { for r in &desc.outbound_relationships { out.push(format!( " {} → {}.{} ({}, on delete {}, on update {})", - r.local_column, + cols_disp(&r.local_columns), r.other_table, - r.other_column, + cols_disp(&r.other_columns), r.name, r.on_delete, r.on_update, @@ -127,8 +137,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec { out.push(format!( " {}.{} → {} ({}, on delete {}, on update {})", r.other_table, - r.other_column, - r.local_column, + cols_disp(&r.other_columns), + cols_disp(&r.local_columns), r.name, r.on_delete, r.on_update, @@ -769,8 +779,8 @@ mod tests { inbound_relationships: vec![RelationshipEnd { name: "cust_orders".to_string(), other_table: "Orders".to_string(), - other_column: "cust_id".to_string(), - local_column: "id".to_string(), + other_columns: vec!["cust_id".to_string()], + local_columns: vec!["id".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index fb0be07..5197b4f 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -245,9 +245,13 @@ pub struct IndexSchema { pub struct RelationshipSchema { pub name: String, pub parent_table: String, - pub parent_column: String, + /// Parent PK column(s); one element for single-column, ordered + /// list for a compound-PK FK (ADR-0043). Paired positionally + /// with `child_columns`. + pub parent_columns: Vec, pub child_table: String, - pub child_column: String, + /// Child column(s), positionally paired with `parent_columns`. + pub child_columns: Vec, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, } diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index 4e72718..518a288 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -188,20 +188,31 @@ fn write_relationship(out: &mut String, rel: &RelationshipSchema) { let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name)); let _ = writeln!( out, - " parent: {{ table: {}, column: {} }}", + " parent: {{ table: {}, columns: [{}] }}", quote_if_needed(&rel.parent_table), - quote_if_needed(&rel.parent_column), + write_col_list(&rel.parent_columns), ); let _ = writeln!( out, - " child: {{ table: {}, column: {} }}", + " child: {{ table: {}, columns: [{}] }}", quote_if_needed(&rel.child_table), - quote_if_needed(&rel.child_column), + write_col_list(&rel.child_columns), ); let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete)); let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update)); } +/// Format a column list for an inline yaml flow sequence — `a, b` +/// (the caller wraps in `[…]`), each element quoted if needed. +/// Matches the `primary_key: [...]` / index `columns: [...]` house +/// style (ADR-0043 D5). One element for a single-column endpoint. +fn write_col_list(cols: &[String]) -> String { + cols.iter() + .map(|c| quote_if_needed(c)) + .collect::>() + .join(", ") +} + const fn action_keyword(action: ReferentialAction) -> &'static str { match action { ReferentialAction::NoAction => "no_action", @@ -309,9 +320,9 @@ pub(crate) fn parse_schema(body: &str) -> Result { relationships.push(RelationshipSchema { name: r.name, parent_table: r.parent.table, - parent_column: r.parent.column, + parent_columns: r.parent.columns, child_table: r.child.table, - child_column: r.child.column, + child_columns: r.child.columns, on_delete, on_update, }); @@ -502,7 +513,10 @@ struct RawRelationship { #[derive(Deserialize)] struct RawEndpoint { table: String, - column: String, + /// FK endpoint column list (ADR-0043): `columns: [a, b]`, one + /// element for a single-column endpoint — matching the + /// `primary_key` / index `columns` house style. + columns: Vec, } #[derive(Deserialize)] @@ -551,9 +565,9 @@ mod tests { relationships: vec![RelationshipSchema { name: "Customers_id_to_Orders_CustId".to_string(), parent_table: "Customers".to_string(), - parent_column: "id".to_string(), + parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), - child_column: "CustId".to_string(), + child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], @@ -578,8 +592,8 @@ mod tests { assert!(body.contains("{ name: id, type: serial }")); assert!(body.contains("{ name: Name, type: text }")); assert!(body.contains("- name: Customers_id_to_Orders_CustId")); - assert!(body.contains("parent: { table: Customers, column: id }")); - assert!(body.contains("child: { table: Orders, column: CustId }")); + assert!(body.contains("parent: { table: Customers, columns: [id] }")); + assert!(body.contains("child: { table: Orders, columns: [CustId] }")); assert!(body.contains("on_delete: cascade")); assert!(body.contains("on_update: no_action")); assert!(body.contains("- name: Orders_CustId_idx")); @@ -934,8 +948,8 @@ project: tables: [] relationships: - name: R - parent: { table: A, column: id } - child: { table: B, column: aid } + parent: { table: A, columns: [id] } + child: { table: B, columns: [aid] } on_delete: blow_up on_update: no_action "; diff --git a/src/runtime.rs b/src/runtime.rs index 0313d31..05543b7 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1591,16 +1591,16 @@ struct EchoLookups { /// teaching playground). drop_relationship: Option<(String, String)>, /// For `Command::AddRelationship { create_fk: true, .. }` — the - /// type of the child column the `--create-fk` flag will create, *if* - /// the column did not already exist (`Some(ty)` → newly created → - /// multi-line echo; `None` → already existed → single-line echo). - /// The type is derived from the parent's PK column type via - /// `Type::fk_target_type` (ADR-0011: `serial → int`, `shortid → - /// text`, others identity). The outer `Option` is `None` for - /// not-applicable commands (not a `--create-fk` add, or simple mode, - /// or a pre-execution lookup failed); the inner option encodes the - /// existed-vs-created distinction. - add_rel_create_fk_new_column_type: Option>, + /// child columns the `--create-fk` flag will newly create, each with + /// its type (ADR-0043: one per child column that did **not** already + /// exist, typed to the matching parent PK column's `fk_target_type` — + /// ADR-0011: `serial → int`, `shortid → text`, others identity). An + /// **empty** vec means every child column already existed → + /// single-line echo; a non-empty vec → multi-line (one `ADD COLUMN` + /// per element). The outer `Option` is `None` for not-applicable + /// commands (not a `--create-fk` add, simple mode, or a + /// pre-execution lookup failed). + add_rel_create_fk_new_columns: Option>, } /// Resolve drop-target names and `--create-fk` pre-state **before** @@ -1638,9 +1638,13 @@ async fn collect_echo_lookups( } => { if let Ok(desc) = database.describe_table(child_table.clone(), None).await && let Some(rel) = desc.outbound_relationships.iter().find(|r| { + // The Endpoints drop selector is single-column + // (ADR-0043 keeps DROP by-endpoints single-column; + // compound relationships drop by name) — match a + // one-column relationship by its sole columns. r.other_table == *parent_table - && r.other_column == *parent_column - && r.local_column == *child_column + && r.other_columns.as_slice() == std::slice::from_ref(parent_column) + && r.local_columns.as_slice() == std::slice::from_ref(child_column) }) { out.drop_relationship = Some((rel.name.clone(), child_table.clone())); @@ -1668,41 +1672,37 @@ async fn collect_echo_lookups( Command::AddRelationship { create_fk: true, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, .. } => { - // Two pre-state facts feed the multi-line `--create-fk` echo - // (ADR-0038 §7 Bucket B, category 2): whether the child - // column already exists (determines single- vs multi-line) - // and the parent PK column's user type (determines the - // newly-created child column's type via - // `Type::fk_target_type`). Both are looked up post-exec from - // the description for `add relationship` (no `--create-fk`), - // but the `--create-fk` multi-line case needs them *before* - // execution to know whether to emit an `ADD COLUMN` line. - let parent_pk_type = database - .describe_table(parent_table.clone(), None) - .await - .ok() - .and_then(|d| { - d.columns + // Pre-state for the multi-line `--create-fk` echo (ADR-0038 + // §7 Bucket B, category 2 / ADR-0043): the subset of child + // columns that do NOT already exist, each typed to the + // matching parent PK column's `fk_target_type`. Needed + // *before* execution to know which `ADD COLUMN` lines to + // emit. The parent columns here are the explicit DSL list, + // paired positionally with the child list. + let parent_desc = database.describe_table(parent_table.clone(), None).await; + let child_desc = database.describe_table(child_table.clone(), None).await; + if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) { + let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new(); + for (child_col, parent_col) in child_columns.iter().zip(parent_columns) { + let already = child_desc.columns.iter().any(|c| c.name == *child_col); + if already { + continue; + } + if let Some(parent_ty) = parent_desc + .columns .iter() - .find(|c| c.name == *parent_column) + .find(|c| c.name == *parent_col) .and_then(|c| c.user_type) - }); - let child_column_existed = database - .describe_table(child_table.clone(), None) - .await - .ok() - .map(|d| d.columns.iter().any(|c| c.name == *child_column)); - if let (Some(parent_ty), Some(existed)) = (parent_pk_type, child_column_existed) { - out.add_rel_create_fk_new_column_type = Some(if existed { - None - } else { - Some(parent_ty.fk_target_type()) - }); + { + new_columns.push((child_col.clone(), parent_ty.fk_target_type())); + } + } + out.add_rel_create_fk_new_columns = Some(new_columns); } } _ => {} @@ -1755,9 +1755,9 @@ fn build_schema_echo( Command::AddRelationship { name, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, create_fk, @@ -1766,57 +1766,55 @@ fn build_schema_echo( // relationships (target_table for AddRelationship is the // parent — `database.add_relationship` returns the parent's // description per ADR-0013), falling back to the command's - // explicit `name` when the description is unavailable. + // explicit `name` when the description is unavailable. Match + // on the column lists (ADR-0043), the child as `other` and + // the parent as `local` from the parent's perspective. let resolved = description .and_then(|d| { d.inbound_relationships.iter().find(|r| { r.other_table == *child_table - && r.other_column == *child_column - && r.local_column == *parent_column + && r.other_columns == *child_columns + && r.local_columns == *parent_columns }) }) .map(|r| r.name.clone()) .or_else(|| name.clone())?; if *create_fk { - // Multi-line iff the child column was newly created - // (`--create-fk`'s pre-state, captured pre-execution - // into `add_rel_create_fk_new_column_type`). When the - // column already existed the echo collapses to the - // single-line FK form — the SQL `ADD COLUMN` would be - // a no-op-with-error otherwise, and the catalogue is - // explicit: "one line if the column already existed". - Some(lookups.add_rel_create_fk_new_column_type?.map_or_else( - || { - vec![crate::echo::render_add_relationship( - &resolved, - parent_table, - parent_column, - child_table, - child_column, - *on_delete, - *on_update, - )] - }, - |new_ty| { - crate::echo::render_add_relationship_create_fk( - &resolved, - parent_table, - parent_column, - child_table, - child_column, - *on_delete, - *on_update, - new_ty, - ) - }, - )) + // The pre-execution lookup captured which child columns + // `--create-fk` newly creates (ADR-0043). An empty list + // → every column existed → single-line FK echo; a + // non-empty list → one `ADD COLUMN` per new column then + // the FK line. + let new_columns = lookups.add_rel_create_fk_new_columns.as_ref()?; + if new_columns.is_empty() { + Some(vec![crate::echo::render_add_relationship( + &resolved, + parent_table, + parent_columns, + child_table, + child_columns, + *on_delete, + *on_update, + )]) + } else { + Some(crate::echo::render_add_relationship_create_fk( + &resolved, + parent_table, + parent_columns, + child_table, + child_columns, + *on_delete, + *on_update, + new_columns, + )) + } } else { Some(vec![crate::echo::render_add_relationship( &resolved, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, *on_delete, *on_update, )]) @@ -2013,17 +2011,19 @@ async fn enrich_fk_violation( }; facts.table = Some(table.clone()); for rel in outbound { - let value = user_value_for_column_with_schema( - database, - command, - table, - &rel.local_column, - ) - .await; + // The friendly FK-error facts model is single-column + // (ADR-0019); for a compound FK (ADR-0043) we enrich + // from the first column pair — the error still surfaces, + // richer multi-column enrichment is a later refinement. + let Some(local_col) = rel.local_columns.first().cloned() else { + continue; + }; + let value = + user_value_for_column_with_schema(database, command, table, &local_col).await; if let Some(v) = value { - facts.column = Some(rel.local_column); + facts.column = Some(local_col); facts.parent_table = Some(rel.other_table); - facts.parent_column = Some(rel.other_column); + facts.parent_column = rel.other_columns.into_iter().next(); facts.value = Some(v.to_string()); break; } @@ -2615,9 +2615,9 @@ async fn execute_command_typed( Command::AddRelationship { name, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, create_fk, @@ -2625,9 +2625,9 @@ async fn execute_command_typed( .add_relationship( name, parent_table, - parent_column, + parent_columns, child_table, - child_column, + child_columns, on_delete, on_update, create_fk, @@ -3193,9 +3193,9 @@ mod tests { .add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -3206,9 +3206,9 @@ mod tests { let add_rel_cmd = Command::AddRelationship { name: None, parent_table: "Customers".to_string(), - parent_column: "id".to_string(), + parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), - child_column: "CustId".to_string(), + child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: false, @@ -3366,9 +3366,9 @@ mod tests { let add_fk_cmd = Command::AddRelationship { name: None, parent_table: "Customers".to_string(), - parent_column: "id".to_string(), + parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), - child_column: "CustId".to_string(), + child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: true, @@ -3378,17 +3378,17 @@ mod tests { let pre_lookups = super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await; assert_eq!( - pre_lookups.add_rel_create_fk_new_column_type, - Some(Some(Type::Int)), + pre_lookups.add_rel_create_fk_new_columns, + Some(vec![("CustId".to_string(), Type::Int)]), "pre-exec captures `serial → int` for the newly-created child column", ); let parent_desc = db .add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, true, @@ -3435,17 +3435,17 @@ mod tests { let pre_lookups = super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await; assert_eq!( - pre_lookups.add_rel_create_fk_new_column_type, - Some(None), + pre_lookups.add_rel_create_fk_new_columns, + Some(vec![]), "pre-exec records the child column already existed → single-line echo", ); let parent_desc = db .add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, true, diff --git a/tests/it/column_op_guards.rs b/tests/it/column_op_guards.rs index 9fb0714..bc9f43e 100644 --- a/tests/it/column_op_guards.rs +++ b/tests/it/column_op_guards.rs @@ -132,9 +132,9 @@ fn add_relationship_refuses_internal_tables() { .block_on(db.add_relationship( None, internal.clone(), - "name".to_string(), + vec!["name".to_string()], "C".to_string(), - "x".to_string(), + vec!["x".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -161,9 +161,9 @@ fn add_relationship_refuses_internal_tables() { .block_on(db.add_relationship( None, "P".to_string(), - "id".to_string(), + vec!["id".to_string()], internal, - "x".to_string(), + vec!["x".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, diff --git a/tests/it/friendly_enrichment.rs b/tests/it/friendly_enrichment.rs index f726952..ff289c9 100644 --- a/tests/it/friendly_enrichment.rs +++ b/tests/it/friendly_enrichment.rs @@ -420,9 +420,9 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -496,9 +496,9 @@ fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, @@ -570,9 +570,9 @@ fn enrich_fk_delete_resolves_child_table() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, ReferentialAction::NoAction, false, diff --git a/tests/it/iteration2_persistence.rs b/tests/it/iteration2_persistence.rs index 9a66f99..22b70f1 100644 --- a/tests/it/iteration2_persistence.rs +++ b/tests/it/iteration2_persistence.rs @@ -192,9 +192,9 @@ fn delete_with_cascade_rewrites_both_csvs() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -424,9 +424,9 @@ fn project_yaml_carries_relationship_after_add() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, diff --git a/tests/it/iteration3_rebuild.rs b/tests/it/iteration3_rebuild.rs index 40cea86..4361dfb 100644 --- a/tests/it/iteration3_rebuild.rs +++ b/tests/it/iteration3_rebuild.rs @@ -185,9 +185,9 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { db.add_relationship( None, "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index 384572d..ea6f31b 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -144,9 +144,9 @@ async fn seed_schema(db: &Database) { db.add_relationship( Some("orders_customer".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "customer_id".to_string(), + vec!["customer_id".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index a4d48d0..2d0b097 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -834,9 +834,9 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() { fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> SqlForeignKey { SqlForeignKey { name: None, - child_column: child_column.to_string(), + child_columns: vec![child_column.to_string()], parent_table: parent_table.to_string(), - parent_column: parent_column.map(str::to_string), + parent_columns: parent_column.map(|c| vec![c.to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, } @@ -929,7 +929,7 @@ fn foreign_key_creates_named_relationship_visible_in_describe() { let rel = &child.outbound_relationships[0]; assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013"); assert_eq!(rel.other_table, "parent"); - assert_eq!(rel.local_column, "pid"); + assert_eq!(rel.local_columns, vec!["pid".to_string()]); let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child"); @@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() { )) .expect("create child with bare REFERENCES"); let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); - assert_eq!(child.outbound_relationships[0].other_column, "id", "resolved to parent PK"); + assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK"); } #[test] @@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() { )) .expect("create self-referential emp with a bare reference"); let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe"); - assert_eq!(emp.outbound_relationships[0].other_column, "id", "bare self-ref resolved to own PK"); + assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK"); // Enforced: a non-existent manager is rejected. r.block_on(db.insert( "emp".to_string(), diff --git a/tests/it/sql_delete.rs b/tests/it/sql_delete.rs index ab64307..43f3a23 100644 --- a/tests/it/sql_delete.rs +++ b/tests/it/sql_delete.rs @@ -100,9 +100,9 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -289,9 +289,9 @@ fn cascade_to_two_children_reports_both() { rt.block_on(db.add_relationship( Some(name.to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], child.to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, @@ -358,9 +358,9 @@ fn delete_violating_fk_fails_and_persists_nothing() { rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::NoAction, // on delete: reject if referenced ReferentialAction::NoAction, false, @@ -395,9 +395,9 @@ fn self_referential_cascade_counts_only_cascaded_rows() { rt.block_on(db.add_relationship( Some("parent_of".to_string()), "T".to_string(), - "id".to_string(), + vec!["id".to_string()], "T".to_string(), - "ParentId".to_string(), + vec!["ParentId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, diff --git a/tests/it/sql_dml_e2e.rs b/tests/it/sql_dml_e2e.rs index 844a053..313d643 100644 --- a/tests/it/sql_dml_e2e.rs +++ b/tests/it/sql_dml_e2e.rs @@ -318,9 +318,9 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), - "id".to_string(), + vec!["id".to_string()], "Orders".to_string(), - "CustId".to_string(), + vec!["CustId".to_string()], ReferentialAction::Cascade, ReferentialAction::NoAction, false, diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index 2c7f578..b71be7a 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -104,9 +104,9 @@ fn dropping_a_referenced_parent_is_refused() { vec![], vec![SqlForeignKey { name: None, - child_column: "pid".to_string(), + child_columns: vec!["pid".to_string()], parent_table: "parent".to_string(), - parent_column: Some("id".to_string()), + parent_columns: Some(vec!["id".to_string()]), on_delete: rdbms_playground::dsl::ReferentialAction::NoAction, on_update: rdbms_playground::dsl::ReferentialAction::NoAction, }], diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index db442d2..2be6ea4 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -420,9 +420,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { &Command::AddRelationship { name: None, parent_table: "Customers".to_string(), - parent_column: "Id".to_string(), + parent_columns: vec!["Id".to_string()], child_table: "Orders".to_string(), - child_column: "CustId".to_string(), + child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: false, @@ -449,8 +449,8 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { inbound_relationships: vec![RelationshipEnd { name: "Customers_Id_to_Orders_CustId".to_string(), other_table: "Orders".to_string(), - other_column: "CustId".to_string(), - local_column: "Id".to_string(), + other_columns: vec!["CustId".to_string()], + local_columns: vec!["Id".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], @@ -462,9 +462,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { command: Command::AddRelationship { name: None, parent_table: "Customers".to_string(), - parent_column: "Id".to_string(), + parent_columns: vec!["Id".to_string()], child_table: "Orders".to_string(), - child_column: "CustId".to_string(), + child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: false, @@ -504,8 +504,8 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { inbound_relationships: vec![RelationshipEnd { name: "Customers_Id_to_Orders_CustId".to_string(), other_table: "Orders".to_string(), - other_column: "CustId".to_string(), - local_column: "Id".to_string(), + other_columns: vec!["CustId".to_string()], + local_columns: vec!["Id".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }],