feat(schema-diff): support case-insensitive table and column matching

This commit is contained in:
jearhe authored and GitHub committed 2026-09-06 18:24:44 +08:00
1 parent 0ef8369e2b
commit 3123e962c8
23 files changed
+726 -94

No files matched your search

@@ -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. */