fix(schema): compare composite foreign keys as whole constraints

This commit is contained in:
Zzz
2026-10-09 09:46:10 +08:00
committed by GitHub
parent 4411e41e42
commit f95344367c
8 changed files with 947 additions and 163 deletions
+3
View File
@@ -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
@@ -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);
+15 -5
View File
@@ -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);
}
};
+10
View File
@@ -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.
+137
View File
@@ -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;
@@ -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<SchemaDiffPreparation, String> {
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<_>>()
!= 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
}
+282 -157
View File
@@ -64,6 +64,74 @@ pub struct IndexDiff {
pub changes: Vec<String>,
}
/// 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<ForeignKeyInfo> 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<SchemaForeignKeyInfo> {
let mut constraints: Vec<SchemaForeignKeyInfo> = 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<ForeignKeyInfo>,
pub source: Option<SchemaForeignKeyInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<ForeignKeyInfo>,
pub target: Option<SchemaForeignKeyInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
}
@@ -2402,27 +2470,21 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
.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::<Vec<_>>();
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<TableDiff> {
})
.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<ForeignKeyDiff> {
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<String> {
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<TriggerDiff> {
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::<Vec<_>>().join(", "),
fk.ref_columns().iter().map(|column| quote_id(column, db_type)).collect::<Vec<_>>().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::<Vec<_>>().join(", "),
ref_table,
quote_id(&fk.ref_column, db_type),
fk.ref_columns().iter().map(|column| quote_id(column, db_type)).collect::<Vec<_>>().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()
@@ -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<ForeignKeyInfo> {
// Deliberately not alphabetical; ordering must preserve these column pairs.
vec![fk("child_parent", "tenant", "scope"), fk("child_parent", "item", "code")]
}
fn options(source: Vec<ForeignKeyInfo>, target: Vec<ForeignKeyInfo>) -> 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<ForeignKeyInfo>, target: Vec<ForeignKeyInfo>) -> 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<TableDiff> = 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}");
}