diff --git a/.gitattributes b/.gitattributes index a40ae5fa27..34e66924bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -44,3 +44,6 @@ crates/dbx-core/assets/docs-export.css -text -diff linguist-generated # parser must not depend on the contributor's core.autocrlf: a CRLF checkout # silently rewrites all three files. src-tauri/tests/fixtures/** text eol=lf + +# Preserve the upstream SQL attachment bytes used by the live MySQL regression. +crates/dbx-core/tests/fixtures/qrtz.sql -text diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts index 294ac1a692..26de04618a 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { convertToSchemaDiffObjects, normalizeSchemaDiffDependencyGraph, schemaDiffObjectSelectionState, selectSchemaDiffInput, selectSchemaDiffInputForObject, setSchemaDiffObjectSelected, type SchemaDiffPreparation } from "../../schema/schemaDiff"; +import { convertToSchemaDiffObjects, normalizeSchemaDiffDependencyGraph, schemaDiffObjectSelectionState, selectSchemaDiffInput, selectSchemaDiffInputForObject, setSchemaDiffObjectSelected, setSchemaDiffObjectSelectedWithDependencies, type SchemaDiffPreparation } from "../../schema/schemaDiff"; function createPreparation(): SchemaDiffPreparation { return { @@ -50,6 +50,68 @@ function createDuplicateForeignKeyPreparation(): SchemaDiffPreparation { } describe("schema diff SQL selection projections", () => { + it("keeps every composite foreign key column in the selection dependency closure", () => { + const result = createPreparation(); + result.diffs[0]!.foreignKeys = [ + { + type: "added", + name: "fk_pair", + source: { + name: "fk_pair", + column: "a_col, a_other_col", + ref_table: "parent", + ref_column: "id, code", + column_pairs: [ + ["a_col", "id"], + ["a_other_col", "code"], + ], + }, + }, + ]; + const objects = convertToSchemaDiffObjects(result.diffs); + const foreignKey = objects[0]!.children!.find((child) => child.objectKind === "foreignKey")!; + for (const column of ["a_col", "a_other_col"]) { + setSchemaDiffObjectSelectedWithDependencies(objects, result, `col-table_a-${column}`, false); + expect(foreignKey.selected).toBe(false); + setSchemaDiffObjectSelectedWithDependencies(objects, result, foreignKey.id, true); + expect(objects[0]!.children!.filter((child) => child.objectKind === "column").every((child) => child.selected)).toBe(true); + expect(selectSchemaDiffInput(result, objects).diffs[0]!.foreignKeys![0]!.source!.column_pairs).toEqual([ + ["a_col", "id"], + ["a_other_col", "code"], + ]); + } + }); + + it("selects a composite foreign key drop before removing either local column", () => { + const result = createPreparation(); + result.diffs[0]!.columns!.forEach((column) => { + column.type = "removed"; + }); + result.diffs[0]!.foreignKeys = [ + { + type: "removed", + name: "fk_pair", + target: { + name: "fk_pair", + column: "a_col, a_other_col", + ref_table: "parent", + ref_column: "id, code", + column_pairs: [ + ["a_col", "id"], + ["a_other_col", "code"], + ], + }, + }, + ]; + const objects = convertToSchemaDiffObjects(result.diffs); + const foreignKey = objects[0]!.children!.find((child) => child.objectKind === "foreignKey")!; + for (const column of ["a_col", "a_other_col"]) { + setSchemaDiffObjectSelected(objects, foreignKey.id, false); + setSchemaDiffObjectSelectedWithDependencies(objects, result, `col-table_a-${column}`, true); + expect(foreignKey.selected).toBe(true); + } + }); + it("keeps the focused table SQL separate from the full selected SQL", () => { const result = createPreparation(); const objects = convertToSchemaDiffObjects(result.diffs); diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index e4ff8373fc..cd8a04a8ff 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -144,11 +144,21 @@ export interface IndexDiff { changes?: string[]; } +export interface SchemaForeignKeyInfo extends ForeignKeyInfo { + /** Ordered local/reference pairs; scalar fields remain display labels. */ + column_pairs?: [string, string][]; +} + +function foreignKeyColumns(foreignKey?: SchemaForeignKeyInfo): string[] { + if (!foreignKey) return []; + return foreignKey.column_pairs?.length ? foreignKey.column_pairs.map(([column]) => column) : [foreignKey.column]; +} + export interface ForeignKeyDiff { type: "added" | "removed" | "modified"; name: string; - source?: ForeignKeyInfo; - target?: ForeignKeyInfo; + source?: SchemaForeignKeyInfo; + target?: SchemaForeignKeyInfo; changes?: string[]; } @@ -1198,7 +1208,7 @@ export function setSchemaDiffObjectSelectedWithDependencies(objects: SchemaDiffO if (indexObject) apply(indexObject.id, true); } for (const [index, foreignKey] of (tableDiff.foreignKeys ?? []).entries()) { - if (!["removed", "modified"].includes(foreignKey.type) || foreignKey.target?.column !== columnDiff.name) continue; + if (!["removed", "modified"].includes(foreignKey.type) || !foreignKeyColumns(foreignKey.target).includes(columnDiff.name)) continue; const foreignKeyObject = child("foreignKey", tableDiff.foreignKeys, index); if (foreignKeyObject) apply(foreignKeyObject.id, true); } @@ -1217,7 +1227,7 @@ export function setSchemaDiffObjectSelectedWithDependencies(objects: SchemaDiffO if (indexObject) apply(indexObject.id, false); } for (const [index, foreignKey] of (tableDiff.foreignKeys ?? []).entries()) { - if (!["added", "modified"].includes(foreignKey.type) || foreignKey.source?.column !== columnDiff.name) continue; + if (!["added", "modified"].includes(foreignKey.type) || !foreignKeyColumns(foreignKey.source).includes(columnDiff.name)) continue; const foreignKeyObject = child("foreignKey", tableDiff.foreignKeys, index); if (foreignKeyObject) apply(foreignKeyObject.id, false); } @@ -1235,7 +1245,7 @@ export function setSchemaDiffObjectSelectedWithDependencies(objects: SchemaDiffO if (value && object.objectKind === "foreignKey") { const foreignKeyIndex = findDiffChildIndex("foreignKey", tableObject.name, tableDiff.foreignKeys, object.id); const foreignKey = foreignKeyIndex < 0 ? undefined : tableDiff.foreignKeys?.[foreignKeyIndex]; - if (foreignKey?.source?.column) applyColumn(foreignKey.source.column, true); + for (const column of foreignKeyColumns(foreignKey?.source)) applyColumn(column, true); } }; diff --git a/crates/dbx-core/tests/fixtures/qrtz.README.md b/crates/dbx-core/tests/fixtures/qrtz.README.md new file mode 100644 index 0000000000..73b0e3dfed --- /dev/null +++ b/crates/dbx-core/tests/fixtures/qrtz.README.md @@ -0,0 +1,10 @@ +`qrtz.sql` is the unchanged original attachment from DBX issue #11245: + +https://github.com/user-attachments/files/33192166/qrtz.sql + +SHA256: `d2c42011bbbc1ab8a5429f04cafd4e8756c79e551f38b1563095719f42b1df21` + +It creates eleven Quartz tables and five three-column foreign keys. It contains +no `USE`, `DROP`, explicit database names or data modifications. Tests select +their newly created disposable database before executing it; table definitions, +constraint definitions and statement order are preserved. diff --git a/crates/dbx-core/tests/fixtures/qrtz.sql b/crates/dbx-core/tests/fixtures/qrtz.sql new file mode 100644 index 0000000000..9349dcda0b --- /dev/null +++ b/crates/dbx-core/tests/fixtures/qrtz.sql @@ -0,0 +1,137 @@ +-- Database export: syagent +-- Date: 2026-10-08 15:11:34 +-- Generated by DBX + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE `qrtz_blob_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_name` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_name的外键', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + `blob_data` blob COMMENT '存放持久化Trigger对象', + PRIMARY KEY (`sched_name`,`trigger_name`,`trigger_group`) USING BTREE, + CONSTRAINT `qrtz_blob_triggers_ibfk_1` FOREIGN KEY (`sched_name`, `trigger_name`, `trigger_group`) REFERENCES `qrtz_triggers` (`sched_name`, `trigger_name`, `trigger_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='Blob类型的触发器表'; + +CREATE TABLE `qrtz_calendars` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `calendar_name` varchar(200) NOT NULL COMMENT '日历名称', + `calendar` blob NOT NULL COMMENT '存放持久化calendar对象', + PRIMARY KEY (`sched_name`,`calendar_name`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='日历信息表'; + +CREATE TABLE `qrtz_cron_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_name` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_name的外键', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + `cron_expression` varchar(200) NOT NULL COMMENT 'cron表达式', + `time_zone_id` varchar(80) DEFAULT NULL COMMENT '时区', + PRIMARY KEY (`sched_name`,`trigger_name`,`trigger_group`) USING BTREE, + CONSTRAINT `qrtz_cron_triggers_ibfk_1` FOREIGN KEY (`sched_name`, `trigger_name`, `trigger_group`) REFERENCES `qrtz_triggers` (`sched_name`, `trigger_name`, `trigger_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='Cron类型的触发器表'; + +CREATE TABLE `qrtz_fired_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `entry_id` varchar(95) NOT NULL COMMENT '调度器实例id', + `trigger_name` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_name的外键', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + `instance_name` varchar(200) NOT NULL COMMENT '调度器实例名', + `fired_time` bigint(20) NOT NULL COMMENT '触发的时间', + `sched_time` bigint(20) NOT NULL COMMENT '定时器制定的时间', + `priority` int(11) NOT NULL COMMENT '优先级', + `state` varchar(16) NOT NULL COMMENT '状态', + `job_name` varchar(200) DEFAULT NULL COMMENT '任务名称', + `job_group` varchar(200) DEFAULT NULL COMMENT '任务组名', + `is_nonconcurrent` varchar(1) DEFAULT NULL COMMENT '是否并发', + `requests_recovery` varchar(1) DEFAULT NULL COMMENT '是否接受恢复执行', + PRIMARY KEY (`sched_name`,`entry_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='已触发的触发器表'; + +CREATE TABLE `qrtz_job_details` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `job_name` varchar(200) NOT NULL COMMENT '任务名称', + `job_group` varchar(200) NOT NULL COMMENT '任务组名', + `description` varchar(250) DEFAULT NULL COMMENT '相关介绍', + `job_class_name` varchar(250) NOT NULL COMMENT '执行任务类名称', + `is_durable` varchar(1) NOT NULL COMMENT '是否持久化', + `is_nonconcurrent` varchar(1) NOT NULL COMMENT '是否并发', + `is_update_data` varchar(1) NOT NULL COMMENT '是否更新数据', + `requests_recovery` varchar(1) NOT NULL COMMENT '是否接受恢复执行', + `job_data` blob COMMENT '存放持久化job对象', + PRIMARY KEY (`sched_name`,`job_name`,`job_group`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='任务详细信息表'; + +CREATE TABLE `qrtz_locks` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `lock_name` varchar(40) NOT NULL COMMENT '悲观锁名称', + PRIMARY KEY (`sched_name`,`lock_name`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='存储的悲观锁信息表'; + +CREATE TABLE `qrtz_paused_trigger_grps` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + PRIMARY KEY (`sched_name`,`trigger_group`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='暂停的触发器表'; + +CREATE TABLE `qrtz_scheduler_state` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `instance_name` varchar(200) NOT NULL COMMENT '实例名称', + `last_checkin_time` bigint(20) NOT NULL COMMENT '上次检查时间', + `checkin_interval` bigint(20) NOT NULL COMMENT '检查间隔时间', + PRIMARY KEY (`sched_name`,`instance_name`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='调度器状态表'; + +CREATE TABLE `qrtz_simple_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_name` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_name的外键', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + `repeat_count` bigint(20) NOT NULL COMMENT '重复的次数统计', + `repeat_interval` bigint(20) NOT NULL COMMENT '重复的间隔时间', + `times_triggered` bigint(20) NOT NULL COMMENT '已经触发的次数', + PRIMARY KEY (`sched_name`,`trigger_name`,`trigger_group`) USING BTREE, + CONSTRAINT `qrtz_simple_triggers_ibfk_1` FOREIGN KEY (`sched_name`, `trigger_name`, `trigger_group`) REFERENCES `qrtz_triggers` (`sched_name`, `trigger_name`, `trigger_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='简单触发器的信息表'; + +CREATE TABLE `qrtz_simprop_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_name` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_name的外键', + `trigger_group` varchar(200) NOT NULL COMMENT 'qrtz_triggers表trigger_group的外键', + `str_prop_1` varchar(512) DEFAULT NULL COMMENT 'String类型的trigger的第一个参数', + `str_prop_2` varchar(512) DEFAULT NULL COMMENT 'String类型的trigger的第二个参数', + `str_prop_3` varchar(512) DEFAULT NULL COMMENT 'String类型的trigger的第三个参数', + `int_prop_1` int(11) DEFAULT NULL COMMENT 'int类型的trigger的第一个参数', + `int_prop_2` int(11) DEFAULT NULL COMMENT 'int类型的trigger的第二个参数', + `long_prop_1` bigint(20) DEFAULT NULL COMMENT 'long类型的trigger的第一个参数', + `long_prop_2` bigint(20) DEFAULT NULL COMMENT 'long类型的trigger的第二个参数', + `dec_prop_1` decimal(13,4) DEFAULT NULL COMMENT 'decimal类型的trigger的第一个参数', + `dec_prop_2` decimal(13,4) DEFAULT NULL COMMENT 'decimal类型的trigger的第二个参数', + `bool_prop_1` varchar(1) DEFAULT NULL COMMENT 'Boolean类型的trigger的第一个参数', + `bool_prop_2` varchar(1) DEFAULT NULL COMMENT 'Boolean类型的trigger的第二个参数', + PRIMARY KEY (`sched_name`,`trigger_name`,`trigger_group`) USING BTREE, + CONSTRAINT `qrtz_simprop_triggers_ibfk_1` FOREIGN KEY (`sched_name`, `trigger_name`, `trigger_group`) REFERENCES `qrtz_triggers` (`sched_name`, `trigger_name`, `trigger_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='同步机制的行锁表'; + +CREATE TABLE `qrtz_triggers` ( + `sched_name` varchar(120) NOT NULL COMMENT '调度名称', + `trigger_name` varchar(200) NOT NULL COMMENT '触发器的名字', + `trigger_group` varchar(200) NOT NULL COMMENT '触发器所属组的名字', + `job_name` varchar(200) NOT NULL COMMENT 'qrtz_job_details表job_name的外键', + `job_group` varchar(200) NOT NULL COMMENT 'qrtz_job_details表job_group的外键', + `description` varchar(250) DEFAULT NULL COMMENT '相关介绍', + `next_fire_time` bigint(20) DEFAULT NULL COMMENT '上一次触发时间(毫秒)', + `prev_fire_time` bigint(20) DEFAULT NULL COMMENT '下一次触发时间(默认为-1表示不触发)', + `priority` int(11) DEFAULT NULL COMMENT '优先级', + `trigger_state` varchar(16) NOT NULL COMMENT '触发器状态', + `trigger_type` varchar(8) NOT NULL COMMENT '触发器的类型', + `start_time` bigint(20) NOT NULL COMMENT '开始时间', + `end_time` bigint(20) DEFAULT NULL COMMENT '结束时间', + `calendar_name` varchar(200) DEFAULT NULL COMMENT '日程表名称', + `misfire_instr` smallint(6) DEFAULT NULL COMMENT '补偿执行的策略', + `job_data` blob COMMENT '存放持久化job对象', + PRIMARY KEY (`sched_name`,`trigger_name`,`trigger_group`) USING BTREE, + KEY `sched_name` (`sched_name`,`job_name`,`job_group`) USING BTREE, + CONSTRAINT `qrtz_triggers_ibfk_1` FOREIGN KEY (`sched_name`, `job_name`, `job_group`) REFERENCES `qrtz_job_details` (`sched_name`, `job_name`, `job_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='触发器详细信息表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/crates/dbx-core/tests/live_mysql_composite_foreign_keys.rs b/crates/dbx-core/tests/live_mysql_composite_foreign_keys.rs new file mode 100644 index 0000000000..8458f5b066 --- /dev/null +++ b/crates/dbx-core/tests/live_mysql_composite_foreign_keys.rs @@ -0,0 +1,119 @@ +//! Run with DBX_LIVE_MYSQL_URL pointing to a disposable MySQL 5.7 or 8.4 instance. +//! Creates unique databases and drops only those created by this test. +use dbx_core::db::mysql::{self, MySqlPool}; +use dbx_core::models::connection::DatabaseType; +use dbx_core::schema_diff::{ + prepare_schema_diff, SchemaDiffPreparation, SchemaDiffPreparationOptions, TableSchemaDetail, +}; +use mysql_async::prelude::Queryable; +use std::time::Duration; + +async fn compare(pool: &MySqlPool, source: &str, target: &str) -> Result { + let source_tables = mysql::list_tables(pool, source).await?; + let target_tables = mysql::list_tables(pool, target).await?; + let mut sides = Vec::new(); + for (database, tables) in [(source, &source_tables), (target, &target_tables)] { + let mut details = Vec::new(); + for table in tables { + details.push(TableSchemaDetail { + name: table.name.clone(), + columns: mysql::get_columns(pool, database, &table.name).await?, + indexes: mysql::list_indexes(pool, database, &table.name).await?, + foreign_keys: mysql::list_foreign_keys(pool, database, &table.name).await?, + triggers: mysql::list_triggers(pool, database, &table.name).await?, + ddl: Some(mysql::show_create_table_ddl(pool, database, &table.name).await?), + }); + } + sides.push(details); + } + let target_details = sides.pop().unwrap(); + let source_details = sides.pop().unwrap(); + Ok(prepare_schema_diff(SchemaDiffPreparationOptions { + source_tables, + target_tables, + source_details, + target_details, + database_type: DatabaseType::Mysql, + target_schema: Some(target.into()), + ..Default::default() + })) +} + +#[tokio::test] +#[ignore = "requires DBX_LIVE_MYSQL_URL on a disposable MySQL 5.7 or 8.4 instance"] +async fn original_quartz_composite_foreign_keys_round_trip() -> Result<(), String> { + let url = std::env::var("DBX_LIVE_MYSQL_URL").expect("set DBX_LIVE_MYSQL_URL to a disposable instance"); + let pool = mysql::connect(&url, Duration::from_secs(10)).await?; + let mut conn = pool.get_conn().await.map_err(|error| error.to_string())?; + let version: String = conn.query_first("SELECT VERSION()").await.map_err(|e| e.to_string())?.unwrap(); + assert!(version.starts_with("5.7.") || version.starts_with("8.4."), "unsupported test version: {version}"); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let source = format!("dbx_fk_source_{suffix}"); + let target = format!("dbx_fk_target_{suffix}"); + let mut created = Vec::new(); + let result: Result<(), String> = async { + for database in [&source, &target] { + conn.query_drop(format!("CREATE DATABASE `{database}`")).await.map_err(|e| e.to_string())?; + created.push(database.clone()); + conn.query_drop(format!("USE `{database}`")).await.map_err(|e| e.to_string())?; + conn.query_drop(include_str!("fixtures/qrtz.sql")).await.map_err(|e| e.to_string())?; + } + // Native driver rows must remain column pairs for navigation and other consumers. + let rows = mysql::list_foreign_keys(&pool, &source, "qrtz_blob_triggers").await?; + if rows.iter().map(|fk| (fk.column.as_str(), fk.ref_column.as_str())).collect::>() + != vec![("sched_name", "sched_name"), ("trigger_name", "trigger_name"), ("trigger_group", "trigger_group")] + { + return Err(format!("driver lost ordered pairs: {rows:?}")); + } + for _ in 0..3 { + for other in [&source, &target] { + let prepared = compare(&pool, &source, other).await?; + if !prepared.diffs.is_empty() || !prepared.sync_sql.trim().is_empty() { + return Err(format!( + "equivalent schema falsely differs: {}", + serde_json::to_string(&prepared).unwrap() + )); + } + } + } + conn.query_drop(format!( + "ALTER TABLE `{target}`.`qrtz_blob_triggers` DROP FOREIGN KEY `qrtz_blob_triggers_ibfk_1`" + )).await.map_err(|e| e.to_string())?; + conn.query_drop(format!( + "ALTER TABLE `{target}`.`qrtz_blob_triggers` ADD CONSTRAINT `qrtz_blob_triggers_ibfk_1` FOREIGN KEY (`sched_name`,`trigger_name`,`trigger_group`) \ + REFERENCES `{target}`.`qrtz_triggers` (`sched_name`,`trigger_name`,`trigger_group`) ON DELETE CASCADE" + )) + .await + .map_err(|e| e.to_string())?; + let changed = compare(&pool, &source, &target).await?; + if changed.diffs.len() != 1 || changed.diffs[0].foreign_keys.as_ref().map(Vec::len) != Some(1) { + return Err(format!("expected one changed constraint: {:?}", changed.diffs)); + } + if changed.sync_sql.matches("DROP FOREIGN KEY").count() != 1 + || changed.sync_sql.matches("ADD CONSTRAINT").count() != 1 + || !changed.sync_sql.contains("(`sched_name`, `trigger_name`, `trigger_group`)") + { + return Err(format!("incomplete composite SQL: {}", changed.sync_sql)); + } + conn.query_drop(&changed.sync_sql).await.map_err(|e| e.to_string())?; + let converged = compare(&pool, &source, &target).await?; + if !converged.diffs.is_empty() || !converged.sync_sql.trim().is_empty() { + return Err(format!("sync did not converge: {:?}", converged.diffs)); + } + let final_rows = mysql::list_foreign_keys(&pool, &target, "qrtz_blob_triggers").await?; + if final_rows.len() != rows.len() + || final_rows.iter().zip(&rows).any(|(actual, expected)| { + actual.column != expected.column || actual.ref_column != expected.ref_column + || actual.on_delete != expected.on_delete || actual.on_update != expected.on_update + }) { + return Err(format!("incorrect final metadata: {final_rows:?}")); + } + Ok(()) + } + .await; + // MySQL DDL does not roll back: cleanup uses only successfully created names. + for database in created.into_iter().rev() { + conn.query_drop(format!("DROP DATABASE `{database}`")).await.map_err(|e| e.to_string())?; + } + result +} diff --git a/crates/dbx-sql-schema/src/schema_diff.rs b/crates/dbx-sql-schema/src/schema_diff.rs index 650097e133..8c245c7446 100644 --- a/crates/dbx-sql-schema/src/schema_diff.rs +++ b/crates/dbx-sql-schema/src/schema_diff.rs @@ -64,6 +64,74 @@ pub struct IndexDiff { pub changes: Vec, } +/// A complete constraint for schema comparison. Drivers expose one row per column pair. +/// Keep the scalar fields for display and compatibility with saved single-column diffs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaForeignKeyInfo { + #[serde(flatten)] + pub info: ForeignKeyInfo, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub column_pairs: Vec<(String, String)>, +} + +impl std::ops::Deref for SchemaForeignKeyInfo { + type Target = ForeignKeyInfo; + + fn deref(&self) -> &Self::Target { + &self.info + } +} + +impl From for SchemaForeignKeyInfo { + fn from(info: ForeignKeyInfo) -> Self { + Self { info, column_pairs: Vec::new() } + } +} + +impl SchemaForeignKeyInfo { + fn columns(&self) -> Vec<&str> { + if self.column_pairs.is_empty() { + vec![&self.column] + } else { + self.column_pairs.iter().map(|(column, _)| column.as_str()).collect() + } + } + + fn ref_columns(&self) -> Vec<&str> { + if self.column_pairs.is_empty() { + vec![&self.ref_column] + } else { + self.column_pairs.iter().map(|(_, column)| column.as_str()).collect() + } + } +} + +/// Called separately for each table and namespace. The driver supplies each constraint's +/// pairs in ordinal order; independent constraints may be interleaved. Never sort the +/// two column lists separately, or parse the display strings as identifiers. +fn group_foreign_keys(rows: &[ForeignKeyInfo]) -> Vec { + let mut constraints: Vec = Vec::new(); + let mut positions = HashMap::new(); + for row in rows { + // JDBC may omit FK_NAME. An empty name does not identify a constraint: + // preserve each row instead of inventing a compound key across references. + if row.name.is_empty() { + constraints.push(row.clone().into()); + continue; + } + let position = *positions.entry(row.name.as_str()).or_insert_with(|| { + constraints.push(row.clone().into()); + constraints.len() - 1 + }); + constraints[position].column_pairs.push((row.column.clone(), row.ref_column.clone())); + } + for constraint in &mut constraints { + constraint.info.column = constraint.columns().join(", "); + constraint.info.ref_column = constraint.ref_columns().join(", "); + } + constraints +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ForeignKeyDiff { @@ -71,9 +139,9 @@ pub struct ForeignKeyDiff { pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, + pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option, + pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } @@ -2402,27 +2470,21 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { .collect() }), foreign_keys: source_detail.map(|detail| { - detail + let rows = detail .foreign_keys .iter() .map(|fk| { - let fk = normalize_mapped_foreign_key( + normalize_mapped_foreign_key( fk, &source_table_name_set, &target_table_name_set, &table_pairs, &options.table_mappings, options.ignore_table_name_case, - ); - ForeignKeyDiff { - diff_type: "added".to_string(), - name: fk.name.clone(), - source: Some(fk), - target: None, - changes: vec![], - } + ) }) - .collect() + .collect::>(); + diff_foreign_keys(&rows, &[]) }), triggers: source_detail.and_then(|detail| { if detail.triggers.is_empty() { @@ -2485,19 +2547,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }) .collect() }), - foreign_keys: target_detail.map(|detail| { - detail - .foreign_keys - .iter() - .map(|foreign_key| ForeignKeyDiff { - diff_type: "removed".to_string(), - name: foreign_key.name.clone(), - source: None, - target: Some(foreign_key.clone()), - changes: vec![], - }) - .collect() - }), + foreign_keys: target_detail.map(|detail| diff_foreign_keys(&[], &detail.foreign_keys)), triggers: target_detail.map(|detail| { detail .triggers @@ -3768,12 +3818,32 @@ fn diff_foreign_keys_with_options( ignore_table_name_case: bool, ignore_column_name_case: bool, ) -> Vec { + let source = group_foreign_keys(source); + let target = group_foreign_keys(target); let mut diffs = Vec::new(); - let target_map: HashMap<&str, &ForeignKeyInfo> = target.iter().map(|fk| (fk.name.as_str(), fk)).collect(); - let source_map: HashMap<&str, &ForeignKeyInfo> = source.iter().map(|fk| (fk.name.as_str(), fk)).collect(); + let target_map: HashMap<&str, usize> = target + .iter() + .enumerate() + .filter(|(_, fk)| !fk.name.is_empty()) + .map(|(index, fk)| (fk.name.as_str(), index)) + .collect(); + let mut matched = vec![false; target.len()]; - for source_fk in source { - let Some(target_fk) = target_map.get(source_fk.name.as_str()) else { + for source_fk in &source { + let target_index = if source_fk.name.is_empty() { + // Unnamed rows have no reliable constraint identity. Match equal records + // one-to-one, irrespective of order; keep unmatched rows as added/removed. + target.iter().enumerate().find_map(|(index, target_fk)| { + (!matched[index] + && target_fk.name.is_empty() + && foreign_key_changes(source_fk, target_fk, ignore_table_name_case, ignore_column_name_case) + .is_empty()) + .then_some(index) + }) + } else { + target_map.get(source_fk.name.as_str()).copied() + }; + let Some(target_index) = target_index else { diffs.push(ForeignKeyDiff { diff_type: "added".to_string(), name: source_fk.name.clone(), @@ -3783,55 +3853,22 @@ fn diff_foreign_keys_with_options( }); continue; }; - - let mut changes = Vec::new(); - if !identifiers_equal(&source_fk.column, &target_fk.column, ignore_column_name_case) { - changes.push(format!("column: {} → {}", target_fk.column, source_fk.column)); - } - if !identifiers_equal(&source_fk.ref_table, &target_fk.ref_table, ignore_table_name_case) { - changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table)); - } - if source_fk.ref_schema != target_fk.ref_schema { - changes.push(format!( - "ref schema: {} → {}", - target_fk.ref_schema.as_deref().unwrap_or(""), - source_fk.ref_schema.as_deref().unwrap_or("") - )); - } - if !identifiers_equal(&source_fk.ref_column, &target_fk.ref_column, ignore_column_name_case) { - changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column)); - } - let source_on_delete = normalized_foreign_key_action(source_fk.on_delete.as_deref()); - let target_on_delete = normalized_foreign_key_action(target_fk.on_delete.as_deref()); - if source_on_delete != target_on_delete { - changes.push(format!( - "delete: {} → {}", - target_on_delete.as_deref().unwrap_or(""), - source_on_delete.as_deref().unwrap_or("") - )); - } - let source_on_update = normalized_foreign_key_action(source_fk.on_update.as_deref()); - let target_on_update = normalized_foreign_key_action(target_fk.on_update.as_deref()); - if source_on_update != target_on_update { - changes.push(format!( - "update: {} → {}", - target_on_update.as_deref().unwrap_or(""), - source_on_update.as_deref().unwrap_or("") - )); - } + matched[target_index] = true; + let target_fk = &target[target_index]; + let changes = foreign_key_changes(source_fk, target_fk, ignore_table_name_case, ignore_column_name_case); if !changes.is_empty() { diffs.push(ForeignKeyDiff { diff_type: "modified".to_string(), name: source_fk.name.clone(), source: Some(source_fk.clone()), - target: Some((*target_fk).clone()), + target: Some(target_fk.clone()), changes, }); } } - for target_fk in target { - if !source_map.contains_key(target_fk.name.as_str()) { + for (index, target_fk) in target.iter().enumerate() { + if !matched[index] { diffs.push(ForeignKeyDiff { diff_type: "removed".to_string(), name: target_fk.name.clone(), @@ -3845,6 +3882,62 @@ fn diff_foreign_keys_with_options( diffs } +fn foreign_key_changes( + source_fk: &SchemaForeignKeyInfo, + target_fk: &SchemaForeignKeyInfo, + ignore_table_name_case: bool, + ignore_column_name_case: bool, +) -> Vec { + let mut changes = Vec::new(); + if source_fk.columns().len() != target_fk.columns().len() + || !source_fk + .columns() + .iter() + .zip(target_fk.columns()) + .all(|(source, target)| identifiers_equal(source, target, ignore_column_name_case)) + { + changes.push(format!("column: {} → {}", target_fk.column, source_fk.column)); + } + if !identifiers_equal(&source_fk.ref_table, &target_fk.ref_table, ignore_table_name_case) { + changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table)); + } + if source_fk.ref_schema != target_fk.ref_schema { + changes.push(format!( + "ref schema: {} → {}", + target_fk.ref_schema.as_deref().unwrap_or(""), + source_fk.ref_schema.as_deref().unwrap_or("") + )); + } + if source_fk.ref_columns().len() != target_fk.ref_columns().len() + || !source_fk + .ref_columns() + .iter() + .zip(target_fk.ref_columns()) + .all(|(source, target)| identifiers_equal(source, target, ignore_column_name_case)) + { + changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column)); + } + let source_on_delete = normalized_foreign_key_action(source_fk.on_delete.as_deref()); + let target_on_delete = normalized_foreign_key_action(target_fk.on_delete.as_deref()); + if source_on_delete != target_on_delete { + changes.push(format!( + "delete: {} → {}", + target_on_delete.as_deref().unwrap_or(""), + source_on_delete.as_deref().unwrap_or("") + )); + } + let source_on_update = normalized_foreign_key_action(source_fk.on_update.as_deref()); + let target_on_update = normalized_foreign_key_action(target_fk.on_update.as_deref()); + if source_on_update != target_on_update { + changes.push(format!( + "update: {} → {}", + target_on_update.as_deref().unwrap_or(""), + source_on_update.as_deref().unwrap_or("") + )); + } + changes +} + pub fn diff_triggers(source: &[TriggerInfo], target: &[TriggerInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &TriggerInfo> = @@ -4588,13 +4681,18 @@ fn drop_foreign_key_sql(table_name: &str, fk_name: &str, db_type: DatabaseType, } } -fn add_foreign_key_sql(table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseType, schema: Option<&str>) -> String { +fn add_foreign_key_sql( + table_name: &str, + fk: &SchemaForeignKeyInfo, + db_type: DatabaseType, + schema: Option<&str>, +) -> String { add_foreign_key_sql_with_reference_separator(table_name, fk, db_type, schema, " ") } fn add_foreign_key_sql_with_reference_separator( table_name: &str, - fk: &ForeignKeyInfo, + fk: &SchemaForeignKeyInfo, db_type: DatabaseType, schema: Option<&str>, reference_separator: &str, @@ -4623,8 +4721,8 @@ fn add_foreign_key_sql_with_reference_separator( format!( "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {ref_table}{reference_separator}({}){on_delete}{on_update};", quote_id(&fk.name, db_type), - quote_id(&fk.column, db_type), - quote_id(&fk.ref_column, db_type) + fk.columns().iter().map(|column| quote_id(column, db_type)).collect::>().join(", "), + fk.ref_columns().iter().map(|column| quote_id(column, db_type)).collect::>().join(", ") ) } @@ -5634,9 +5732,9 @@ fn generate_create_table_sql( col_defs.push(format!( "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}){}{}", quote_id(&fk.name, db_type), - quote_id(&fk.column, db_type), + fk.columns().iter().map(|column| quote_id(column, db_type)).collect::>().join(", "), ref_table, - quote_id(&fk.ref_column, db_type), + fk.ref_columns().iter().map(|column| quote_id(column, db_type)).collect::>().join(", "), on_delete, on_update )); @@ -8286,7 +8384,7 @@ mod tests { on_update: Some("RESTRICT".into()), on_delete: Some("SET NULL".into()), }; - let fk_sql = add_foreign_key_sql("events", &fk, DatabaseType::SqlServer, Some("dbo")); + let fk_sql = add_foreign_key_sql("events", &fk.into(), DatabaseType::SqlServer, Some("dbo")); assert!(fk_sql.contains("ON DELETE SET NULL ON UPDATE NO ACTION"), "FK actions: {fk_sql}"); assert!(!fk_sql.contains("RESTRICT"), "SQL Server does not accept RESTRICT: {fk_sql}"); } @@ -9541,15 +9639,18 @@ mod tests { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".to_string(), name: "orders_user_id_fk".to_string(), - source: Some(foreign_key(ForeignKeyInfo { - name: "orders_user_id_fk".to_string(), - column: String::new(), - ref_schema: None, - ref_table: "users".to_string(), - ref_column: String::new(), - on_update: None, - on_delete: None, - })), + source: Some( + foreign_key(ForeignKeyInfo { + name: "orders_user_id_fk".to_string(), + column: String::new(), + ref_schema: None, + ref_table: "users".to_string(), + ref_column: String::new(), + on_update: None, + on_delete: None, + }) + .into(), + ), target: None, changes: Vec::new(), }]), @@ -12724,15 +12825,18 @@ mod tests { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".into(), name: "orders_user_fk".into(), - source: Some(foreign_key(ForeignKeyInfo { - name: "orders_user_fk".into(), - column: "user_id".into(), - ref_schema: Some("identity".into()), - ref_table: "users".into(), - ref_column: "id".into(), - on_update: Some("CASCADE".into()), - on_delete: Some("SET NULL".into()), - })), + source: Some( + foreign_key(ForeignKeyInfo { + name: "orders_user_fk".into(), + column: "user_id".into(), + ref_schema: Some("identity".into()), + ref_table: "users".into(), + ref_column: "id".into(), + on_update: Some("CASCADE".into()), + on_delete: Some("SET NULL".into()), + }) + .into(), + ), target: None, changes: vec![], }]), @@ -13002,24 +13106,30 @@ mod tests { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".to_string(), name: "fk_ref".into(), - source: Some(foreign_key(ForeignKeyInfo { - name: "fk_ref".into(), - column: "id".into(), - ref_schema: None, - ref_table: "users".into(), - ref_column: "id".into(), - on_update: None, - on_delete: Some("CASCADE".into()), - })), - target: Some(foreign_key(ForeignKeyInfo { - name: "fk_ref".into(), - column: "id".into(), - ref_schema: None, - ref_table: "users".into(), - ref_column: "id".into(), - on_update: None, - on_delete: Some("SET NULL".into()), - })), + source: Some( + foreign_key(ForeignKeyInfo { + name: "fk_ref".into(), + column: "id".into(), + ref_schema: None, + ref_table: "users".into(), + ref_column: "id".into(), + on_update: None, + on_delete: Some("CASCADE".into()), + }) + .into(), + ), + target: Some( + foreign_key(ForeignKeyInfo { + name: "fk_ref".into(), + column: "id".into(), + ref_schema: None, + ref_table: "users".into(), + ref_column: "id".into(), + on_update: None, + on_delete: Some("SET NULL".into()), + }) + .into(), + ), changes: vec!["delete: SET NULL → CASCADE".into()], }]), triggers: None, @@ -13734,15 +13844,18 @@ mod tests { let fks = vec![ForeignKeyDiff { diff_type: "added".into(), name: "fk_user".into(), - source: Some(ForeignKeyInfo { - name: "fk_user".into(), - column: "user_id".into(), - ref_schema: None, - ref_table: "users".into(), - ref_column: "id".into(), - on_update: None, - on_delete: Some("CASCADE".into()), - }), + source: Some( + ForeignKeyInfo { + name: "fk_user".into(), + column: "user_id".into(), + ref_schema: None, + ref_table: "users".into(), + ref_column: "id".into(), + on_update: None, + on_delete: Some("CASCADE".into()), + } + .into(), + ), target: None, changes: vec![], }]; @@ -14830,15 +14943,18 @@ mod tests { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "added".into(), name: format!("fk_{name}"), - source: Some(ForeignKeyInfo { - name: format!("fk_{name}"), - column: "parent_id".into(), - ref_schema: None, - ref_table: parent.into(), - ref_column: "id".into(), - on_update: None, - on_delete: None, - }), + source: Some( + ForeignKeyInfo { + name: format!("fk_{name}"), + column: "parent_id".into(), + ref_schema: None, + ref_table: parent.into(), + ref_column: "id".into(), + on_update: None, + on_delete: None, + } + .into(), + ), target: None, changes: Vec::new(), }]), @@ -14855,15 +14971,18 @@ mod tests { diff_type: "removed".into(), name: format!("fk_{name}"), source: None, - target: Some(ForeignKeyInfo { - name: format!("fk_{name}"), - column: "parent_id".into(), - ref_schema: None, - ref_table: parent.into(), - ref_column: "id".into(), - on_update: None, - on_delete: None, - }), + target: Some( + ForeignKeyInfo { + name: format!("fk_{name}"), + column: "parent_id".into(), + ref_schema: None, + ref_table: parent.into(), + ref_column: "id".into(), + on_update: None, + on_delete: None, + } + .into(), + ), changes: Vec::new(), }]), target_ddl: Some(format!("CREATE TABLE `{name}` (`id` int NOT NULL, `parent_id` int)")), @@ -14953,15 +15072,18 @@ mod tests { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "added".into(), name: "fk_aaa_child_mod9761".into(), - source: Some(ForeignKeyInfo { - name: "fk_aaa_child_mod9761".into(), - column: "parent_id".into(), - ref_schema: None, - ref_table: "zzz_parent9761".into(), - ref_column: "id".into(), - on_update: None, - on_delete: None, - }), + source: Some( + ForeignKeyInfo { + name: "fk_aaa_child_mod9761".into(), + column: "parent_id".into(), + ref_schema: None, + ref_table: "zzz_parent9761".into(), + ref_column: "id".into(), + on_update: None, + on_delete: None, + } + .into(), + ), target: None, changes: Vec::new(), }]), @@ -14988,15 +15110,18 @@ mod tests { diff_type: "removed".into(), name: "fk_aaa_child_mod9761".into(), source: None, - target: Some(ForeignKeyInfo { - name: "fk_aaa_child_mod9761".into(), - column: "parent_id".into(), - ref_schema: None, - ref_table: "zzz_parent9761".into(), - ref_column: "id".into(), - on_update: None, - on_delete: None, - }), + target: Some( + ForeignKeyInfo { + name: "fk_aaa_child_mod9761".into(), + column: "parent_id".into(), + ref_schema: None, + ref_table: "zzz_parent9761".into(), + ref_column: "id".into(), + on_update: None, + on_delete: None, + } + .into(), + ), changes: Vec::new(), }]), ..Default::default() diff --git a/crates/dbx-sql-schema/tests/composite_foreign_keys.rs b/crates/dbx-sql-schema/tests/composite_foreign_keys.rs new file mode 100644 index 0000000000..1444fea510 --- /dev/null +++ b/crates/dbx-sql-schema/tests/composite_foreign_keys.rs @@ -0,0 +1,318 @@ +use dbx_sql_schema::models::connection::DatabaseType; +use dbx_sql_schema::schema_diff::{ + diff_foreign_keys, generate_schema_sync_sql, prepare_schema_diff, SchemaDiffPreparation, + SchemaDiffPreparationOptions, TableDiff, TableSchemaDetail, +}; +use dbx_sql_schema::types::{ColumnInfo, ForeignKeyInfo, TableInfo}; + +fn fk(name: &str, column: &str, ref_column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.into(), + column: column.into(), + ref_schema: Some("external".into()), + ref_table: "parent".into(), + ref_column: ref_column.into(), + on_delete: Some("RESTRICT".into()), + on_update: Some("CASCADE".into()), + } +} + +fn composite() -> Vec { + // Deliberately not alphabetical; ordering must preserve these column pairs. + vec![fk("child_parent", "tenant", "scope"), fk("child_parent", "item", "code")] +} + +fn options(source: Vec, target: Vec) -> SchemaDiffPreparationOptions { + let table = TableInfo { + name: "child".into(), + table_type: "BASE TABLE".into(), + valid: None, + comment: None, + parent_schema: None, + parent_name: None, + }; + let detail = |foreign_keys| TableSchemaDetail { + name: "child".into(), + columns: ["tenant", "item"] + .into_iter() + .map(|name| ColumnInfo { name: name.into(), data_type: "int".into(), ..Default::default() }) + .collect(), + indexes: vec![], + foreign_keys, + triggers: vec![], + ddl: None, + }; + SchemaDiffPreparationOptions { + source_tables: vec![table.clone()], + target_tables: vec![table], + source_details: vec![detail(source)], + target_details: vec![detail(target)], + database_type: DatabaseType::Mysql, + target_schema: Some("destination".into()), + enable_rollback: true, + ..Default::default() + } +} + +fn prepare(source: Vec, target: Vec) -> SchemaDiffPreparation { + prepare_schema_diff(options(source, target)) +} + +#[test] +fn composite_self_compare_has_no_diff_or_sync_sql() { + let rows = composite(); + assert!(diff_foreign_keys(&rows, &rows).is_empty()); + let result = prepare(rows.clone(), rows); + assert!(result.diffs.is_empty(), "{:?}", result.diffs); + assert!(result.sync_sql.trim().is_empty(), "{}", result.sync_sql); +} + +#[test] +fn independent_constraint_order_does_not_change_comparison() { + let mut source = composite(); + source.insert(1, fk("single", "owner", "id")); + let mut target = composite(); + target.push(fk("single", "owner", "id")); + assert!(diff_foreign_keys(&source, &target).is_empty()); +} + +#[test] +fn unnamed_foreign_keys_keep_each_reference_and_detect_changes() { + let source = + vec![fk("", "tenant", "scope"), ForeignKeyInfo { ref_table: "projects".into(), ..fk("", "item", "code") }]; + for field in ["column", "ref_column", "ref_table", "ref_schema", "on_delete", "on_update"] { + let mut target = source.clone(); + let row = &mut target[1]; + match field { + "column" => row.column = "old_item".into(), + "ref_column" => row.ref_column = "old_code".into(), + "ref_table" => row.ref_table = "archived_projects".into(), + "ref_schema" => row.ref_schema = Some("archive".into()), + "on_delete" => row.on_delete = Some("CASCADE".into()), + "on_update" => row.on_update = Some("RESTRICT".into()), + _ => unreachable!(), + } + let result = prepare(source.clone(), target.clone()); + let changes = result.diffs[0].foreign_keys.as_ref().unwrap(); + // Without a constraint identity, preserve the unmatched records rather than + // guessing that rows describe the same constraint or a compound key. + assert_eq!(changes.len(), 2, "{field}: {changes:?}"); + assert_eq!(changes[0].diff_type, "added"); + assert_eq!(changes[0].source.as_ref().unwrap().column, source[1].column); + assert_eq!(changes[0].source.as_ref().unwrap().ref_table, source[1].ref_table); + assert_eq!(changes[1].diff_type, "removed"); + assert_eq!(changes[1].target.as_ref().unwrap().column, target[1].column); + assert_eq!(changes[1].target.as_ref().unwrap().ref_table, target[1].ref_table); + } +} + +#[test] +fn unnamed_foreign_keys_compare_as_unordered_rows_alongside_named_composites() { + let mut source = composite(); + source.push(fk("", "owner", "id")); + source.push(ForeignKeyInfo { ref_table: "projects".into(), ..fk("", "project", "id") }); + let mut target = source[..2].to_vec(); + target.extend(source[2..].iter().rev().cloned()); + let result = prepare(source, target); + assert!(result.diffs.is_empty(), "{:?}", result.diffs); + assert!(result.sync_sql.is_empty()); +} + +#[test] +fn unnamed_foreign_key_matching_preserves_multiplicity() { + let row = fk("", "owner", "id"); + let double = vec![row.clone(), row.clone()]; + assert!(diff_foreign_keys(&double, &double).is_empty()); + let added = diff_foreign_keys(&double, std::slice::from_ref(&row)); + assert_eq!(added.len(), 1); + assert_eq!(added[0].diff_type, "added"); + let removed = diff_foreign_keys(std::slice::from_ref(&row), &double); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].diff_type, "removed"); +} + +#[test] +fn unnamed_foreign_keys_on_the_same_column_do_not_hide_different_parents() { + let source = + vec![fk("", "owner", "id"), ForeignKeyInfo { ref_table: "other_parent".into(), ..fk("", "owner", "id") }]; + let added = diff_foreign_keys(&source, &[]); + assert_eq!(added.len(), 2); + assert_eq!(added[0].source.as_ref().unwrap().ref_table, "parent"); + assert_eq!(added[1].source.as_ref().unwrap().ref_table, "other_parent"); + assert!(added.iter().all(|diff| diff.source.as_ref().unwrap().column == "owner")); + let mut target = source.clone(); + target[1].ref_table = "third_parent".into(); + assert_eq!(diff_foreign_keys(&source, &target).len(), 2); +} + +#[test] +fn unnamed_foreign_keys_respect_existing_case_and_action_options() { + let source = vec![fk("", "Owner", "Id")]; + let mut target = source.clone(); + target[0].column = "owner".into(); + target[0].ref_column = "id".into(); + target[0].ref_table = "PARENT".into(); + target[0].on_delete = Some(" restrict ".into()); + let mut input = options(source, target); + assert!(!prepare_schema_diff(input.clone()).diffs.is_empty()); + input.ignore_table_name_case = true; + input.ignore_column_name_case = true; + assert!(prepare_schema_diff(input).diffs.is_empty()); +} + +#[test] +fn added_composite_emits_one_complete_constraint() { + let result = prepare(composite(), vec![]); + assert_eq!(result.diffs[0].foreign_keys.as_ref().unwrap().len(), 1); + assert_eq!(result.sync_sql.matches("ADD CONSTRAINT").count(), 1, "{}", result.sync_sql); + assert!( + result.sync_sql.contains("FOREIGN KEY (`tenant`, `item`) REFERENCES `external`.`parent` (`scope`, `code`)"), + "{}", + result.sync_sql + ); +} + +#[test] +fn removed_composite_emits_one_drop_and_complete_rollback() { + let result = prepare(vec![], composite()); + assert_eq!(result.diffs[0].foreign_keys.as_ref().unwrap().len(), 1); + assert_eq!(result.sync_sql.matches("DROP FOREIGN KEY").count(), 1); + let rollback = result.rollback_sync_sql.as_deref().unwrap(); + assert!(rollback.contains("FOREIGN KEY (`tenant`, `item`)"), "{rollback}"); + assert!(rollback.contains("(`scope`, `code`)"), "{rollback}"); +} + +#[test] +fn changed_pair_emits_complete_source_and_target_constraints() { + let mut target = composite(); + target[1].ref_column = "old_code".into(); + let result = prepare(composite(), target); + let fks = result.diffs[0].foreign_keys.as_ref().unwrap(); + assert_eq!(fks.len(), 1); + assert_eq!(fks[0].diff_type, "modified"); + assert_eq!(result.sync_sql.matches("DROP FOREIGN KEY").count(), 1); + assert_eq!(result.sync_sql.matches("ADD CONSTRAINT").count(), 1); + assert!(result.sync_sql.contains("(`scope`, `code`)"), "{}", result.sync_sql); + assert!(result.rollback_sync_sql.as_deref().unwrap().contains("(`scope`, `old_code`)")); +} + +#[test] +fn swapped_references_cannot_be_hidden_by_sorting_columns_separately() { + let mut target = composite(); + target[0].ref_column = "code".into(); + target[1].ref_column = "scope".into(); + let diffs = diff_foreign_keys(&composite(), &target); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].changes.iter().any(|change| change.starts_with("ref column:"))); +} + +#[test] +fn local_reference_table_schema_and_action_changes_are_detected() { + for field in ["column", "ref_column", "ref_table", "ref_schema", "on_delete", "on_update", "name"] { + let mut target = composite(); + for row in &mut target { + match field { + "column" => row.column.push_str("_old"), + "ref_column" => row.ref_column.push_str("_old"), + "ref_table" => row.ref_table = "other_parent".into(), + "ref_schema" => row.ref_schema = Some("other_schema".into()), + "on_delete" => row.on_delete = Some("CASCADE".into()), + "on_update" => row.on_update = Some("RESTRICT".into()), + "name" => row.name = "renamed_constraint".into(), + _ => unreachable!(), + } + } + assert!(!diff_foreign_keys(&composite(), &target).is_empty(), "missed {field}"); + assert!(prepare(composite(), target).sync_sql.contains("FOREIGN KEY"), "missed {field}"); + } +} + +#[test] +fn same_constraint_names_in_different_tables_stay_separate() { + let mut input = options(composite(), composite()); + let mut table = input.source_tables[0].clone(); + table.name = "another_child".into(); + input.source_tables.push(table.clone()); + input.target_tables.push(table); + let mut detail = input.source_details[0].clone(); + detail.name = "another_child".into(); + detail.foreign_keys = vec![fk("child_parent", "tenant", "scope")]; + input.source_details.push(detail.clone()); + input.target_details.push(detail); + assert!(prepare_schema_diff(input).diffs.is_empty()); +} + +#[test] +fn empty_and_single_column_constraints_keep_existing_behavior() { + assert!(diff_foreign_keys(&[], &[]).is_empty()); + let rows = vec![fk("single", "owner", "id")]; + assert!(diff_foreign_keys(&rows, &rows).is_empty()); + let result = prepare(rows, vec![]); + assert_eq!(result.sync_sql.matches("ADD CONSTRAINT").count(), 1); + assert!(result.sync_sql.contains("FOREIGN KEY (`owner`) REFERENCES `external`.`parent` (`id`)")); +} + +#[test] +fn identifiers_with_commas_quotes_and_case_are_preserved() { + let rows = vec![fk("punctuation", "Tenant,Name", "Scope,Name"), fk("punctuation", "item`id", "code`id")]; + let sql = prepare(rows.clone(), vec![]).sync_sql; + assert!(sql.contains("FOREIGN KEY (`Tenant,Name`, `item``id`)"), "{sql}"); + assert!(sql.contains("(`Scope,Name`, `code``id`)"), "{sql}"); + let mut target = rows.clone(); + target[0].column = "tenant,name".into(); + assert!(!diff_foreign_keys(&rows, &target).is_empty()); +} + +#[test] +fn new_table_composite_ddl_quotes_each_column_for_supported_dialects() { + for db_type in [DatabaseType::Mysql, DatabaseType::Postgres, DatabaseType::SqlServer, DatabaseType::Sqlite] { + let mut input = options(composite(), vec![]); + input.database_type = db_type; + input.target_tables.clear(); + input.target_details.clear(); + let result = prepare_schema_diff(input); + assert_eq!(result.diffs[0].foreign_keys.as_ref().unwrap().len(), 1, "{db_type:?}"); + let expected = match db_type { + DatabaseType::Mysql => "FOREIGN KEY (`tenant`, `item`)", + DatabaseType::SqlServer => "FOREIGN KEY ([tenant], [item])", + _ => "FOREIGN KEY (\"tenant\", \"item\")", + }; + assert!(result.sync_sql.contains(expected), "{db_type:?}: {}", result.sync_sql); + } +} + +#[test] +fn equivalent_compared_databases_keep_existing_namespace_mapping() { + let mut input = options(composite(), composite()); + let mut parent = input.source_tables[0].clone(); + parent.name = "parent".into(); + input.source_tables.push(parent.clone()); + input.target_tables.push(parent); + for row in &mut input.source_details[0].foreign_keys { + row.ref_schema = Some("source_db".into()); + } + for row in &mut input.target_details[0].foreign_keys { + row.ref_schema = Some("destination".into()); + } + assert!(prepare_schema_diff(input).diffs.is_empty()); +} + +#[test] +fn serialized_diffs_regenerate_the_complete_constraint() { + let result = prepare(composite(), vec![]); + let diffs: Vec = serde_json::from_value(serde_json::to_value(&result.diffs).unwrap()).unwrap(); + let sql = generate_schema_sync_sql( + &diffs, + &[], + &[], + &[], + &[], + DatabaseType::Mysql, + Some("destination"), + false, + None, + &[], + ); + assert_eq!(sql.matches("ADD CONSTRAINT").count(), 1, "{sql}"); + assert!(sql.contains("FOREIGN KEY (`tenant`, `item`) REFERENCES `external`.`parent` (`scope`, `code`)"), "{sql}"); +}