feat(data-compare): support per-table match column configuration

This commit is contained in:
jearhe authored and GitHub committed 2026-09-19 08:52:27 +08:00
1 parent aa4e0a30fd
commit d10216e302
18 files changed
+1359 -120

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "../dataCompare";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase, normalizeKeyColumns, sameKeyColumns } from "../dataCompare";
describe("inferCompareKeyColumns", () => {
it("returns all primary-key columns when a primary key exists", () => {
@@ -71,3 +71,36 @@ describe("matchColumnNameIgnoreCase", () => {
expect(matchColumnNameIgnoreCase("missing", ["snid"])).toBeUndefined();
});
});
describe("normalizeKeyColumns", () => {
it("keeps the given order so a composite key stays stable", () => {
expect(normalizeKeyColumns(["tenant_id", "order_id"])).toEqual(["tenant_id", "order_id"]);
});
it("trims names and drops blanks", () => {
expect(normalizeKeyColumns([" id ", "", " ", "name"])).toEqual(["id", "name"]);
});
it("drops case-insensitive duplicates but keeps the first spelling", () => {
expect(normalizeKeyColumns(["ID", "id", "Id"])).toEqual(["ID"]);
});
it("returns an empty array for an empty selection", () => {
expect(normalizeKeyColumns([])).toEqual([]);
});
});
describe("sameKeyColumns", () => {
it("ignores order and identifier case", () => {
expect(sameKeyColumns(["tenant_id", "order_id"], ["ORDER_ID", "TENANT_ID"])).toBe(true);
});
it("detects different selections and different lengths", () => {
expect(sameKeyColumns(["id"], ["ID", "name"])).toBe(false);
expect(sameKeyColumns(["id"], ["name"])).toBe(false);
});
it("treats two empty selections as equal", () => {
expect(sameKeyColumns([], [])).toBe(true);
});
});
+39 -1
View File
@@ -121,6 +121,9 @@ export interface DataCompareSyncPlan {
syncSql: string;
}
/** Minimal column shape a match-column picker needs, so it can be fed straight from `getColumns`. */
export type CompareKeyColumnOption = Pick<ColumnInfo, "name" | "is_primary_key">;
/**
* Safely infers the columns to use as comparison keys for a table.
*
@@ -134,7 +137,7 @@ export interface DataCompareSyncPlan {
* (category, status, date, ...) and make the whole table comparison fail with
* an unhelpful duplicate-key error.
*/
export function inferCompareKeyColumns(columns: Pick<ColumnInfo, "name" | "is_primary_key">[]): string[] {
export function inferCompareKeyColumns(columns: CompareKeyColumnOption[]): string[] {
const primaryKeys: string[] = [];
for (const column of columns) {
if (column.is_primary_key) primaryKeys.push(column.name);
@@ -142,6 +145,41 @@ export function inferCompareKeyColumns(columns: Pick<ColumnInfo, "name" | "is_pr
return primaryKeys;
}
/**
* Normalizes a match-column selection: trims entries, drops blanks and removes
* case-insensitive duplicates while keeping the caller's order.
*
* Order is preserved because the backend builds each row key from `keyColumns`
* in order; a stable order keeps diff keys identical between runs, and it lets a
* composite primary key keep the column order the table declares.
*/
export function normalizeKeyColumns(columns: readonly string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const column of columns) {
const name = column.trim();
if (!name) continue;
const lowerName = name.toLowerCase();
if (seen.has(lowerName)) continue;
seen.add(lowerName);
normalized.push(name);
}
return normalized;
}
/**
* Compares two match-column selections ignoring order and identifier case.
* Used to decide whether a manual selection still equals the inferred primary
* key, in which case it carries no override.
*/
export function sameKeyColumns(a: readonly string[], b: readonly string[]): boolean {
const normalizedA = normalizeKeyColumns(a);
const normalizedB = normalizeKeyColumns(b);
if (normalizedA.length !== normalizedB.length) return false;
const lowerB = new Set(normalizedB.map((column) => column.toLowerCase()));
return normalizedA.every((column) => lowerB.has(column.toLowerCase()));
}
export interface CompareColumnIntersection {
/** Target-side column names, kept in source order. */
columns: string[];