From 7958f7ad903088b4eb5f9fddf8e781a782bf5daa Mon Sep 17 00:00:00 2001 From: Alan Saputra Lengkoan Date: Tue, 27 Oct 2026 18:43:14 +0800 Subject: [PATCH] fix(schema-diff): equate MySQL RESTRICT/NO ACTION and refresh metadata --- .../__tests__/SchemaDiffOptionsPanel.spec.ts | 8 + .../__tests__/useSchemaDiffSession.spec.ts | 37 +++++ .../src/composables/useSchemaDiffSession.ts | 12 ++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/id.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pl.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/ru.ts | 1 + apps/desktop/src/i18n/locales/th.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/schema/schemaDiffOptions.ts | 1 + apps/desktop/src/types/schemaDiff.ts | 8 + crates/dbx-sql-schema/src/schema_diff.rs | 140 +++++++++++++++++- 18 files changed, 210 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts index f0163f5759..4ef3964c37 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts @@ -58,6 +58,14 @@ describe("SchemaDiffOptionsPanel", () => { expect(normalized.ignoreColumnNameCase).toBe(false); }); + it("exposes the refresh-metadata option for every target and defaults it on, including legacy configs", () => { + for (const dbType of ["postgres", "mysql", "goldendb", "oracle"]) { + expect(getSchemaDiffOptionsForDbType(dbType).map((item) => item.id)).toContain("refreshMetadata"); + expect(normalizeSchemaDiffCompareOptions({}, dbType).refreshMetadata).toBe(true); + } + expect(normalizeSchemaDiffCompareOptions({ refreshMetadata: false }, "mysql").refreshMetadata).toBe(false); + }); + it("exposes charset comparison only for supported MySQL targets and defaults it on", () => { expect(getSchemaDiffOptionsForDbType("mysql").map((item) => item.id)).toContain("compareCharset"); expect(getSchemaDiffOptionsForDbType("goldendb").map((item) => item.id)).toContain("compareCharset"); diff --git a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts index 46a6505d3b..c85f76b3a6 100644 --- a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/backend/api", () => apiMock); vi.mock("@/composables/useDialogSources", () => ({ openSchemaDiffSession: openMock })); vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => trackerMock })); vi.mock("@/lib/schema/schemaDiffMetadataLoad", () => ({ loadSchemaDetails: vi.fn().mockResolvedValue([]) })); +vi.mock("@/lib/metadata/objectMetadataCache", () => ({ invalidateObjectMetadataCache: vi.fn().mockResolvedValue(undefined) })); const { startSchemaDiffSession } = await import("../useSchemaDiffSession.ts"); @@ -85,6 +86,14 @@ test("applies every exclude rule before loading source and target details", asyn await waitForSession(session); assert.equal(session.status, "completed"); assert.equal(vi.mocked(loadSchemaDetails).mock.calls.length, 2); + const { invalidateObjectMetadataCache } = await import("@/lib/metadata/objectMetadataCache"); + assert.deepEqual( + vi.mocked(invalidateObjectMetadataCache).mock.calls.map(([match]) => match), + [ + { connectionId: "source", database: "app" }, + { connectionId: "target", database: "warehouse" }, + ], + ); for (const [tables] of vi.mocked(loadSchemaDetails).mock.calls) { assert.deepEqual( tables.map((table) => table.name), @@ -94,6 +103,34 @@ test("applies every exclude rule before loading source and target details", asyn vi.clearAllMocks(); }); +test("skips metadata cache invalidation when refreshMetadata is turned off", async () => { + const { loadSchemaDetails } = await import("@/lib/schema/schemaDiffMetadataLoad"); + const { invalidateObjectMetadataCache } = await import("@/lib/metadata/objectMetadataCache"); + vi.clearAllMocks(); + apiMock.prepareSchemaDiff.mockResolvedValue({ diffs: [], renameCandidates: [], syncSql: "", rollbackSyncSql: "" }); + const session = startSchemaDiffSession( + { + sourceConnectionId: "source", + sourceDatabase: "app", + sourceSchema: "", + targetConnectionId: "target", + targetDatabase: "warehouse", + targetSchema: "", + sourceDbType: "mysql", + targetDbType: "mysql", + options: { functions: false, refreshMetadata: false }, + ignoreComments: false, + label: "cached compare", + }, + { tableListLoader: { load: vi.fn().mockResolvedValue([{ name: "users", table_type: "BASE TABLE" }]) } }, + ); + await waitForSession(session); + assert.equal(session.status, "completed"); + assert.equal(vi.mocked(loadSchemaDetails).mock.calls.length, 2); + assert.equal(vi.mocked(invalidateObjectMetadataCache).mock.calls.length, 0); + vi.clearAllMocks(); +}); + async function waitForSession(session: { status: string }) { for (let attempt = 0; attempt < 40 && session.status === "running"; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/apps/desktop/src/composables/useSchemaDiffSession.ts b/apps/desktop/src/composables/useSchemaDiffSession.ts index bac6e82587..d14eed3302 100644 --- a/apps/desktop/src/composables/useSchemaDiffSession.ts +++ b/apps/desktop/src/composables/useSchemaDiffSession.ts @@ -7,6 +7,7 @@ import { filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter"; import { compileSchemaDiffTableFilter } from "@/lib/schema/schemaDiffTableFilter"; import { filterSchemaDiffFunctions, schemaDiffRoutineKeyFromFunction } from "@/lib/schema/schemaDiffRoutine"; import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad"; +import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache"; import { registerSchemaDiffTask } from "@/lib/schema/schemaDiffCancellation"; import type { SchemaDiffTableIdentity, SchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList"; import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjectPhase, shouldLoadSchemaDiffRoutines, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress"; @@ -204,6 +205,17 @@ async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: Sc signal.throwIfAborted(); ({ sourceTables, targetTables } = filterSchemaDiffTables(sourceTableList, targetTableList, tableFilter, sessionOptions, sessionOptions.selectedTables)); + // Column/index/foreign-key reads are served from the persisted object + // metadata cache, which has no TTL on the backend. A compare must see the + // live structure on both sides, otherwise changes made outside DBX are + // hidden behind a stale snapshot. Best effort: a failed deletion only + // means the compare falls back to whatever the cache returns. Users can + // opt out via `refreshMetadata` to reuse the cache for faster compares. + if (sessionOptions.refreshMetadata) { + await Promise.all([invalidateObjectMetadataCache({ connectionId: input.sourceConnectionId, database: input.sourceDatabase }), invalidateObjectMetadataCache({ connectionId: input.targetConnectionId, database: input.targetDatabase })]); + signal.throwIfAborted(); + } + publishProgress(session, { phase: "loading-source-details", current: 0, total: sourceTables.length }); sourceDetails = await loadSchemaDetails( sourceTables, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d5a4144f16..0b959ec0f0 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -9724,6 +9724,7 @@ export default { compareCharset: "Compare character sets and collations", ignoreTableNameCase: "Ignore table name case", ignoreColumnNameCase: "Ignore column name case", + refreshMetadata: "Refresh metadata before comparing", detectRenames: "Detect renames", detectTableRenames: "Detect table renames", enableRollback: "Enable rollback", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7a22c15919..36ea437523 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -8534,6 +8534,7 @@ export default withEnglishFallback({ compareCharset: "Comparar conjuntos de caracteres y collations", ignoreTableNameCase: "Ignorar mayúsculas y minúsculas del nombre de la tabla", ignoreColumnNameCase: "Ignorar mayúsculas y minúsculas del nombre de la columna", + refreshMetadata: "Actualizar metadatos antes de comparar", detectRenames: "Detectar renombrados", detectTableRenames: "Detectar renombrados de tabla", enableRollback: "Habilitar reversión", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index c0a7ae7857..24a0c83386 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -8057,6 +8057,7 @@ export default withEnglishFallback({ compareCharset: "Bandingkan character set dan collation", ignoreTableNameCase: "Abaikan huruf besar/kecil pada nama tabel", ignoreColumnNameCase: "Abaikan huruf besar/kecil pada nama kolom", + refreshMetadata: "Muat ulang metadata sebelum membandingkan", detectRenames: "Deteksi penggantian nama", detectTableRenames: "Deteksi penggantian nama tabel", enableRollback: "Aktifkan rollback", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 86ae8873b4..79cf034b3e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -8323,6 +8323,7 @@ export default withEnglishFallback({ batchSection: "Batch", batchPatterns: "Pattern tabella (separati da virgola)", dialectAuto: "auto", + refreshMetadata: "Aggiorna i metadati prima del confronto", }, }, tableMultiSelect: { diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f6eccaba66..01d8d9a721 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -8343,6 +8343,7 @@ export default withEnglishFallback({ batchSection: "バッチ", batchPatterns: "テーブルパターン(カンマ区切り)", dialectAuto: "自動", + refreshMetadata: "比較前にメタデータを更新", }, }, tableMultiSelect: { diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1e24e0e694..e53b879259 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -8447,6 +8447,7 @@ export default withEnglishFallback({ batchSection: "일괄", batchPatterns: "테이블 패턴 (쉼표로 구분)", dialectAuto: "자동", + refreshMetadata: "비교 전 메타데이터 새로 고침", }, }, dataCompare: { diff --git a/apps/desktop/src/i18n/locales/pl.ts b/apps/desktop/src/i18n/locales/pl.ts index 21b75486d5..0f4b14a0bb 100644 --- a/apps/desktop/src/i18n/locales/pl.ts +++ b/apps/desktop/src/i18n/locales/pl.ts @@ -8120,6 +8120,7 @@ export default withEnglishFallback({ compareCharset: "Porównuj zestawy znaków i collations", ignoreTableNameCase: "Ignoruj wielkość liter w nazwach tabel", ignoreColumnNameCase: "Ignoruj wielkość liter w nazwach kolumn", + refreshMetadata: "Odśwież metadane przed porównaniem", detectRenames: "Wykrywaj zmiany nazw", detectTableRenames: "Wykrywaj zmiany nazw tabel", enableRollback: "Włącz wycofanie (Rollback)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c03e7e74cd..b37191f8a7 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -8322,6 +8322,7 @@ export default withEnglishFallback({ batchSection: "Lote", batchPatterns: "Padrões de tabela (separados por vírgula)", dialectAuto: "automático", + refreshMetadata: "Atualizar metadados antes da comparação", }, }, tableMultiSelect: { diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index aa5dff5f2b..136956b297 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -9898,6 +9898,7 @@ export default withEnglishFallback({ batchSection: "Пакетно", batchPatterns: "Шаблоны таблиц (через запятую)", dialectAuto: "авто", + refreshMetadata: "Обновлять метаданные перед сравнением", }, }, tableMultiSelect: { diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index 9a18b271ed..0b4c9a8c74 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -10267,6 +10267,7 @@ export default withEnglishFallback({ compareCharset: "เปรียบเทียบชุดอักขระและ Collation", ignoreTableNameCase: "ละเว้นตัวพิมพ์เล็กใหญ่ของชื่อตาราง", ignoreColumnNameCase: "ละเว้นตัวพิมพ์เล็กใหญ่ของชื่อคอลัมน์", + refreshMetadata: "รีเฟรชเมทาดาทาก่อนเปรียบเทียบ", detectRenames: "ตรวจหาการเปลี่ยนชื่อ", detectTableRenames: "ตรวจหาการเปลี่ยนชื่อตาราง", enableRollback: "เปิดใช้ Rollback", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 25fbe878c8..ff6f6a18db 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -9691,6 +9691,7 @@ export default withEnglishFallback({ compareCharset: "比较字符集和排序规则", ignoreTableNameCase: "忽略表名大小写", ignoreColumnNameCase: "忽略字段名大小写", + refreshMetadata: "比较前刷新元数据", detectRenames: "检测重命名", detectTableRenames: "检测表重命名", enableRollback: "启用回滚", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3d32e773a3..24c41167df 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7447,6 +7447,7 @@ export default withEnglishFallback({ batchSection: "批次模式", batchPatterns: "資料表模式(逗號分隔)", dialectAuto: "自動", + refreshMetadata: "比較前重新整理中繼資料", }, }, tableMultiSelect: { diff --git a/apps/desktop/src/lib/schema/schemaDiffOptions.ts b/apps/desktop/src/lib/schema/schemaDiffOptions.ts index cf0193e293..761a85fde8 100644 --- a/apps/desktop/src/lib/schema/schemaDiffOptions.ts +++ b/apps/desktop/src/lib/schema/schemaDiffOptions.ts @@ -25,6 +25,7 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [ { id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false }, { id: "ignoreTableNameCase", labelKey: "schemaDiff.options.ignoreTableNameCase", defaultChecked: false }, { id: "ignoreColumnNameCase", labelKey: "schemaDiff.options.ignoreColumnNameCase", defaultChecked: false }, + { id: "refreshMetadata", labelKey: "schemaDiff.options.refreshMetadata", defaultChecked: true }, { id: "detectRenames", labelKey: "schemaDiff.options.detectRenames", defaultChecked: false }, { id: "detectTableRenames", labelKey: "schemaDiff.options.detectTableRenames", defaultChecked: false }, { id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false }, diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index f42850363d..8138e50a2d 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -196,6 +196,12 @@ export interface SchemaDiffCompareOptions { compareCharset: boolean; ignoreTableNameCase: boolean; ignoreColumnNameCase: boolean; + /** + * Drop the persisted object metadata cache for both endpoints before loading + * table details, so the compare reads the live structure. Turn off to reuse + * cached metadata (faster, but may hide changes made outside DBX). + */ + refreshMetadata: boolean; tableIncludePattern: string; tableExcludePattern: string; tableFilterPriority: SchemaDiffTableFilterPriority; @@ -286,6 +292,7 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = { compareCharset: true, ignoreTableNameCase: false, ignoreColumnNameCase: false, + refreshMetadata: true, tableIncludePattern: "", tableExcludePattern: "", tableFilterPriority: "exclude", @@ -324,6 +331,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = { compareCharset: true, ignoreTableNameCase: false, ignoreColumnNameCase: false, + refreshMetadata: true, tableIncludePattern: "", tableExcludePattern: "", tableFilterPriority: "exclude", diff --git a/crates/dbx-sql-schema/src/schema_diff.rs b/crates/dbx-sql-schema/src/schema_diff.rs index 8ca23e47fe..a981dd60d6 100644 --- a/crates/dbx-sql-schema/src/schema_diff.rs +++ b/crates/dbx-sql-schema/src/schema_diff.rs @@ -2415,6 +2415,17 @@ fn compare_mysql_column_charset(options: &SchemaDiffPreparationOptions) -> bool source_dialect == DialectKind::Mysql && target_dialect == DialectKind::Mysql } +/// MySQL-family engines treat `RESTRICT`, `NO ACTION` and an omitted referential action identically, +/// so they are only folded together when both sides of the comparison use the MySQL dialect. +/// PostgreSQL, SQL Server, Oracle, SQLite, etc. are deliberately excluded: there `NO ACTION` may be +/// deferred to the end of the statement/transaction while `RESTRICT` is checked immediately. +fn mysql_foreign_key_actions(options: &SchemaDiffPreparationOptions) -> bool { + let target_dialect = + options.target_dialect.unwrap_or_else(|| DialectKind::from_database_type(options.database_type)); + let source_dialect = options.source_dialect.unwrap_or(target_dialect); + source_dialect == DialectKind::Mysql && target_dialect == DialectKind::Mysql +} + fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_details: HashMap<&str, &TableSchemaDetail> = options.source_details.iter().map(|detail| (detail.name.as_str(), detail)).collect(); @@ -2719,6 +2730,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { &normalized_target_fks, options.ignore_table_name_case, options.ignore_column_name_case, + mysql_foreign_key_actions(options), ); let trigger_diffs = diff_triggers(&source.triggers, &target.triggers); let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None); @@ -3840,6 +3852,30 @@ fn normalized_foreign_key_action(action: Option<&str>) -> Option { .filter(|value| !value.is_empty()) } +/// Normalizes a referential action for comparison. With `mysql_actions`, InnoDB semantics apply: +/// `RESTRICT`, `NO ACTION` and an omitted clause (which MySQL reports as `NO ACTION`) all reject +/// the change immediately, so they collapse to one value and never surface as a difference. +/// +/// References (MySQL 8.4 Reference Manual, 15.1.20.5 "FOREIGN KEY Constraints"): +/// - "NO ACTION: ... For InnoDB, this is equivalent to RESTRICT". +/// - "Specifying RESTRICT (or NO ACTION) is the same as omitting the ON DELETE or ON UPDATE clause." +/// - "For an ON DELETE or ON UPDATE that is not specified, the default action is always NO ACTION." +/// MariaDB KB "Foreign Keys" likewise documents `NO ACTION` as a "Synonym for RESTRICT". +/// +/// Only the comparison is normalized; generated DDL keeps the source's original action text. +/// Known caveat: on MySQL NDB Cluster `NO ACTION` is a deferred check, but schema metadata does +/// not carry the storage engine, and NDB foreign keys are rare enough that InnoDB semantics win. +fn comparable_foreign_key_action(action: Option<&str>, mysql_actions: bool) -> Option { + let normalized = normalized_foreign_key_action(action); + if !mysql_actions { + return normalized; + } + match normalized.as_deref() { + None | Some("RESTRICT" | "NO ACTION") => Some("NO ACTION".to_string()), + _ => normalized, + } +} + fn normalize_self_referencing_fk( fk: &ForeignKeyInfo, own_table_names: &HashSet<&str>, @@ -3885,7 +3921,7 @@ fn normalize_mapped_foreign_key( } pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec { - diff_foreign_keys_with_options(source, target, false, false) + diff_foreign_keys_with_options(source, target, false, false, false) } fn diff_foreign_keys_with_options( @@ -3893,6 +3929,7 @@ fn diff_foreign_keys_with_options( target: &[ForeignKeyInfo], ignore_table_name_case: bool, ignore_column_name_case: bool, + mysql_actions: bool, ) -> Vec { let source = group_foreign_keys(source); let target = group_foreign_keys(target); @@ -3912,8 +3949,14 @@ fn diff_foreign_keys_with_options( 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()) + && foreign_key_changes( + source_fk, + target_fk, + ignore_table_name_case, + ignore_column_name_case, + mysql_actions, + ) + .is_empty()) .then_some(index) }) } else { @@ -3931,7 +3974,8 @@ fn diff_foreign_keys_with_options( }; 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); + let changes = + foreign_key_changes(source_fk, target_fk, ignore_table_name_case, ignore_column_name_case, mysql_actions); if !changes.is_empty() { diffs.push(ForeignKeyDiff { diff_type: "modified".to_string(), @@ -3963,6 +4007,7 @@ fn foreign_key_changes( target_fk: &SchemaForeignKeyInfo, ignore_table_name_case: bool, ignore_column_name_case: bool, + mysql_actions: bool, ) -> Vec { let mut changes = Vec::new(); if source_fk.columns().len() != target_fk.columns().len() @@ -3993,8 +4038,8 @@ fn foreign_key_changes( { 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()); + let source_on_delete = comparable_foreign_key_action(source_fk.on_delete.as_deref(), mysql_actions); + let target_on_delete = comparable_foreign_key_action(target_fk.on_delete.as_deref(), mysql_actions); if source_on_delete != target_on_delete { changes.push(format!( "delete: {} → {}", @@ -4002,8 +4047,8 @@ fn foreign_key_changes( 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()); + let source_on_update = comparable_foreign_key_action(source_fk.on_update.as_deref(), mysql_actions); + let target_on_update = comparable_foreign_key_action(target_fk.on_update.as_deref(), mysql_actions); if source_on_update != target_on_update { changes.push(format!( "update: {} → {}", @@ -13692,6 +13737,85 @@ mod tests { assert!(diffs[0].changes.iter().any(|change| change == "update: NO ACTION → CASCADE")); } + fn mysql_fk_action_options( + database_type: DatabaseType, + dialect: Option, + source_actions: (Option<&str>, Option<&str>), + target_actions: (Option<&str>, Option<&str>), + ) -> SchemaDiffPreparationOptions { + let detail = |(on_update, on_delete): (Option<&str>, Option<&str>)| TableSchemaDetail { + name: "purchase_order_amendment".to_string(), + columns: vec![column("id_purchase_order", "int unsigned", None)], + indexes: Vec::new(), + foreign_keys: vec![foreign_key(ForeignKeyInfo { + name: "purchase_order_amendment_id_purchase_order_foreign".into(), + column: "id_purchase_order".into(), + ref_schema: None, + ref_table: "purchase_order".into(), + ref_column: "id".into(), + on_update: on_update.map(str::to_string), + on_delete: on_delete.map(str::to_string), + })], + triggers: Vec::new(), + ddl: None, + }; + SchemaDiffPreparationOptions { + source_tables: vec![table_info("purchase_order_amendment", "TABLE")], + target_tables: vec![table_info("purchase_order_amendment", "TABLE")], + source_details: vec![detail(source_actions)], + target_details: vec![detail(target_actions)], + database_type, + source_dialect: dialect, + target_dialect: dialect, + ..Default::default() + } + } + + #[test] + fn mysql_fk_restrict_and_no_action_are_equivalent() { + let cases = [ + ((Some("NO ACTION"), Some("RESTRICT")), (Some("RESTRICT"), Some("RESTRICT"))), + ((Some("restrict"), Some("NO ACTION")), (Some("NO ACTION"), Some("RESTRICT"))), + ((None, None), (Some("RESTRICT"), Some("NO ACTION"))), + ((Some(""), Some("RESTRICT")), (Some("NO ACTION"), None)), + ]; + for (source, target) in cases { + let result = prepare_schema_diff(mysql_fk_action_options( + DatabaseType::Mysql, + Some(DialectKind::Mysql), + source, + target, + )); + assert!(result.diffs.is_empty(), "{source:?} vs {target:?} reported {:?}", result.diffs); + assert!(!result.sync_sql.contains("FOREIGN KEY"), "no FK rebuild expected: {}", result.sync_sql); + } + } + + #[test] + fn mysql_fk_real_action_change_is_still_reported() { + let result = prepare_schema_diff(mysql_fk_action_options( + DatabaseType::Mysql, + Some(DialectKind::Mysql), + (Some("CASCADE"), Some("SET NULL")), + (Some("NO ACTION"), Some("RESTRICT")), + )); + assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); + let changes = &result.diffs[0].foreign_keys.as_ref().expect("fk diffs")[0].changes; + assert!(changes.iter().any(|change| change == "update: NO ACTION → CASCADE"), "{changes:?}"); + assert!(changes.iter().any(|change| change == "delete: NO ACTION → SET NULL"), "{changes:?}"); + } + + #[test] + fn postgres_fk_restrict_and_no_action_remain_distinct() { + let result = prepare_schema_diff(mysql_fk_action_options( + DatabaseType::Postgres, + Some(DialectKind::Postgres), + (Some("NO ACTION"), Some("RESTRICT")), + (Some("RESTRICT"), Some("RESTRICT")), + )); + assert_eq!(result.diffs.len(), 1, "PostgreSQL NO ACTION is deferrable, unlike RESTRICT"); + } + #[test] fn modified_foreign_key_sql_preserves_reference_schema_and_actions() { let table_diff = TableDiff {