feat(diff): manual table mapping for schema compare
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user