fix(schema-diff): equate MySQL RESTRICT/NO ACTION and refresh metadata

This commit is contained in:
Alan Saputra Lengkoan authored and GitHub committed 2026-10-27 18:43:14 +08:00
1 parent 452aa46d54
commit 7958f7ad90
18 files changed
+210 -8

No files matched your search

@@ -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");
@@ -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));
@@ -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,
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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: {
+1
View File
@@ -8343,6 +8343,7 @@ export default withEnglishFallback({
batchSection: "バッチ",
batchPatterns: "テーブルパターン(カンマ区切り)",
dialectAuto: "自動",
refreshMetadata: "比較前にメタデータを更新",
},
},
tableMultiSelect: {
+1
View File
@@ -8447,6 +8447,7 @@ export default withEnglishFallback({
batchSection: "일괄",
batchPatterns: "테이블 패턴 (쉼표로 구분)",
dialectAuto: "자동",
refreshMetadata: "비교 전 메타데이터 새로 고침",
},
},
dataCompare: {
+1
View File
@@ -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)",
+1
View File
@@ -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: {
+1
View File
@@ -9898,6 +9898,7 @@ export default withEnglishFallback({
batchSection: "Пакетно",
batchPatterns: "Шаблоны таблиц (через запятую)",
dialectAuto: "авто",
refreshMetadata: "Обновлять метаданные перед сравнением",
},
},
tableMultiSelect: {
+1
View File
@@ -10267,6 +10267,7 @@ export default withEnglishFallback({
compareCharset: "เปรียบเทียบชุดอักขระและ Collation",
ignoreTableNameCase: "ละเว้นตัวพิมพ์เล็กใหญ่ของชื่อตาราง",
ignoreColumnNameCase: "ละเว้นตัวพิมพ์เล็กใหญ่ของชื่อคอลัมน์",
refreshMetadata: "รีเฟรชเมทาดาทาก่อนเปรียบเทียบ",
detectRenames: "ตรวจหาการเปลี่ยนชื่อ",
detectTableRenames: "ตรวจหาการเปลี่ยนชื่อตาราง",
enableRollback: "เปิดใช้ Rollback",
+1
View File
@@ -9691,6 +9691,7 @@ export default withEnglishFallback({
compareCharset: "比较字符集和排序规则",
ignoreTableNameCase: "忽略表名大小写",
ignoreColumnNameCase: "忽略字段名大小写",
refreshMetadata: "比较前刷新元数据",
detectRenames: "检测重命名",
detectTableRenames: "检测表重命名",
enableRollback: "启用回滚",
+1
View File
@@ -7447,6 +7447,7 @@ export default withEnglishFallback({
batchSection: "批次模式",
batchPatterns: "資料表模式(逗號分隔)",
dialectAuto: "自動",
refreshMetadata: "比較前重新整理中繼資料",
},
},
tableMultiSelect: {
@@ -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 },
+8
View File
@@ -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",
+132 -8
View File
@@ -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<TableDiff> {
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<TableDiff> {
&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<String> {
.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<String> {
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<ForeignKeyDiff> {
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<ForeignKeyDiff> {
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<String> {
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<DialectKind>,
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 {