feat(schema-diff): support case-insensitive table and column matching
This commit is contained in:
1 parent
0ef8369e2b
commit
3123e962c8
23 files changed
+726
-94
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user