mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 14:54:46 +00:00
feat(schema-diff): support case-insensitive table and column matching
This commit is contained in:
@@ -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 }];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5971,6 +5971,8 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "CASCADE削除を使用",
|
||||
sequenceLastValues: "シーケンス最終値を比較",
|
||||
compareColumnOrder: "列順序を比較",
|
||||
ignoreTableNameCase: "テーブル名の大文字小文字を無視",
|
||||
ignoreColumnNameCase: "列名の大文字小文字を無視",
|
||||
detectRenames: "名前変更を検出",
|
||||
detectTableRenames: "テーブル名変更を検出",
|
||||
enableRollback: "ロールバックを有効化",
|
||||
|
||||
@@ -5674,6 +5674,8 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "CASCADE 삭제 사용",
|
||||
sequenceLastValues: "시퀀스 마지막 값 비교",
|
||||
compareColumnOrder: "컬럼 순서 비교",
|
||||
ignoreTableNameCase: "테이블 이름 대소문자 무시",
|
||||
ignoreColumnNameCase: "컬럼 이름 대소문자 무시",
|
||||
detectRenames: "이름 변경 감지",
|
||||
detectTableRenames: "테이블 이름 변경 감지",
|
||||
enableRollback: "롤백 활성화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6308,6 +6308,8 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "使用级联删除",
|
||||
sequenceLastValues: "比较序列最后值",
|
||||
compareColumnOrder: "比较字段顺序",
|
||||
ignoreTableNameCase: "忽略表名大小写",
|
||||
ignoreColumnNameCase: "忽略字段名大小写",
|
||||
detectRenames: "检测重命名",
|
||||
detectTableRenames: "检测表重命名",
|
||||
enableRollback: "启用回滚",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ColumnDiff>, Vec<ColumnCompatibilityWarning>) {
|
||||
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<ColumnDiff>, Vec<ColumnCompatibilityWarning>) {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<TableDiff> {
|
||||
.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<TableDiff> {
|
||||
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<TableDiff> {
|
||||
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<TableDiff> {
|
||||
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<ForeignKeyInfo> = source
|
||||
.foreign_keys
|
||||
.iter()
|
||||
@@ -2409,13 +2535,23 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
|
||||
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<ForeignKeyInfo> =
|
||||
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<ForeignKeyInfo> = 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<DialectKind>,
|
||||
target_dialect: Option<DialectKind>,
|
||||
) -> Vec<ColumnDiff> {
|
||||
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<Option<usize>> {
|
||||
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<DialectKind>,
|
||||
target_dialect: Option<DialectKind>,
|
||||
ignore_column_name_case: bool,
|
||||
) -> Vec<ColumnDiff> {
|
||||
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<IndexDiff> {
|
||||
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<IndexDiff> {
|
||||
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<IndexDiff
|
||||
if source_index.is_unique { "YES" } else { "NO" }
|
||||
));
|
||||
}
|
||||
if source_index.columns.join(",") != target_index.columns.join(",") {
|
||||
if !index_columns_equal(source_index, target_index, ignore_column_name_case) {
|
||||
changes.push(format!("columns: {} → {}", target_index.columns.join(", "), source_index.columns.join(", ")));
|
||||
}
|
||||
if source_index.index_type.as_deref().unwrap_or_default()
|
||||
@@ -3064,7 +3282,7 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff
|
||||
}
|
||||
let source_included = source_index.included_columns.clone().unwrap_or_default();
|
||||
let target_included = target_index.included_columns.clone().unwrap_or_default();
|
||||
if source_included.join(",") != target_included.join(",") {
|
||||
if !identifier_lists_equal(&source_included, &target_included, ignore_column_name_case) {
|
||||
changes.push(format!(
|
||||
"include: {} → {}",
|
||||
if target_included.is_empty() { "none".to_string() } else { target_included.join(", ") },
|
||||
@@ -3114,9 +3332,15 @@ fn normalized_foreign_key_action(action: Option<&str>) -> Option<String> {
|
||||
.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<ForeignKeyDiff> {
|
||||
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<ForeignKeyDiff> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ColumnInfo>,
|
||||
indexes: Vec<IndexInfo>,
|
||||
foreign_keys: Vec<ForeignKeyInfo>,
|
||||
) -> TableSchemaDetail {
|
||||
TableSchemaDetail { name: name.to_string(), columns, indexes, foreign_keys, triggers: Vec::new(), ddl: None }
|
||||
}
|
||||
|
||||
fn options(
|
||||
source_tables: Vec<TableInfo>,
|
||||
target_tables: Vec<TableInfo>,
|
||||
source_details: Vec<TableSchemaDetail>,
|
||||
target_details: Vec<TableSchemaDetail>,
|
||||
) -> 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user