From 3123e962c8b8d7b8def9a6835f677efc2a4bf25f Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 6 Sep 2026 18:24:44 +0800 Subject: [PATCH] feat(schema-diff): support case-insensitive table and column matching --- .../components/diff/SchemaDiffConfigStep.vue | 8 +- .../src/components/diff/SchemaDiffDialog.vue | 2 + .../diff/__tests__/SchemaDiffDialog.spec.ts | 2 + .../__tests__/SchemaDiffOptionsPanel.spec.ts | 11 +- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../schema/schemaDiffTableFilter.spec.ts | 11 + .../schema/schemaDiffTableMapping.spec.ts | 13 + apps/desktop/src/lib/schema/schemaDiff.ts | 2 + .../src/lib/schema/schemaDiffOptions.ts | 2 + .../src/lib/schema/schemaDiffTableFilter.ts | 5 +- .../src/lib/schema/schemaDiffTableMapping.ts | 71 ++-- apps/desktop/src/types/schemaDiff.ts | 6 + crates/dbx-core/src/schema_diff.rs | 388 +++++++++++++++--- .../tests/api_contract_verification.rs | 23 ++ .../tests/schema_diff_case_options.rs | 258 ++++++++++++ 23 files changed, 726 insertions(+), 94 deletions(-) create mode 100644 crates/dbx-core/tests/schema_diff_case_options.rs diff --git a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue index c175f89bf5..3de42fa536 100644 --- a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue @@ -83,17 +83,17 @@ const restrictTables = ref(false); const localSelectedTables = ref([]); const activeTableMappings = computed(() => props.options?.tableMappings ?? []); -const tableMatches = computed(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value)); +const tableMatches = computed(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value, props.options.ignoreTableNameCase)); const matchedTableCount = computed(() => tableMatches.value.filter((match) => match.targetTable).length); const missingTargetTables = computed(() => tableMatches.value.filter((match) => !match.targetTable).map((match) => match.sourceTable)); const tableMappingConflictSource = ref(null); function targetTableOptions(sourceTable: string): string[] { - return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value); + return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value, props.options.ignoreTableNameCase); } function handleTableMappingUpdate(sourceTable: string, targetTable: string) { - const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable); + const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable, props.options.ignoreTableNameCase); if (update.accepted) { tableMappingConflictSource.value = null; emitTableMappings(update.mappings); @@ -107,7 +107,7 @@ function emitTableMappings(nextMappings: SchemaDiffTableMapping[]) { } function reconcileTableMappings(selectedTables: string[], targetTables = targetTableList.value) { - const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value); + const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value, props.options.ignoreTableNameCase) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value); emitTableMappings(nextMappings); } diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 0b09c24950..52412bae2d 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -591,6 +591,8 @@ async function handleCompare() { ignoreComments: ignoreComments.value, cascadeDelete: opts?.cascadeDelete ?? false, compareColumnOrder: opts.compareColumnOrder, + ignoreTableNameCase: opts.ignoreTableNameCase, + ignoreColumnNameCase: opts.ignoreColumnNameCase, detectRenames: opts?.detectRenames ?? false, detectTableRenames: opts?.detectTableRenames ?? false, renameThreshold: opts?.renameThreshold ?? 0.5, diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts index dd9ef8d02c..86a8bb1607 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts @@ -112,6 +112,8 @@ describe("SchemaDiffDialog fullscreen layout", () => { expect(configStepSource).toContain("tableMatchStatus.${match.kind}"); expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"'); expect(dialogSource).toContain("tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings"); + expect(dialogSource).toContain("ignoreTableNameCase: opts.ignoreTableNameCase"); + expect(dialogSource).toContain("ignoreColumnNameCase: opts.ignoreColumnNameCase"); expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);"); }); diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts index 2225d3a321..23ba99ed15 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts @@ -2,7 +2,8 @@ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_POSTGRES_OPTIONS, type SchemaDiffCompareOptions, type SchemaDiffOptionItem } from "@/types/schemaDiff"; +import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions"; +import { DEFAULT_POSTGRES_OPTIONS, normalizeSchemaDiffCompareOptions, type SchemaDiffCompareOptions, type SchemaDiffOptionItem } from "@/types/schemaDiff"; vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); @@ -49,6 +50,14 @@ afterEach(() => { }); describe("SchemaDiffOptionsPanel", () => { + it("exposes independent case-insensitive name options with false defaults", () => { + const ids = getSchemaDiffOptionsForDbType("postgres").map((item) => item.id); + expect(ids).toEqual(expect.arrayContaining(["ignoreTableNameCase", "ignoreColumnNameCase"])); + const normalized = normalizeSchemaDiffCompareOptions({}); + expect(normalized.ignoreTableNameCase).toBe(false); + expect(normalized.ignoreColumnNameCase).toBe(false); + }); + it("keeps unsaved checkbox edits when the parent refreshes equivalent options", async () => { const options = ref({ ...DEFAULT_POSTGRES_OPTIONS }); const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }]; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 22cf9d80df..7ccfb6ce35 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6325,6 +6325,8 @@ export default { cascadeDelete: "Use CASCADE delete", sequenceLastValues: "Compare sequence last values", compareColumnOrder: "Compare column order", + ignoreTableNameCase: "Ignore table name case", + ignoreColumnNameCase: "Ignore column name case", 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 28741eefac..d700763656 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5933,6 +5933,8 @@ export default withEnglishFallback({ cascadeDelete: "Use CASCADE delete", sequenceLastValues: "Compare sequence last values", compareColumnOrder: "Compare column order", + ignoreTableNameCase: "Ignorar mayúsculas y minúsculas del nombre de la tabla", + ignoreColumnNameCase: "Ignorar mayúsculas y minúsculas del nombre de la columna", detectRenames: "Detectar renombrados", detectTableRenames: "Detectar renombrados de tabla", enableRollback: "Habilitar reversión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 95e5404e6a..b447375c29 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5933,6 +5933,8 @@ export default withEnglishFallback({ cascadeDelete: "Usa CASCADE delete", sequenceLastValues: "Confronta ultimi valori sequenza", compareColumnOrder: "Confronta ordine colonne", + ignoreTableNameCase: "Ignora maiuscole/minuscole del nome della tabella", + ignoreColumnNameCase: "Ignora maiuscole/minuscole del nome della colonna", detectRenames: "Rileva ridenominazioni", detectTableRenames: "Rileva ridenominazioni tabelle", enableRollback: "Abilita rollback", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b5b7848623..0d9ed1f63f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5971,6 +5971,8 @@ export default withEnglishFallback({ cascadeDelete: "CASCADE削除を使用", sequenceLastValues: "シーケンス最終値を比較", compareColumnOrder: "列順序を比較", + ignoreTableNameCase: "テーブル名の大文字小文字を無視", + ignoreColumnNameCase: "列名の大文字小文字を無視", detectRenames: "名前変更を検出", detectTableRenames: "テーブル名変更を検出", enableRollback: "ロールバックを有効化", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3563c19c1a..4251dc8aa2 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5674,6 +5674,8 @@ export default withEnglishFallback({ cascadeDelete: "CASCADE 삭제 사용", sequenceLastValues: "시퀀스 마지막 값 비교", compareColumnOrder: "컬럼 순서 비교", + ignoreTableNameCase: "테이블 이름 대소문자 무시", + ignoreColumnNameCase: "컬럼 이름 대소문자 무시", detectRenames: "이름 변경 감지", detectTableRenames: "테이블 이름 변경 감지", enableRollback: "롤백 활성화", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c3ba345b6f..6306234762 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5935,6 +5935,8 @@ export default withEnglishFallback({ cascadeDelete: "Usar CASCADE delete", sequenceLastValues: "Comparar últimos valores de sequência", compareColumnOrder: "Comparar ordem das colunas", + ignoreTableNameCase: "Ignorar maiúsculas/minúsculas do nome da tabela", + ignoreColumnNameCase: "Ignorar maiúsculas/minúsculas do nome da coluna", detectRenames: "Detectar renomeações", detectTableRenames: "Detectar renomeações de tabela", enableRollback: "Habilitar rollback", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 14fc5dae8e..84595eeb5d 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6238,6 +6238,8 @@ export default withEnglishFallback({ cascadeDelete: "CASCADE silme kullan", sequenceLastValues: "Dizi son değerlerini karşılaştır", compareColumnOrder: "Sütun sırasını karşılaştır", + ignoreTableNameCase: "Tablo adı büyük/küçük harflerini yoksay", + ignoreColumnNameCase: "Sütun adı büyük/küçük harflerini yoksay", detectRenames: "Yeniden adlandırmaları algıla", detectTableRenames: "Tablo yeniden adlandırmalarını algıla", enableRollback: "Geri almayı etkinleştir", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2c6f4adde3..554e008a0d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6308,6 +6308,8 @@ export default withEnglishFallback({ cascadeDelete: "使用级联删除", sequenceLastValues: "比较序列最后值", compareColumnOrder: "比较字段顺序", + ignoreTableNameCase: "忽略表名大小写", + ignoreColumnNameCase: "忽略字段名大小写", detectRenames: "检测重命名", detectTableRenames: "检测表重命名", enableRollback: "启用回滚", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 13a772b164..663d19fd2e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5253,6 +5253,8 @@ export default withEnglishFallback({ cascadeDelete: "使用級聯刪除", sequenceLastValues: "比較序列最後值", compareColumnOrder: "比較欄位順序", + ignoreTableNameCase: "忽略資料表名稱大小寫", + ignoreColumnNameCase: "忽略欄位名稱大小寫", detectRenames: "檢測重新命名", detectTableRenames: "檢測表重新命名", enableRollback: "啟用回溯", diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts index 19151f9b87..584fac221f 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts @@ -97,6 +97,17 @@ describe("schemaDiffTableFilter", () => { expect(result.targetTables.map((t) => t.name)).toEqual(["a", "c"]); }); + it("loads a unique case-insensitive target for explicitly selected tables", () => { + const options = { ...baseOptions, ignoreTableNameCase: true }; + const source: TableInfo[] = [{ name: "USER_INFO", table_type: "BASE TABLE" }]; + const target: TableInfo[] = [ + { name: "user_info", table_type: "BASE TABLE" }, + { name: "other", table_type: "BASE TABLE" }, + ]; + const result = filterSchemaDiffTables(source, target, compileSchemaDiffTableFilter(options), options, ["USER_INFO"]); + expect(result.targetTables.map((table) => table.name)).toEqual(["user_info"]); + }); + it("does not include unselected target tables that would otherwise look like drops", () => { const source: TableInfo[] = [{ name: "a", table_type: "BASE TABLE" }]; const target: TableInfo[] = [ diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts index 8afb115185..015486aa2a 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts @@ -15,6 +15,17 @@ describe("schema diff table mappings", () => { ]); }); + it("matches table names case-insensitively only when enabled and rejects ambiguous candidates", () => { + expect(reconcileSchemaDiffTableMappings(["User"], ["user"], [], true)).toEqual([{ sourceTable: "User", targetTable: "user" }]); + expect(reconcileSchemaDiffTableMappings(["user"], ["User", "USER"], [], true)).toEqual([]); + expect(reconcileSchemaDiffTableMappings(["foo", "FOO"], ["FOO"], [], true)).toEqual([{ sourceTable: "FOO", targetTable: "FOO" }]); + expect(buildSchemaDiffTableMatches(["User"], ["user"], [], true)).toEqual([{ sourceTable: "User", targetTable: "user", kind: "automatic" }]); + }); + + it("keeps explicit table mappings ahead of case-insensitive automatic matching", () => { + expect(reconcileSchemaDiffTableMappings(["source_a"], ["SOURCE_A", "target_b"], [{ sourceTable: "source_a", targetTable: "target_b" }], true)).toEqual([{ sourceTable: "source_a", targetTable: "target_b" }]); + }); + it("leaves a source unmatched when only some names exist on target", () => { expect(buildSchemaDiffTableMatches(["a", "b", "c"], ["a", "x", "c"], [])).toEqual([ { sourceTable: "a", targetTable: "a", kind: "automatic" }, @@ -79,6 +90,8 @@ describe("schema diff table mappings", () => { const restored = normalizeSchemaDiffCompareOptions(JSON.parse(JSON.stringify(options))); expect(restored.tableMappings).toEqual([{ sourceTable: "a", targetTable: "a_new" }]); expect(normalizeSchemaDiffCompareOptions({}).tableMappings).toEqual([]); + expect(normalizeSchemaDiffCompareOptions({}).ignoreTableNameCase).toBe(false); + expect(normalizeSchemaDiffCompareOptions({}).ignoreColumnNameCase).toBe(false); }); it("swaps each mapping direction", () => { diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index 9aa387cbcb..1f13f12eed 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -223,6 +223,8 @@ export interface SchemaDiffPreparationOptions { ignoreComments?: boolean; cascadeDelete?: boolean; compareColumnOrder?: boolean; + ignoreTableNameCase?: boolean; + ignoreColumnNameCase?: boolean; detectRenames?: boolean; detectTableRenames?: boolean; renameThreshold?: number; diff --git a/apps/desktop/src/lib/schema/schemaDiffOptions.ts b/apps/desktop/src/lib/schema/schemaDiffOptions.ts index 07847bafad..309fa9cb27 100644 --- a/apps/desktop/src/lib/schema/schemaDiffOptions.ts +++ b/apps/desktop/src/lib/schema/schemaDiffOptions.ts @@ -23,6 +23,8 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [ { id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false }, { id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true }, { 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: "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/lib/schema/schemaDiffTableFilter.ts b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts index 2a5e4893b0..88e974645e 100644 --- a/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts +++ b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts @@ -50,11 +50,11 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick & { +type SchemaDiffTableFilterOptions = Pick & { tableMappings?: SchemaDiffCompareOptions["tableMappings"]; }; -export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables { +export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true, ignoreTableNameCase: false }, selectedTables?: string[]): FilteredSchemaDiffTables { // Visual (explicit) table selection is applied first, then the existing include/exclude // regex filter. With an explicit selection, targets are selected by the effective // source→target mapping; without one, the legacy all-tables path is unchanged. @@ -72,6 +72,7 @@ export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: filteredSourceTables.map((table) => table.name), filteredTargetTables.map((table) => table.name), options.tableMappings ?? [], + options.ignoreTableNameCase, ).map((mapping) => mapping.targetTable), ); diff --git a/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts b/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts index 3654ac06d1..ff49eda519 100644 --- a/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts +++ b/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts @@ -23,6 +23,10 @@ function firstMappingBySource(mappings: readonly SchemaDiffTableMapping[]): Map< return bySource; } +function identifiersEqual(left: string, right: string, ignoreCase: boolean): boolean { + return ignoreCase ? left.toLowerCase() === right.toLowerCase() : left === right; +} + /** Keep mappings for selected source tables while preserving their current target, even if the target list is loading. */ export function pruneSchemaDiffTableMappings(selectedTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] { const selected = new Set(selectedTables); @@ -40,45 +44,67 @@ export function pruneSchemaDiffTableMappings(selectedTables: readonly string[], * A target can only be consumed once, so stale duplicate mappings cannot leak * into the comparison request. */ -export function reconcileSchemaDiffTableMappings(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] { +export function reconcileSchemaDiffTableMappings(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): SchemaDiffTableMapping[] { const targets = new Set(targetTables); const explicitBySource = firstMappingBySource(mappings); - const usedTargets = new Set(); - const next: SchemaDiffTableMapping[] = []; + const reservedExplicitTargets = new Set(); + const explicitTargetsBySource = new Map(); for (const sourceTable of selectedTables) { - if (!sourceTable) continue; - - let targetTable = explicitBySource.get(sourceTable); - if (!targetTable || !targets.has(targetTable) || usedTargets.has(targetTable)) { - targetTable = targets.has(sourceTable) && !usedTargets.has(sourceTable) ? sourceTable : undefined; + const targetTable = explicitBySource.get(sourceTable); + if (targetTable && targets.has(targetTable) && !reservedExplicitTargets.has(targetTable)) { + reservedExplicitTargets.add(targetTable); + explicitTargetsBySource.set(sourceTable, targetTable); } - - if (!targetTable) continue; - usedTargets.add(targetTable); - next.push({ sourceTable, targetTable }); } - return next; + const resolvedTargetsBySource = new Map(explicitTargetsBySource); + const usedTargets = new Set(reservedExplicitTargets); + + // Resolve every exact match before considering case-insensitive candidates. + for (const sourceTable of selectedTables) { + if (!sourceTable || resolvedTargetsBySource.has(sourceTable)) continue; + if (targets.has(sourceTable) && !usedTargets.has(sourceTable)) { + usedTargets.add(sourceTable); + resolvedTargetsBySource.set(sourceTable, sourceTable); + } + } + + if (ignoreTableNameCase) { + for (const sourceTable of selectedTables) { + if (!sourceTable || resolvedTargetsBySource.has(sourceTable)) continue; + const candidates = targetTables.filter((candidate) => !usedTargets.has(candidate) && identifiersEqual(sourceTable, candidate, true)); + if (candidates.length === 1) { + usedTargets.add(candidates[0]); + resolvedTargetsBySource.set(sourceTable, candidates[0]); + } + } + } + + return selectedTables.flatMap((sourceTable) => { + const targetTable = sourceTable ? resolvedTargetsBySource.get(sourceTable) : undefined; + return targetTable ? [{ sourceTable, targetTable }] : []; + }); } -export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMatch[] { - const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings); +export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): SchemaDiffTableMatch[] { + const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings, ignoreTableNameCase); const targetBySource = new Map(reconciled.map((mapping) => [mapping.sourceTable, mapping.targetTable])); + const explicitSources = new Set(firstMappingBySource(mappings).keys()); return selectedTables.map((sourceTable) => { const targetTable = targetBySource.get(sourceTable); let kind: SchemaDiffTableMatchKind; if (!targetTable) kind = "unmatched"; - else if (targetTable === sourceTable) kind = "automatic"; + else if (!explicitSources.has(sourceTable) && identifiersEqual(targetTable, sourceTable, ignoreTableNameCase)) kind = "automatic"; else kind = "manual"; return { sourceTable, targetTable, kind }; }); } /** Update one mapping and reject a target already assigned to another source. */ -export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableMapping[], sourceTable: string, targetTable: string): SchemaDiffTableMappingUpdate { - const conflict = mappings.find((mapping) => mapping.sourceTable !== sourceTable && mapping.targetTable === targetTable); +export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableMapping[], sourceTable: string, targetTable: string, ignoreTableNameCase = false): SchemaDiffTableMappingUpdate { + const conflict = mappings.find((mapping) => mapping.sourceTable !== sourceTable && identifiersEqual(mapping.targetTable, targetTable, ignoreTableNameCase)); if (targetTable && conflict) { return { mappings: [...mappings], accepted: false, conflictSource: conflict.sourceTable }; } @@ -90,13 +116,10 @@ export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableM } /** Hide targets occupied by another source while retaining the current value for the edited row. */ -export function availableSchemaDiffTargetTables(sourceTable: string, targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): string[] { +export function availableSchemaDiffTargetTables(sourceTable: string, targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): string[] { const currentTarget = mappings.find((mapping) => mapping.sourceTable === sourceTable)?.targetTable; - const occupiedTargets = new Set(); - for (const mapping of mappings) { - if (mapping.sourceTable !== sourceTable) occupiedTargets.add(mapping.targetTable); - } - return targetTables.filter((targetTable) => targetTable === currentTarget || !occupiedTargets.has(targetTable)); + const occupiedTargets = mappings.filter((mapping) => mapping.sourceTable !== sourceTable).map((mapping) => mapping.targetTable); + return targetTables.filter((targetTable) => identifiersEqual(targetTable, currentTarget ?? "", ignoreTableNameCase) || !occupiedTargets.some((occupiedTarget) => identifiersEqual(occupiedTarget, targetTable, ignoreTableNameCase))); } /** Reverse source/target for swap, dropping malformed duplicate targets defensively. */ diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index 40bc7a2f24..f6c9be60af 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -22,6 +22,8 @@ export interface SchemaDiffCompareOptions { cascadeDelete: boolean; sequenceLastValues: boolean; compareColumnOrder: boolean; + ignoreTableNameCase: boolean; + ignoreColumnNameCase: boolean; tableIncludePattern: string; tableExcludePattern: string; tableFilterPriority: SchemaDiffTableFilterPriority; @@ -100,6 +102,8 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = { cascadeDelete: false, sequenceLastValues: true, compareColumnOrder: false, + ignoreTableNameCase: false, + ignoreColumnNameCase: false, tableIncludePattern: "", tableExcludePattern: "", tableFilterPriority: "exclude", @@ -133,6 +137,8 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = { cascadeDelete: false, sequenceLastValues: false, compareColumnOrder: false, + ignoreTableNameCase: false, + ignoreColumnNameCase: false, tableIncludePattern: "", tableExcludePattern: "", tableFilterPriority: "exclude", diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index 81e73339ac..68a2f75a69 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -402,6 +402,10 @@ pub struct SchemaDiffPreparationOptions { #[serde(default)] pub compare_column_order: bool, #[serde(default)] + pub ignore_table_name_case: bool, + #[serde(default)] + pub ignore_column_name_case: bool, + #[serde(default)] pub detect_renames: bool, #[serde(default)] pub detect_table_renames: bool, @@ -577,13 +581,31 @@ impl DependencyGraph { Self::build_with_functions(details, tables, &[], &[]) } + pub fn build_with_options( + details: &[TableSchemaDetail], + tables: &[TableInfo], + ignore_table_name_case: bool, + ) -> Self { + Self::build_with_functions_and_options(details, tables, &[], &[], ignore_table_name_case) + } + /// Extended build: also extracts dependencies from view DDLs, triggers, and function/sequence definitions. /// Falls back to regex-based text scanning when no live DB query is available. pub fn build_with_functions( + details: &[TableSchemaDetail], + tables: &[TableInfo], + functions: &[FunctionInfo], + sequences: &[SequenceInfo], + ) -> Self { + Self::build_with_functions_and_options(details, tables, functions, sequences, false) + } + + pub fn build_with_functions_and_options( details: &[TableSchemaDetail], tables: &[TableInfo], functions: &[FunctionInfo], _sequences: &[SequenceInfo], + ignore_table_name_case: bool, ) -> Self { let table_names: HashSet<&str> = tables.iter().filter(|t| !t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); @@ -608,14 +630,25 @@ impl DependencyGraph { for table_name in &table_names { if let Some(detail) = detail_map.get(table_name) { for fk in &detail.foreign_keys { - if table_names.contains(fk.ref_table.as_str()) { + let referenced_table = if table_names.contains(fk.ref_table.as_str()) { + Some(fk.ref_table.as_str()) + } else if ignore_table_name_case { + let mut candidates = + table_names.iter().copied().filter(|name| name.eq_ignore_ascii_case(&fk.ref_table)); + let candidate = candidates.next(); + candidate.filter(|_| candidates.next().is_none()) + } else { + None + }; + + if let Some(referenced_table) = referenced_table { if let Some(node) = nodes.get_mut(*table_name) { - if !node.depends_on.contains(&fk.ref_table) { - node.depends_on.push(fk.ref_table.clone()); + if !node.depends_on.iter().any(|name| name == referenced_table) { + node.depends_on.push(referenced_table.to_string()); } } - if let Some(ref_node) = nodes.get_mut(&fk.ref_table) { - if !ref_node.depended_by.iter().any(|d| d == *table_name) { + if let Some(ref_node) = nodes.get_mut(referenced_table) { + if !ref_node.depended_by.iter().any(|name| name == *table_name) { ref_node.depended_by.push((*table_name).to_string()); } } @@ -1075,13 +1108,48 @@ pub fn diff_columns_with_compatibility( target_dialect: DialectKind, compatibility_threshold: f64, field_mappings: &[FieldMapping], +) -> (Vec, Vec) { + diff_columns_with_compatibility_options( + source, + target, + ignore_comments, + compare_column_order, + source_dialect, + target_dialect, + compatibility_threshold, + field_mappings, + false, + ) +} + +#[allow(clippy::too_many_arguments)] +fn diff_columns_with_compatibility_options( + source: &[ColumnInfo], + target: &[ColumnInfo], + ignore_comments: bool, + compare_column_order: bool, + source_dialect: DialectKind, + target_dialect: DialectKind, + compatibility_threshold: f64, + field_mappings: &[FieldMapping], + ignore_column_name_case: bool, ) -> (Vec, Vec) { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(source_dialect, target_dialect); let engine = DefaultTypeInferenceEngine; - let basic_diffs = diff_columns_with_options(source, target, ignore_comments, compare_column_order, false, 0.5); + let basic_diffs = diff_columns_with_identifier_options( + source, + target, + ignore_comments, + compare_column_order, + false, + 0.5, + None, + None, + ignore_column_name_case, + ); let mut warnings = Vec::new(); let mut enhanced_diffs = Vec::new(); @@ -1531,6 +1599,8 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard ignore_comments: options.ignore_comments, cascade_delete: options.cascade_delete, compare_column_order: options.compare_column_order, + ignore_table_name_case: options.ignore_table_name_case, + ignore_column_name_case: options.ignore_column_name_case, source_dialect: options.source_dialect, target_dialect: options.target_dialect, table_mappings: options.table_mappings.clone(), @@ -1802,6 +1872,8 @@ impl Default for SchemaDiffPreparationOptions { ignore_comments: false, cascade_delete: false, compare_column_order: false, + ignore_table_name_case: false, + ignore_column_name_case: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, @@ -1895,7 +1967,11 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP let dialect_str = options.source_dialect.map(|d| d.label().to_string()).unwrap_or_else(|| "generic".to_string()); let options = AstTransmitFilter::filter_diff_preparation_options(options, &dialect_str); - let dep_graph = DependencyGraph::build(&options.source_details, &options.source_tables); + let dep_graph = DependencyGraph::build_with_options( + &options.source_details, + &options.source_tables, + options.ignore_table_name_case, + ); let mut diffs = if let Some(ref strategy) = options.shard_strategy { shard_diff(&options, strategy) @@ -1956,7 +2032,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP if let Some(source_detail) = options.source_details.iter().find(|d| d.name == diff.name) { let target_name = diff.target_name.as_deref().unwrap_or(&diff.name); if let Some(target_detail) = options.target_details.iter().find(|d| d.name == target_name) { - let (_, warnings) = diff_columns_with_compatibility( + let (_, warnings) = diff_columns_with_compatibility_options( &source_detail.columns, &target_detail.columns, options.ignore_comments, @@ -1965,6 +2041,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP tgt_dialect, options.compatibility_threshold, &options.field_mappings, + options.ignore_column_name_case, ); all_warnings.extend(warnings); } @@ -2076,10 +2153,19 @@ struct SchemaDiffTableNameResolution { target_only: Vec, } +fn identifiers_equal(left: &str, right: &str, ignore_case: bool) -> bool { + if ignore_case { + left.eq_ignore_ascii_case(right) + } else { + left == right + } +} + fn resolve_schema_diff_table_names( source_names: &[String], target_names: &[String], mappings: &[SchemaDiffTableMapping], + ignore_table_name_case: bool, ) -> SchemaDiffTableNameResolution { let target_set: HashSet<&str> = target_names.iter().map(String::as_str).collect(); let mut explicit_by_source: HashMap<&str, &str> = HashMap::new(); @@ -2089,24 +2175,52 @@ fn resolve_schema_diff_table_names( } } - let mut used_targets = HashSet::new(); + // Reserve valid explicit targets before automatic matching so an explicit mapping + // wins even when another source table appears earlier in the input list. + let mut explicit_targets_by_source = HashMap::new(); + let mut reserved_explicit_targets = HashSet::new(); + for source_name in source_names { + let Some(target_name) = explicit_by_source.get(source_name.as_str()).copied() else { continue }; + if target_set.contains(target_name) && reserved_explicit_targets.insert(target_name) { + explicit_targets_by_source.insert(source_name.clone(), target_name.to_string()); + } + } + + let mut resolved_targets_by_source = explicit_targets_by_source.clone(); + let mut used_targets: HashSet<&str> = reserved_explicit_targets.clone(); + + // Resolve every exact match before considering case-insensitive candidates. + // This keeps an exact match from losing its target to an earlier case-only match. + for source_name in source_names { + if resolved_targets_by_source.contains_key(source_name) { + continue; + } + if target_set.contains(source_name.as_str()) && used_targets.insert(source_name.as_str()) { + resolved_targets_by_source.insert(source_name.clone(), source_name.clone()); + } + } + + if ignore_table_name_case { + for source_name in source_names { + if resolved_targets_by_source.contains_key(source_name) { + continue; + } + let mut candidates = target_names.iter().filter(|target_name| { + !used_targets.contains(target_name.as_str()) && identifiers_equal(source_name, target_name, true) + }); + let candidate = candidates.next(); + if let Some(target_name) = candidate.filter(|_| candidates.next().is_none()) { + used_targets.insert(target_name.as_str()); + resolved_targets_by_source.insert(source_name.clone(), target_name.clone()); + } + } + } + let mut pairs = Vec::new(); let mut source_only = Vec::new(); for source_name in source_names { - let explicit_target = explicit_by_source - .get(source_name.as_str()) - .copied() - .filter(|target_name| target_set.contains(target_name) && !used_targets.contains(target_name)); - let target_name = explicit_target.or_else(|| { - target_set - .contains(source_name.as_str()) - .then_some(source_name.as_str()) - .filter(|target_name| !used_targets.contains(target_name)) - }); - - if let Some(target_name) = target_name { - used_targets.insert(target_name); - pairs.push((source_name.clone(), target_name.to_string())); + if let Some(target_name) = resolved_targets_by_source.get(source_name) { + pairs.push((source_name.clone(), target_name.clone())); } else { source_only.push(source_name.clone()); } @@ -2151,10 +2265,19 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { .map(|table| table.name.clone()) .collect(); - let table_resolution = - resolve_schema_diff_table_names(&source_table_names, &target_table_names, &options.table_mappings); - let view_resolution = - resolve_schema_diff_table_names(&source_view_names, &target_view_names, &options.table_mappings); + let table_resolution = resolve_schema_diff_table_names( + &source_table_names, + &target_table_names, + &options.table_mappings, + options.ignore_table_name_case, + ); + let view_resolution = resolve_schema_diff_table_names( + &source_view_names, + &target_view_names, + &options.table_mappings, + options.ignore_table_name_case, + ); + let table_pairs = table_resolution.pairs.clone(); let mut result = Vec::new(); // A foreign key whose `ref_table` is itself one of the tables being compared is a @@ -2214,7 +2337,9 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { 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(), @@ -2390,7 +2515,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { for (name, target_name) in table_resolution.pairs { let Some(source) = source_details.get(name.as_str()) else { continue }; let Some(target) = target_details.get(target_name.as_str()) else { continue }; - let column_diffs = diff_columns_with_dialect_options( + let column_diffs = diff_columns_with_identifier_options( &source.columns, &target.columns, options.ignore_comments, @@ -2399,8 +2524,9 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { options.rename_threshold, options.source_dialect, options.target_dialect, + options.ignore_column_name_case, ); - let index_diffs = diff_indexes(&source.indexes, &target.indexes); + let index_diffs = diff_indexes_with_options(&source.indexes, &target.indexes, options.ignore_column_name_case); let normalized_source_fks: Vec = source .foreign_keys .iter() @@ -2409,13 +2535,23 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { fk, &source_table_name_set, &target_table_name_set, + &table_pairs, &options.table_mappings, + options.ignore_table_name_case, ) }) .collect(); - let normalized_target_fks: Vec = - target.foreign_keys.iter().map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set)).collect(); - let foreign_key_diffs = diff_foreign_keys(&normalized_source_fks, &normalized_target_fks); + let normalized_target_fks: Vec = target + .foreign_keys + .iter() + .map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set, options.ignore_table_name_case)) + .collect(); + let foreign_key_diffs = diff_foreign_keys_with_options( + &normalized_source_fks, + &normalized_target_fks, + options.ignore_table_name_case, + options.ignore_column_name_case, + ); let trigger_diffs = diff_triggers(&source.triggers, &target.triggers); let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None); let target_comment = target_table_comments.get(target_name.as_str()).cloned().unwrap_or(None); @@ -2845,18 +2981,77 @@ fn diff_columns_with_dialect_options( rename_threshold: f64, source_dialect: Option, target_dialect: Option, +) -> Vec { + diff_columns_with_identifier_options( + source, + target, + ignore_comments, + compare_column_order, + detect_renames, + rename_threshold, + source_dialect, + target_dialect, + false, + ) +} + +fn resolve_column_matches( + source: &[ColumnInfo], + target: &[ColumnInfo], + ignore_column_name_case: bool, +) -> Vec> { + let mut matches = vec![None; source.len()]; + let mut used_targets = HashSet::new(); + + // Exact matches always win, even when a case-insensitive candidate is also available. + for (source_index, source_column) in source.iter().enumerate() { + if let Some(target_index) = target.iter().enumerate().find_map(|(target_index, target_column)| { + (target_column.name == source_column.name && !used_targets.contains(&target_index)).then_some(target_index) + }) { + matches[source_index] = Some(target_index); + used_targets.insert(target_index); + } + } + + if ignore_column_name_case { + for (source_index, source_column) in source.iter().enumerate() { + if matches[source_index].is_some() { + continue; + } + let mut candidates = target.iter().enumerate().filter(|(target_index, target_column)| { + !used_targets.contains(target_index) && target_column.name.eq_ignore_ascii_case(&source_column.name) + }); + let candidate = candidates.next().map(|(target_index, _)| target_index); + if let Some(target_index) = candidate.filter(|_| candidates.next().is_none()) { + matches[source_index] = Some(target_index); + used_targets.insert(target_index); + } + } + } + + matches +} + +#[allow(clippy::too_many_arguments)] +fn diff_columns_with_identifier_options( + source: &[ColumnInfo], + target: &[ColumnInfo], + ignore_comments: bool, + compare_column_order: bool, + detect_renames: bool, + rename_threshold: f64, + source_dialect: Option, + target_dialect: Option, + ignore_column_name_case: bool, ) -> Vec { let mut diffs = Vec::new(); - let target_map: HashMap<&str, &ColumnInfo> = target.iter().map(|column| (column.name.as_str(), column)).collect(); - let source_map: HashMap<&str, &ColumnInfo> = source.iter().map(|column| (column.name.as_str(), column)).collect(); - let target_position_map: HashMap<&str, usize> = - target.iter().enumerate().map(|(index, column)| (column.name.as_str(), index)).collect(); - let can_compare_order = compare_column_order - && source.len() == target.len() - && source.iter().all(|column| target_map.contains_key(column.name.as_str())); + let column_matches = resolve_column_matches(source, target, ignore_column_name_case); + let can_compare_order = + compare_column_order && source.len() == target.len() && column_matches.iter().all(Option::is_some); for (source_index, source_column) in source.iter().enumerate() { - if let Some(target_column) = target_map.get(source_column.name.as_str()) { + if let Some(target_index) = column_matches[source_index] { + let target_column = &target[target_index]; let mut changes = Vec::new(); if !column_types_equal_for_dialects( &source_column.data_type, @@ -2892,12 +3087,8 @@ fn diff_columns_with_dialect_options( source_column.comment.as_deref().unwrap_or_default() )); } - if can_compare_order { - if let Some(target_index) = target_position_map.get(source_column.name.as_str()) { - if source_index != *target_index { - changes.push(format!("order: {} → {}", *target_index + 1, source_index + 1)); - } - } + if can_compare_order && source_index != target_index { + changes.push(format!("order: {} → {}", target_index + 1, source_index + 1)); } if !changes.is_empty() { diffs.push(ColumnDiff { @@ -2922,7 +3113,7 @@ fn diff_columns_with_dialect_options( } for (target_index, target_column) in target.iter().enumerate() { - if !source_map.contains_key(target_column.name.as_str()) { + if !column_matches.iter().flatten().any(|matched_index| *matched_index == target_index) { diffs.push(ColumnDiff { diff_type: "removed".to_string(), name: target_column.name.clone(), @@ -3016,6 +3207,33 @@ fn column_add_position(columns: &[ColumnInfo], index: usize) -> ColumnAddPositio } pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec { + diff_indexes_with_options(source, target, false) +} + +fn index_columns_equal(source: &IndexInfo, target: &IndexInfo, ignore_column_name_case: bool) -> bool { + source.columns.len() == target.columns.len() + && source.columns.iter().enumerate().all(|(index, source_column)| { + let target_column = &target.columns[index]; + let source_is_expression = source.key_is_expression.get(index).copied().unwrap_or(false); + let target_is_expression = target.key_is_expression.get(index).copied().unwrap_or(false); + if source_is_expression || target_is_expression { + source_column == target_column + } else { + identifiers_equal(source_column, target_column, ignore_column_name_case) + } + }) +} + +fn identifier_lists_equal(left: &[String], right: &[String], ignore_column_name_case: bool) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| identifiers_equal(left, right, ignore_column_name_case)) +} + +fn diff_indexes_with_options( + source: &[IndexInfo], + target: &[IndexInfo], + ignore_column_name_case: bool, +) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &IndexInfo> = target.iter().map(|index| (index.name.as_str(), index)).collect(); let source_map: HashMap<&str, &IndexInfo> = source.iter().map(|index| (index.name.as_str(), index)).collect(); @@ -3043,7 +3261,7 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec Vec) -> Option { .filter(|value| !value.is_empty()) } -fn normalize_self_referencing_fk(fk: &ForeignKeyInfo, own_table_names: &HashSet<&str>) -> ForeignKeyInfo { +fn normalize_self_referencing_fk( + fk: &ForeignKeyInfo, + own_table_names: &HashSet<&str>, + ignore_table_name_case: bool, +) -> ForeignKeyInfo { let mut normalized = fk.clone(); - if fk.ref_schema.is_some() && own_table_names.contains(fk.ref_table.as_str()) { + if fk.ref_schema.is_some() + && own_table_names.iter().any(|table_name| identifiers_equal(table_name, &fk.ref_table, ignore_table_name_case)) + { normalized.ref_schema = None; } normalized @@ -3126,19 +3350,42 @@ fn normalize_mapped_foreign_key( fk: &ForeignKeyInfo, source_table_names: &HashSet<&str>, target_table_names: &HashSet<&str>, + table_pairs: &[(String, String)], mappings: &[SchemaDiffTableMapping], + ignore_table_name_case: bool, ) -> ForeignKeyInfo { - let mut normalized = normalize_self_referencing_fk(fk, source_table_names); - if let Some(mapping) = mappings.iter().find(|mapping| { - mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str()) - }) { - normalized.ref_table = mapping.target_table.clone(); + let mut normalized = normalize_self_referencing_fk(fk, source_table_names, ignore_table_name_case); + let mapped_target = mappings + .iter() + .find(|mapping| { + mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str()) + }) + .map(|mapping| mapping.target_table.as_str()) + .or_else(|| { + table_pairs + .iter() + .find(|(source_table, _)| { + identifiers_equal(source_table, &normalized.ref_table, ignore_table_name_case) + }) + .map(|(_, target_table)| target_table.as_str()) + }); + if let Some(mapped_target) = mapped_target { + normalized.ref_table = mapped_target.to_string(); normalized.ref_schema = None; } normalized } pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec { + diff_foreign_keys_with_options(source, target, false, false) +} + +fn diff_foreign_keys_with_options( + source: &[ForeignKeyInfo], + target: &[ForeignKeyInfo], + ignore_table_name_case: bool, + ignore_column_name_case: bool, +) -> Vec { 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(); @@ -3156,10 +3403,10 @@ pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) - }; let mut changes = Vec::new(); - if source_fk.column != target_fk.column { + if !identifiers_equal(&source_fk.column, &target_fk.column, ignore_column_name_case) { changes.push(format!("column: {} → {}", target_fk.column, source_fk.column)); } - if source_fk.ref_table != target_fk.ref_table { + 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 { @@ -3169,7 +3416,7 @@ pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) - source_fk.ref_schema.as_deref().unwrap_or("") )); } - if source_fk.ref_column != target_fk.ref_column { + 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()); @@ -3921,6 +4168,15 @@ fn target_table_name(diff: &TableDiff) -> &str { diff.target_name.as_deref().unwrap_or(&diff.name) } +fn ddl_column_name(column: &ColumnDiff) -> &str { + if let (Some(source), Some(target)) = (&column.source, &column.target) { + if source.name.eq_ignore_ascii_case(&target.name) { + return &target.name; + } + } + &column.name +} + fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String { let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" }; let name = qualified_name(target_table_name(diff), db_type, schema); @@ -5482,12 +5738,16 @@ fn generate_schema_sync_sql_inner( } "modified" => { if let Some(source) = &column.source { - let mapped = convert_col(source); + let target_column_name = ddl_column_name(column); + let mut mapped = convert_col(source); + if mapped.name != target_column_name { + mapped.name = target_column_name.to_string(); + } if db_type == DatabaseType::SqlServer { if let Some(target_col) = &column.target { standalone_statements.extend(sqlserver_column_change_statements( &table, - &column.name, + target_column_name, source, target_col, &mapped.data_type, @@ -5503,7 +5763,7 @@ fn generate_schema_sync_sql_inner( )); } } else { - let name = quote_id(&column.name, db_type); + let name = quote_id(target_column_name, db_type); if column.changes.iter().any(|change| change.starts_with("type:")) { parts.push(format!(" ALTER COLUMN {name} TYPE {}", mapped.data_type)); } @@ -5618,7 +5878,7 @@ fn generate_schema_sync_sql_inner( if column.changes.iter().any(|change| change.starts_with("comment:")) { lines.extend(column_comment_sql( target_name, - &column.name, + ddl_column_name(column), source.comment.as_deref().unwrap_or_default(), db_type, schema, @@ -12155,6 +12415,8 @@ mod tests { ignore_comments: false, cascade_delete: false, compare_column_order: false, + ignore_table_name_case: false, + ignore_column_name_case: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index 7a635120c2..f10b2deacb 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -49,6 +49,8 @@ fn prepare_schema_diff_function_signature() { ignore_comments: false, cascade_delete: false, compare_column_order: false, + ignore_table_name_case: false, + ignore_column_name_case: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, @@ -67,6 +69,25 @@ fn prepare_schema_diff_function_signature() { let _result: SchemaDiffPreparation = prepare_schema_diff(options); } +#[test] +fn schema_diff_case_options_are_backward_compatible_and_use_camel_case() { + let legacy: SchemaDiffPreparationOptions = serde_json::from_value(serde_json::json!({ + "databaseType": "postgres" + })) + .unwrap(); + assert!(!legacy.ignore_table_name_case); + assert!(!legacy.ignore_column_name_case); + + let mut options = legacy; + options.ignore_table_name_case = true; + options.ignore_column_name_case = true; + let json = serde_json::to_value(options).unwrap(); + assert_eq!(json["ignoreTableNameCase"], true); + assert_eq!(json["ignoreColumnNameCase"], true); + assert!(json.get("ignore_table_name_case").is_none()); + assert!(json.get("ignore_column_name_case").is_none()); +} + /// Verify generate_schema_sync_sql accepts all arg types #[test] fn generate_schema_sync_sql_function_signature() { @@ -127,6 +148,8 @@ fn schema_diff_preparation_field_names() { ignore_comments: false, cascade_delete: false, compare_column_order: false, + ignore_table_name_case: false, + ignore_column_name_case: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, diff --git a/crates/dbx-core/tests/schema_diff_case_options.rs b/crates/dbx-core/tests/schema_diff_case_options.rs new file mode 100644 index 0000000000..9fa35238c8 --- /dev/null +++ b/crates/dbx-core/tests/schema_diff_case_options.rs @@ -0,0 +1,258 @@ +use dbx_core::models::connection::DatabaseType; +use dbx_core::schema_diff::TableSchemaDetail; +use dbx_core::schema_diff::{prepare_schema_diff, SchemaDiffPreparationOptions, SchemaDiffTableMapping}; +use dbx_core::types::{ColumnInfo, ForeignKeyInfo, IndexInfo, TableInfo}; + +fn table(name: &str) -> TableInfo { + TableInfo { + name: name.to_string(), + table_type: "BASE TABLE".to_string(), + comment: None, + parent_schema: None, + parent_name: None, + } +} + +fn column(name: &str, data_type: &str) -> ColumnInfo { + ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), ..Default::default() } +} + +fn index(name: &str, columns: &[&str]) -> IndexInfo { + IndexInfo { + name: name.to_string(), + columns: columns.iter().map(|column| (*column).to_string()).collect(), + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + key_is_expression: Vec::new(), + column_opclasses: Vec::new(), + constraint_backed: false, + } +} + +fn foreign_key(name: &str, column: &str, ref_table: &str, ref_column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_string(), + column: column.to_string(), + ref_schema: None, + ref_table: ref_table.to_string(), + ref_column: ref_column.to_string(), + on_update: None, + on_delete: None, + } +} + +fn detail( + name: &str, + columns: Vec, + indexes: Vec, + foreign_keys: Vec, +) -> TableSchemaDetail { + TableSchemaDetail { name: name.to_string(), columns, indexes, foreign_keys, triggers: Vec::new(), ddl: None } +} + +fn options( + source_tables: Vec, + target_tables: Vec, + source_details: Vec, + target_details: Vec, +) -> SchemaDiffPreparationOptions { + SchemaDiffPreparationOptions { + source_tables, + target_tables, + source_details, + target_details, + database_type: DatabaseType::Postgres, + ..Default::default() + } +} + +#[test] +fn keeps_case_sensitive_matching_by_default() { + let result = prepare_schema_diff(options( + vec![table("USER")], + vec![table("user")], + vec![detail("USER", vec![column("ID", "int")], Vec::new(), Vec::new())], + vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())], + )); + + assert_eq!(result.diffs.len(), 2); + assert!(result.diffs.iter().any(|diff| diff.diff_type == "added" && diff.name == "USER")); + assert!(result.diffs.iter().any(|diff| diff.diff_type == "removed" && diff.name == "user")); +} + +#[test] +fn matches_table_column_index_and_foreign_key_references_case_insensitively() { + let mut options = options( + vec![table("USER_INFO"), table("USER_ACCESS")], + vec![table("user_info"), table("user_access")], + vec![ + detail("USER_INFO", vec![column("ID", "int")], Vec::new(), Vec::new()), + detail( + "USER_ACCESS", + vec![column("USER_ID", "int")], + vec![index("idx_user_id", &["USER_ID"])], + vec![foreign_key("fk_user_info", "USER_ID", "USER_INFO", "ID")], + ), + ], + vec![ + detail("user_info", vec![column("id", "int")], Vec::new(), Vec::new()), + detail( + "user_access", + vec![column("user_id", "int")], + vec![index("idx_user_id", &["user_id"])], + vec![foreign_key("fk_user_info", "user_id", "user_info", "id")], + ), + ], + ); + options.ignore_table_name_case = true; + options.ignore_column_name_case = true; + + let result = prepare_schema_diff(options); + + assert!( + result.diffs.is_empty(), + "case-only table, column, index, and foreign-key references should not diff: {result:?}" + ); +} + +#[test] +fn keeps_table_and_column_case_options_independent() { + let base = || { + options( + vec![table("USER")], + vec![table("user")], + vec![detail("USER", vec![column("ID", "int")], Vec::new(), Vec::new())], + vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())], + ) + }; + + let mut table_only = base(); + table_only.ignore_table_name_case = true; + let table_only_result = prepare_schema_diff(table_only); + assert_eq!(table_only_result.diffs.len(), 1); + assert_eq!(table_only_result.diffs[0].diff_type, "modified"); + assert_eq!(table_only_result.diffs[0].columns.as_ref().unwrap().len(), 2); + + let mut column_only = base(); + column_only.ignore_column_name_case = true; + let column_only_result = prepare_schema_diff(column_only); + assert_eq!(column_only_result.diffs.len(), 2); + assert!(column_only_result.diffs.iter().all(|diff| diff.diff_type == "added" || diff.diff_type == "removed")); +} + +#[test] +fn ignores_case_when_comparing_column_order() { + let mut options = options( + vec![table("orders")], + vec![table("orders")], + vec![detail("orders", vec![column("ID", "int"), column("NAME", "text")], Vec::new(), Vec::new())], + vec![detail("orders", vec![column("id", "int"), column("name", "text")], Vec::new(), Vec::new())], + ); + options.ignore_column_name_case = true; + options.compare_column_order = true; + + assert!(prepare_schema_diff(options).diffs.is_empty()); +} + +#[test] +fn explicit_table_mappings_take_precedence_over_case_insensitive_matching() { + let mut options = options( + vec![table("source_a")], + vec![table("SOURCE_A"), table("target_b")], + vec![detail("source_a", vec![column("id", "int")], Vec::new(), Vec::new())], + vec![ + detail("SOURCE_A", vec![column("id", "int")], Vec::new(), Vec::new()), + detail("target_b", vec![column("id", "int")], Vec::new(), Vec::new()), + ], + ); + options.ignore_table_name_case = true; + options.table_mappings = + vec![SchemaDiffTableMapping { source_table: "source_a".to_string(), target_table: "target_b".to_string() }]; + + let result = prepare_schema_diff(options); + + assert_eq!(result.diffs.len(), 1); + assert_eq!(result.diffs[0].diff_type, "removed"); + assert_eq!(result.diffs[0].name, "SOURCE_A"); +} + +#[test] +fn exact_table_matches_take_precedence_over_case_insensitive_candidates() { + let mut options = options( + vec![table("foo"), table("FOO")], + vec![table("FOO")], + vec![detail("foo", Vec::new(), Vec::new(), Vec::new()), detail("FOO", Vec::new(), Vec::new(), Vec::new())], + vec![detail("FOO", Vec::new(), Vec::new(), Vec::new())], + ); + options.ignore_table_name_case = true; + + let result = prepare_schema_diff(options); + + assert_eq!(result.diffs.len(), 1); + assert_eq!(result.diffs[0].diff_type, "added"); + assert_eq!(result.diffs[0].name, "foo"); +} + +#[test] +fn does_not_match_ambiguous_case_insensitive_table_candidates() { + let mut options = options( + vec![table("uSeR")], + vec![table("User"), table("USER")], + vec![detail("uSeR", Vec::new(), Vec::new(), Vec::new())], + vec![detail("User", Vec::new(), Vec::new(), Vec::new()), detail("USER", Vec::new(), Vec::new(), Vec::new())], + ); + options.ignore_table_name_case = true; + + let result = prepare_schema_diff(options); + + assert_eq!(result.diffs.len(), 3); + assert!(result.diffs.iter().any(|diff| diff.diff_type == "added" && diff.name == "uSeR")); + assert_eq!(result.diffs.iter().filter(|diff| diff.diff_type == "removed").count(), 2); +} + +#[test] +fn case_only_matches_do_not_trigger_table_or_column_rename_detection() { + let mut options = options( + vec![table("User")], + vec![table("user")], + vec![detail("User", vec![column("ID", "int")], Vec::new(), Vec::new())], + vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())], + ); + options.ignore_table_name_case = true; + options.ignore_column_name_case = true; + options.detect_renames = true; + options.detect_table_renames = true; + + let result = prepare_schema_diff(options); + + assert!(result.diffs.is_empty()); + assert!(result.rename_candidates.is_empty()); +} + +#[test] +fn preserves_original_identifiers_in_case_insensitive_matches() { + let mut options = options( + vec![table("USER_INFO")], + vec![table("user_info")], + vec![detail("USER_INFO", vec![column("ID", "bigint")], Vec::new(), Vec::new())], + vec![detail("user_info", vec![column("id", "int")], Vec::new(), Vec::new())], + ); + options.ignore_table_name_case = true; + options.ignore_column_name_case = true; + + let result = prepare_schema_diff(options); + let table_diff = &result.diffs[0]; + let column_diff = &table_diff.columns.as_ref().unwrap()[0]; + + assert_eq!(table_diff.name, "USER_INFO"); + assert_eq!(table_diff.target_name.as_deref(), Some("user_info")); + assert_eq!(column_diff.name, "ID"); + assert_eq!(column_diff.source.as_ref().unwrap().name, "ID"); + assert_eq!(column_diff.target.as_ref().unwrap().name, "id"); + assert!(result.sync_sql.contains("ALTER COLUMN \"id\" TYPE bigint")); + assert!(!result.sync_sql.contains("ALTER COLUMN \"ID\" TYPE bigint")); +}