feat(schema-diff): support case-insensitive table and column matching

This commit is contained in:
jearhe authored and GitHub committed 2026-09-06 18:24:44 +08:00
1 parent 0ef8369e2b
commit 3123e962c8
23 files changed
+726 -94

No files matched your search

@@ -83,17 +83,17 @@ const restrictTables = ref(false);
const localSelectedTables = ref<string[]>([]);
const activeTableMappings = computed(() => props.options?.tableMappings ?? []);
const tableMatches = computed<SchemaDiffTableMatch[]>(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value));
const tableMatches = computed<SchemaDiffTableMatch[]>(() => 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<string | null>(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);
}
@@ -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,
@@ -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 ?? []);");
});
@@ -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<SchemaDiffCompareOptions>({ ...DEFAULT_POSTGRES_OPTIONS });
const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }];
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -5971,6 +5971,8 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE削除を使用",
sequenceLastValues: "シーケンス最終値を比較",
compareColumnOrder: "列順序を比較",
ignoreTableNameCase: "テーブル名の大文字小文字を無視",
ignoreColumnNameCase: "列名の大文字小文字を無視",
detectRenames: "名前変更を検出",
detectTableRenames: "テーブル名変更を検出",
enableRollback: "ロールバックを有効化",
+2
View File
@@ -5674,6 +5674,8 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE 삭제 사용",
sequenceLastValues: "시퀀스 마지막 값 비교",
compareColumnOrder: "컬럼 순서 비교",
ignoreTableNameCase: "테이블 이름 대소문자 무시",
ignoreColumnNameCase: "컬럼 이름 대소문자 무시",
detectRenames: "이름 변경 감지",
detectTableRenames: "테이블 이름 변경 감지",
enableRollback: "롤백 활성화",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -6308,6 +6308,8 @@ export default withEnglishFallback({
cascadeDelete: "使用级联删除",
sequenceLastValues: "比较序列最后值",
compareColumnOrder: "比较字段顺序",
ignoreTableNameCase: "忽略表名大小写",
ignoreColumnNameCase: "忽略字段名大小写",
detectRenames: "检测重命名",
detectTableRenames: "检测表重命名",
enableRollback: "启用回滚",
+2
View File
@@ -5253,6 +5253,8 @@ export default withEnglishFallback({
cascadeDelete: "使用級聯刪除",
sequenceLastValues: "比較序列最後值",
compareColumnOrder: "比較欄位順序",
ignoreTableNameCase: "忽略資料表名稱大小寫",
ignoreColumnNameCase: "忽略欄位名稱大小寫",
detectRenames: "檢測重新命名",
detectTableRenames: "檢測表重新命名",
enableRollback: "啟用回溯",
@@ -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[] = [
@@ -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", () => {
@@ -223,6 +223,8 @@ export interface SchemaDiffPreparationOptions {
ignoreComments?: boolean;
cascadeDelete?: boolean;
compareColumnOrder?: boolean;
ignoreTableNameCase?: boolean;
ignoreColumnNameCase?: boolean;
detectRenames?: boolean;
detectTableRenames?: boolean;
renameThreshold?: number;
@@ -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 },
@@ -50,11 +50,11 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick<SchemaDiffCom
return isSchemaDiffView(table) ? options.views : options.tables;
}
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views"> & {
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views" | "ignoreTableNameCase"> & {
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),
);
@@ -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<string>();
const next: SchemaDiffTableMapping[] = [];
const reservedExplicitTargets = new Set<string>();
const explicitTargetsBySource = new Map<string, string>();
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<string>();
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. */
+6
View File
@@ -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",