fix(compare): match data compare columns case-insensitively
This commit is contained in:
1 parent
ab35a5b1da
commit
eea3faa224
4 files changed
+165
-12
No files matched your search
@@ -14,7 +14,7 @@ import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import { inferCompareKeyColumns } from "@/lib/dataGrid/dataCompare";
|
||||
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "@/lib/dataGrid/dataCompare";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
@@ -546,6 +546,8 @@ async function startCompare() {
|
||||
if (!targetTables.value.includes(task.targetTable)) {
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : [];
|
||||
const sourceColumnNames = sourceColumns.map((column) => column.name);
|
||||
const sourceKeyColumns = resolvedKeys.map((key) => matchColumnNameIgnoreCase(key, sourceColumnNames) ?? key);
|
||||
const preparation = await api.prepareDataCompareMissingTarget({
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
@@ -555,12 +557,12 @@ async function startCompare() {
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: resolvedKeys,
|
||||
keyColumns: sourceKeyColumns,
|
||||
});
|
||||
results.push({
|
||||
sourceTable: task.sourceTable,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: resolvedKeys,
|
||||
keyColumns: sourceKeyColumns,
|
||||
columns: sourceColumns.map((column) => column.name),
|
||||
columnInfo: sourceColumns,
|
||||
status: "different",
|
||||
@@ -591,9 +593,19 @@ async function startCompare() {
|
||||
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const targetColumns = await loadColumnsWithCache(targetColumnCache, targetConnectionId.value, targetDatabase.value, targetSchema.value, task.targetTable);
|
||||
const columns = sourceColumns.map((column) => column.name).filter((column) => targetColumns.some((target) => target.name === column));
|
||||
const matched = intersectCompareColumns(sourceColumns, targetColumns);
|
||||
const columns = matched.columns;
|
||||
const columnInfo = columns.map((column) => targetColumns.find((target) => target.name === column)).filter((column): column is CompareColumn => !!column);
|
||||
const missingKeys = resolvedKeys.filter((column) => !columns.includes(column));
|
||||
const canonicalKeyColumns: string[] = [];
|
||||
const missingKeys: string[] = [];
|
||||
for (const key of resolvedKeys) {
|
||||
const canonical = matchColumnNameIgnoreCase(key, columns);
|
||||
if (!canonical) {
|
||||
missingKeys.push(key);
|
||||
} else if (!canonicalKeyColumns.includes(canonical)) {
|
||||
canonicalKeyColumns.push(canonical);
|
||||
}
|
||||
}
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
|
||||
}
|
||||
@@ -611,7 +623,8 @@ async function startCompare() {
|
||||
targetSchema: targetSchema.value,
|
||||
targetTable: task.targetTable,
|
||||
columns,
|
||||
keyColumns: resolvedKeys,
|
||||
keyColumns: canonicalKeyColumns,
|
||||
sourceColumns: matched.sourceColumns,
|
||||
});
|
||||
|
||||
const added = preparation.result.added.length;
|
||||
@@ -622,7 +635,7 @@ async function startCompare() {
|
||||
results.push({
|
||||
sourceTable: task.sourceTable,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: resolvedKeys,
|
||||
keyColumns: canonicalKeyColumns,
|
||||
columns,
|
||||
columnInfo,
|
||||
status,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferCompareKeyColumns } from "../dataCompare";
|
||||
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "../dataCompare";
|
||||
|
||||
describe("inferCompareKeyColumns", () => {
|
||||
it("returns all primary-key columns when a primary key exists", () => {
|
||||
@@ -34,3 +34,40 @@ describe("inferCompareKeyColumns", () => {
|
||||
expect(inferCompareKeyColumns([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersectCompareColumns", () => {
|
||||
it("matches lower-case source columns against upper-case target columns", () => {
|
||||
const intersection = intersectCompareColumns([{ name: "snid" }, { name: "username" }, { name: "amount" }], [{ name: "SNID" }, { name: "USERNAME" }, { name: "AMOUNT" }]);
|
||||
expect(intersection.columns).toEqual(["SNID", "USERNAME", "AMOUNT"]);
|
||||
expect(intersection.sourceColumns).toEqual(["snid", "username", "amount"]);
|
||||
});
|
||||
|
||||
it("keeps source order and omits columns missing on either side", () => {
|
||||
const intersection = intersectCompareColumns([{ name: "Amount" }, { name: "snid" }, { name: "extra" }], [{ name: "SNID" }, { name: "AMOUNT" }]);
|
||||
expect(intersection.columns).toEqual(["AMOUNT", "SNID"]);
|
||||
expect(intersection.sourceColumns).toEqual(["Amount", "snid"]);
|
||||
});
|
||||
|
||||
it("returns an empty intersection when no column names match ignoring case", () => {
|
||||
const intersection = intersectCompareColumns([{ name: "snid" }], [{ name: "id" }]);
|
||||
expect(intersection.columns).toEqual([]);
|
||||
expect(intersection.sourceColumns).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates duplicated source columns", () => {
|
||||
const intersection = intersectCompareColumns([{ name: "snid" }, { name: "SNID" }], [{ name: "SNID" }]);
|
||||
expect(intersection.columns).toEqual(["SNID"]);
|
||||
expect(intersection.sourceColumns).toEqual(["snid"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchColumnNameIgnoreCase", () => {
|
||||
it("resolves a name to the canonical entry ignoring case", () => {
|
||||
expect(matchColumnNameIgnoreCase("SNID", ["snid", "name"])).toBe("snid");
|
||||
expect(matchColumnNameIgnoreCase("Name", ["SNID", "NAME"])).toBe("NAME");
|
||||
});
|
||||
|
||||
it("returns undefined when no column matches", () => {
|
||||
expect(matchColumnNameIgnoreCase("missing", ["snid"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ export interface DataCompareFromTablesOptions {
|
||||
targetTable: string;
|
||||
columns: string[];
|
||||
keyColumns: string[];
|
||||
/** Source-side column names aligned positionally with `columns`; needed when the two databases store identifier case differently (e.g. SQL Server vs Oracle). */
|
||||
sourceColumns?: string[];
|
||||
fetchBatchSize?: number;
|
||||
degradationThreshold?: DegradationThreshold;
|
||||
samplingStrategy?: SamplingStrategy;
|
||||
@@ -139,3 +141,48 @@ export function inferCompareKeyColumns(columns: Pick<ColumnInfo, "name" | "is_pr
|
||||
}
|
||||
return primaryKeys;
|
||||
}
|
||||
|
||||
export interface CompareColumnIntersection {
|
||||
/** Target-side column names, kept in source order. */
|
||||
columns: string[];
|
||||
/** Source-side names aligned positionally with `columns`. */
|
||||
sourceColumns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects source and target columns case-insensitively.
|
||||
*
|
||||
* Databases store identifier case differently (SQL Server keeps the created
|
||||
* case, Oracle and Dameng store upper case, PostgreSQL lower case), so a
|
||||
* migrated table's columns only match across engines when compared without
|
||||
* case. Target names are canonical because sync SQL runs against the target.
|
||||
*/
|
||||
export function intersectCompareColumns(sourceColumns: Pick<ColumnInfo, "name">[], targetColumns: Pick<ColumnInfo, "name">[]): CompareColumnIntersection {
|
||||
const targetNamesByLowerName = new Map<string, string>();
|
||||
for (const column of targetColumns) {
|
||||
const lowerName = column.name.toLowerCase();
|
||||
if (!targetNamesByLowerName.has(lowerName)) targetNamesByLowerName.set(lowerName, column.name);
|
||||
}
|
||||
|
||||
const columns: string[] = [];
|
||||
const sourceNames: string[] = [];
|
||||
const matched = new Set<string>();
|
||||
for (const column of sourceColumns) {
|
||||
const lowerName = column.name.toLowerCase();
|
||||
const targetName = targetNamesByLowerName.get(lowerName);
|
||||
if (targetName === undefined || matched.has(lowerName)) continue;
|
||||
matched.add(lowerName);
|
||||
columns.push(targetName);
|
||||
sourceNames.push(column.name);
|
||||
}
|
||||
return { columns, sourceColumns: sourceNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a column name to the matching entry of `columns`, ignoring case.
|
||||
* Returns undefined when no column matches.
|
||||
*/
|
||||
export function matchColumnNameIgnoreCase(name: string, columns: string[]): string | undefined {
|
||||
const lowerName = name.toLowerCase();
|
||||
return columns.find((column) => column.toLowerCase() === lowerName);
|
||||
}
|
||||
Reference in new issue
Block a user