mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 08:15:46 +00:00
fix(schema): compare composite foreign keys as whole constraints
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user