feat(diff): manual table mapping for schema compare

This commit is contained in:
jearhe authored and GitHub committed 2026-09-05 09:52:52 +08:00
1 parent d5a511b719
commit b5fcb3a832
28 files changed
+885 -122

No files matched your search

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch";
describe("buildSameNameTableMatches", () => {
it("matches same-name target tables and reports missing ones", () => {
const result = buildSameNameTableMatches(["a", "b", "c"], ["a", "c", "d"]);
expect(result).toEqual({ matched: ["a", "c"], missing: ["b"] });
});
it("reports all missing when no target tables exist", () => {
const result = buildSameNameTableMatches(["x", "y"], []);
expect(result).toEqual({ matched: [], missing: ["x", "y"] });
});
it("has no missing when every source matches", () => {
const result = buildSameNameTableMatches(["a", "b"], ["b", "a"]);
expect(result).toEqual({ matched: ["a", "b"], missing: [] });
});
it("handles empty source set", () => {
const result = buildSameNameTableMatches([], ["a"]);
expect(result).toEqual({ matched: [], missing: [] });
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { loadSchemaDetails, type SchemaDiffMetadataApi } from "@/lib/schema/schemaDiffMetadataLoad";
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
import { availableSchemaDiffTargetTables, buildSchemaDiffTableMatches, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, swapSchemaDiffTableMappings, updateSchemaDiffTableMapping } from "@/lib/schema/schemaDiffTableMapping";
import { DEFAULT_MYSQL_OPTIONS, normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { TableInfo } from "@/types/database";
const table = (name: string): TableInfo => ({ name, table_type: "BASE TABLE" });
describe("schema diff table mappings", () => {
it("automatically matches same-name tables", () => {
expect(buildSchemaDiffTableMatches(["a", "b"], ["a", "b"], [])).toEqual([
{ sourceTable: "a", targetTable: "a", kind: "automatic" },
{ sourceTable: "b", targetTable: "b", kind: "automatic" },
]);
});
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" },
{ sourceTable: "b", kind: "unmatched" },
{ sourceTable: "c", targetTable: "c", kind: "automatic" },
]);
});
it("keeps manual mappings alongside automatic matches", () => {
expect(buildSchemaDiffTableMatches(["a", "b", "c"], ["a", "x", "c"], [{ sourceTable: "b", targetTable: "x" }])).toEqual([
{ sourceTable: "a", targetTable: "a", kind: "automatic" },
{ sourceTable: "b", targetTable: "x", kind: "manual" },
{ sourceTable: "c", targetTable: "c", kind: "automatic" },
]);
});
it("allows a manual mapping to override an automatic match", () => {
expect(buildSchemaDiffTableMatches(["a"], ["a", "a_new"], [{ sourceTable: "a", targetTable: "a_new" }])).toEqual([{ sourceTable: "a", targetTable: "a_new", kind: "manual" }]);
});
it("removes mappings for deselected sources and automatically matches new sources", () => {
const mappings = [
{ sourceTable: "a", targetTable: "a" },
{ sourceTable: "b", targetTable: "b_new" },
];
expect(reconcileSchemaDiffTableMappings(["a", "c"], ["a", "b_new", "c"], mappings)).toEqual([
{ sourceTable: "a", targetTable: "a" },
{ sourceTable: "c", targetTable: "c" },
]);
expect(pruneSchemaDiffTableMappings(["a"], mappings)).toEqual([{ sourceTable: "a", targetTable: "a" }]);
});
it("reconciles a vanished target mapping back to same-name or unmatched", () => {
expect(reconcileSchemaDiffTableMappings(["a", "b"], ["a", "b"], [{ sourceTable: "b", targetTable: "b_old" }])).toEqual([
{ sourceTable: "a", targetTable: "a" },
{ sourceTable: "b", targetTable: "b" },
]);
expect(reconcileSchemaDiffTableMappings(["b"], ["b_other"], [{ sourceTable: "b", targetTable: "b_old" }])).toEqual([]);
});
it("rejects duplicate target mappings and hides occupied targets", () => {
const mappings = [{ sourceTable: "a", targetTable: "x" }];
expect(updateSchemaDiffTableMapping(mappings, "b", "x")).toEqual({ mappings, accepted: false, conflictSource: "a" });
expect(availableSchemaDiffTargetTables("b", ["x", "y"], mappings)).toEqual(["y"]);
expect(
reconcileSchemaDiffTableMappings(
["a", "b"],
["x", "b"],
[
{ sourceTable: "a", targetTable: "x" },
{ sourceTable: "b", targetTable: "x" },
],
),
).toEqual([
{ sourceTable: "a", targetTable: "x" },
{ sourceTable: "b", targetTable: "b" },
]);
});
it("round-trips table mappings through normalized config JSON", () => {
const options = normalizeSchemaDiffCompareOptions({ tableMappings: [{ sourceTable: "a", targetTable: "a_new" }] });
const restored = normalizeSchemaDiffCompareOptions(JSON.parse(JSON.stringify(options)));
expect(restored.tableMappings).toEqual([{ sourceTable: "a", targetTable: "a_new" }]);
expect(normalizeSchemaDiffCompareOptions({}).tableMappings).toEqual([]);
});
it("swaps each mapping direction", () => {
expect(
swapSchemaDiffTableMappings([
{ sourceTable: "a", targetTable: "b" },
{ sourceTable: "c", targetTable: "d" },
]),
).toEqual([
{ sourceTable: "b", targetTable: "a" },
{ sourceTable: "d", targetTable: "c" },
]);
});
it("selects mapped target tables for metadata loading instead of same-name tables", async () => {
const options = { ...DEFAULT_MYSQL_OPTIONS, tableMappings: [{ sourceTable: "charge_records", targetTable: "charge_record" }] };
const filter = compileSchemaDiffTableFilter(options);
const filtered = filterSchemaDiffTables([table("charge_records")], [table("charge_records"), table("charge_record")], filter, options, ["charge_records"]);
expect(filtered.sourceTables.map((entry) => entry.name)).toEqual(["charge_records"]);
expect(filtered.targetTables.map((entry) => entry.name)).toEqual(["charge_record"]);
const getColumns = vi.fn().mockResolvedValue([]);
const api: SchemaDiffMetadataApi = {
getTableDdl: vi.fn().mockResolvedValue(""),
getColumns,
listIndexes: vi.fn().mockResolvedValue([]),
listForeignKeys: vi.fn().mockResolvedValue([]),
listTriggers: vi.fn().mockResolvedValue([]),
};
await loadSchemaDetails(
filtered.targetTables,
{
connectionId: "target",
database: "app",
schema: "public",
dbType: "mysql",
options,
},
api,
);
expect(getColumns).toHaveBeenCalledWith("target", "app", "public", "charge_record");
expect(getColumns).not.toHaveBeenCalledWith("target", "app", "public", "charge_records");
});
});
@@ -1,23 +0,0 @@
/**
* Same-name target table matching, shared by Data Compare and Schema Diff.
* Given the selected source table set and the available target tables, produces
* which source tables matched a same-name target and which are missing.
*/
export interface SameNameTableMatchResult {
matched: string[];
missing: string[];
}
export function buildSameNameTableMatches(sourceTables: string[], targetTables: string[]): SameNameTableMatchResult {
const targetSet = new Set(targetTables);
const matched: string[] = [];
const missing: string[] = [];
for (const source of sourceTables) {
if (targetSet.has(source)) {
matched.push(source);
} else {
missing.push(source);
}
}
return { matched, missing };
}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
import type { SchemaDiffTableMapping } from "@/types/schemaDiff";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
const DIALECT_KIND_MAP: Record<string, string> = {
@@ -176,6 +177,7 @@ export interface TableDiff {
type: "added" | "removed" | "modified" | "renamed";
objectType?: "table" | "view";
name: string;
targetName?: string;
columns?: ColumnDiff[];
indexes?: IndexDiff[];
foreignKeys?: ForeignKeyDiff[];
@@ -230,6 +232,7 @@ export interface SchemaDiffPreparationOptions {
targetDialect?: string;
compatibilityThreshold?: number;
fieldMappings?: FieldMappingEntry[];
tableMappings?: SchemaDiffTableMapping[];
}
export interface RenameCandidate {
@@ -699,7 +702,7 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
objectKind: diff.objectType === "view" ? "view" : "table",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : isRenamed ? newName : diff.name,
targetName: diff.type === "removed" ? undefined : isRenamed ? newName : (diff.targetName ?? diff.name),
selected: opType !== "none",
sourceDdl: diff.ddl,
targetDdl: diff.targetDdl,
@@ -974,7 +977,7 @@ export function injectColumnRenameSql(sql: string, diffs: TableDiff[], threshold
// rc = removed column (exists in source, NOT in target → needs to be ADDED to target)
// best = added column (exists in target, NOT in source → needs to be DROPPED from target)
// To sync target → source: rename target's "best.name" to source's "rc.name"
replacements.push({ table: diff.name, oldName: best.name, newName: rc.name });
replacements.push({ table: diff.targetName ?? diff.name, oldName: best.name, newName: rc.name });
used.add(best.name);
}
}
@@ -1,4 +1,5 @@
import type { TableInfo } from "@/types/database";
import { reconcileSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping";
import type { SchemaDiffCompareOptions } from "@/types/schemaDiff";
export interface CompiledSchemaDiffTableFilter {
@@ -49,19 +50,33 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick<SchemaDiffCom
return isSchemaDiffView(table) ? options.views : options.tables;
}
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: Pick<SchemaDiffCompareOptions, "tables" | "views"> = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables {
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views"> & {
tableMappings?: SchemaDiffCompareOptions["tableMappings"];
};
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables {
// Visual (explicit) table selection is applied first, then the existing include/exclude
// regex filter. Source and target use the SAME selected table set, so targets are matched
// by the same name and any target missing a same-name table simply drops out (surfaced to
// the user in the UI as "missing"). Semantics:
// - `undefined`: no visual restriction — legacy all-tables behavior + regex filter.
// - `[]`: restriction explicitly enabled with an empty selection — compare nothing.
// - `["a", "b"]`: restrict to exactly those table names.
// regex filter. With an explicit selection, targets are selected by the effective
// source→target mapping; without one, the legacy all-tables path is unchanged.
const selectedSet = selectedTables === undefined ? null : new Set(selectedTables);
const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && (!selectedSet || selectedSet.has(table.name)) && matchesSchemaDiffTableFilter(table.name, filter);
const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && matchesSchemaDiffTableFilter(table.name, filter);
const filteredSourceTables = sourceTables.filter((table) => includeTable(table) && (!selectedSet || selectedSet.has(table.name)));
const filteredTargetTables = targetTables.filter(includeTable);
if (!selectedSet) {
return { sourceTables: filteredSourceTables, targetTables: filteredTargetTables };
}
const selectedTargetNames = new Set(
reconcileSchemaDiffTableMappings(
filteredSourceTables.map((table) => table.name),
filteredTargetTables.map((table) => table.name),
options.tableMappings ?? [],
).map((mapping) => mapping.targetTable),
);
return {
sourceTables: sourceTables.filter(includeTable),
targetTables: targetTables.filter(includeTable),
sourceTables: filteredSourceTables,
targetTables: filteredTargetTables.filter((table) => selectedTargetNames.has(table.name)),
};
}
@@ -22,6 +22,7 @@ interface SchemaDiffTableListCoordinatorOptions {
getIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity;
setTables(side: SchemaDiffTableSide, tables: TableInfo[]): void;
onSourceTablesLoaded?(tables: TableInfo[]): void;
onTargetTablesLoaded?(tables: TableInfo[]): void;
}
function identityKey(identity: SchemaDiffTableIdentity): string {
@@ -89,6 +90,7 @@ export function createSchemaDiffTableListCoordinator(options: SchemaDiffTableLis
options.setTables(side, tables);
if (side === "source") options.onSourceTablesLoaded?.(tables);
else options.onTargetTablesLoaded?.(tables);
return true;
} catch {
if (generation === generations[side] && sameSchemaDiffTableIdentity(identity, options.getIdentity(side))) {
@@ -0,0 +1,116 @@
import type { SchemaDiffTableMapping } from "@/types/schemaDiff";
export type SchemaDiffTableMatchKind = "automatic" | "manual" | "unmatched";
export interface SchemaDiffTableMatch {
sourceTable: string;
targetTable?: string;
kind: SchemaDiffTableMatchKind;
}
export interface SchemaDiffTableMappingUpdate {
mappings: SchemaDiffTableMapping[];
accepted: boolean;
conflictSource?: string;
}
function firstMappingBySource(mappings: readonly SchemaDiffTableMapping[]): Map<string, string> {
const bySource = new Map<string, string>();
for (const mapping of mappings) {
if (!mapping.sourceTable || !mapping.targetTable || bySource.has(mapping.sourceTable)) continue;
bySource.set(mapping.sourceTable, mapping.targetTable);
}
return bySource;
}
/** 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);
const seenSources = new Set<string>();
return mappings.filter((mapping) => {
if (!selected.has(mapping.sourceTable) || !mapping.targetTable || seenSources.has(mapping.sourceTable)) return false;
seenSources.add(mapping.sourceTable);
return true;
});
}
/**
* Reconcile explicit mappings with the current source and target lists.
* Explicit mappings win when valid; otherwise a same-name target is tried.
* 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[] {
const targets = new Set(targetTables);
const explicitBySource = firstMappingBySource(mappings);
const usedTargets = new Set<string>();
const next: SchemaDiffTableMapping[] = [];
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;
}
if (!targetTable) continue;
usedTargets.add(targetTable);
next.push({ sourceTable, targetTable });
}
return next;
}
export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMatch[] {
const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings);
const targetBySource = new Map(reconciled.map((mapping) => [mapping.sourceTable, mapping.targetTable]));
return selectedTables.map((sourceTable) => {
const targetTable = targetBySource.get(sourceTable);
let kind: SchemaDiffTableMatchKind;
if (!targetTable) kind = "unmatched";
else if (targetTable === sourceTable) 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);
if (targetTable && conflict) {
return { mappings: [...mappings], accepted: false, conflictSource: conflict.sourceTable };
}
return {
mappings: [...mappings.filter((mapping) => mapping.sourceTable !== sourceTable), ...(targetTable ? [{ sourceTable, targetTable }] : [])],
accepted: true,
};
}
/** 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[] {
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));
}
/** Reverse source/target for swap, dropping malformed duplicate targets defensively. */
export function swapSchemaDiffTableMappings(mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] {
const usedTargets = new Set<string>();
const swapped: SchemaDiffTableMapping[] = [];
for (const mapping of mappings) {
if (!mapping.sourceTable || !mapping.targetTable || usedTargets.has(mapping.sourceTable)) continue;
usedTargets.add(mapping.sourceTable);
swapped.push({ sourceTable: mapping.targetTable, targetTable: mapping.sourceTable });
}
return swapped;
}
export function areSchemaDiffTableMappingsEqual(left: readonly SchemaDiffTableMapping[], right: readonly SchemaDiffTableMapping[]): boolean {
return left.length === right.length && left.every((mapping, index) => mapping.sourceTable === right[index]?.sourceTable && mapping.targetTable === right[index]?.targetTable);
}