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
@@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch";
|
||||
import { buildSchemaDiffTableMatches, availableSchemaDiffTargetTables, areSchemaDiffTableMappingsEqual, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, updateSchemaDiffTableMapping, type SchemaDiffTableMatch } from "@/lib/schema/schemaDiffTableMapping";
|
||||
import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
@@ -15,7 +15,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
||||
(e: "update:targetSchema", value: string): void;
|
||||
(e: "update:ignoreComments", value: boolean): void;
|
||||
(e: "update:fieldMappings", value: FieldMappingEntry[]): void;
|
||||
(e: "update:tableMappings", value: SchemaDiffTableMapping[]): void;
|
||||
(e: "open-field-mapping"): void;
|
||||
(e: "compare"): void;
|
||||
(e: "saveConfig"): void;
|
||||
@@ -77,11 +78,38 @@ const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
|
||||
// ---- Visual (explicit) table selection ----
|
||||
const sourceTableList = ref<string[]>([]);
|
||||
const targetTableList = ref<string[]>([]);
|
||||
const targetTableListLoaded = ref(false);
|
||||
const restrictTables = ref(false);
|
||||
const localSelectedTables = ref<string[]>([]);
|
||||
|
||||
const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value));
|
||||
const missingTargetTables = computed(() => matchResult.value.missing);
|
||||
const activeTableMappings = computed(() => props.options?.tableMappings ?? []);
|
||||
const tableMatches = computed<SchemaDiffTableMatch[]>(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value));
|
||||
const matchedTableCount = computed(() => tableMatches.value.filter((match) => match.targetTable).length);
|
||||
const missingTargetTables = computed(() => tableMatches.value.filter((match) => !match.targetTable).map((match) => match.sourceTable));
|
||||
const tableMappingConflictSource = ref<string | null>(null);
|
||||
|
||||
function targetTableOptions(sourceTable: string): string[] {
|
||||
return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value);
|
||||
}
|
||||
|
||||
function handleTableMappingUpdate(sourceTable: string, targetTable: string) {
|
||||
const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable);
|
||||
if (update.accepted) {
|
||||
tableMappingConflictSource.value = null;
|
||||
emitTableMappings(update.mappings);
|
||||
} else {
|
||||
tableMappingConflictSource.value = update.conflictSource ?? sourceTable;
|
||||
}
|
||||
}
|
||||
|
||||
function emitTableMappings(nextMappings: SchemaDiffTableMapping[]) {
|
||||
if (!areSchemaDiffTableMappingsEqual(nextMappings, activeTableMappings.value)) emit("update:tableMappings", nextMappings);
|
||||
}
|
||||
|
||||
function reconcileTableMappings(selectedTables: string[], targetTables = targetTableList.value) {
|
||||
const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value);
|
||||
emitTableMappings(nextMappings);
|
||||
}
|
||||
|
||||
// Keep the visual restriction in sync with the (persisted) config options.
|
||||
// `undefined` = not restricted (compare all tables, then regex filter);
|
||||
@@ -91,27 +119,46 @@ watch(
|
||||
(value) => {
|
||||
restrictTables.value = Array.isArray(value);
|
||||
localSelectedTables.value = value && Array.isArray(value) ? [...value] : [];
|
||||
if (!restrictTables.value) emitTableMappings([]);
|
||||
else if (targetTableListLoaded.value) reconcileTableMappings(localSelectedTables.value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.options?.tableMappings,
|
||||
() => {
|
||||
if (restrictTables.value && targetTableListLoaded.value) reconcileTableMappings(localSelectedTables.value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function handleToggleRestrict(enabled: boolean) {
|
||||
restrictTables.value = enabled;
|
||||
emit("update:selectedTables", enabled ? [...localSelectedTables.value] : undefined);
|
||||
if (!enabled) emitTableMappings([]);
|
||||
else reconcileTableMappings(localSelectedTables.value);
|
||||
}
|
||||
|
||||
function handleUpdateSelectedTables(value: string[]) {
|
||||
localSelectedTables.value = value;
|
||||
if (restrictTables.value) emit("update:selectedTables", [...value]);
|
||||
if (!restrictTables.value) return;
|
||||
emit("update:selectedTables", [...value]);
|
||||
reconcileTableMappings(value);
|
||||
}
|
||||
|
||||
function clearUnavailableTableSelection() {
|
||||
sourceTableList.value = [];
|
||||
targetTableList.value = [];
|
||||
if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) return;
|
||||
targetTableListLoaded.value = false;
|
||||
if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) {
|
||||
emitTableMappings([]);
|
||||
return;
|
||||
}
|
||||
restrictTables.value = false;
|
||||
localSelectedTables.value = [];
|
||||
emit("update:selectedTables", undefined);
|
||||
emitTableMappings([]);
|
||||
}
|
||||
|
||||
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
|
||||
@@ -127,18 +174,28 @@ function isTableIdentityReady(side: SchemaDiffTableSide): boolean {
|
||||
function setTableList(side: SchemaDiffTableSide, tables: Array<{ name?: string }>) {
|
||||
const names = tables.map((entry) => entry.name ?? "").filter(Boolean);
|
||||
if (side === "source") sourceTableList.value = names;
|
||||
else targetTableList.value = names;
|
||||
else {
|
||||
targetTableList.value = names;
|
||||
targetTableListLoaded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileSelectedTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) {
|
||||
if (!restrictTables.value || !Array.isArray(props.selectedTables)) return;
|
||||
|
||||
const availableTables = tables.map((entry) => entry.name ?? "").filter(Boolean);
|
||||
const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables);
|
||||
if (nextSelectedTables.length === props.selectedTables.length && nextSelectedTables.every((table, index) => table === props.selectedTables?.[index])) return;
|
||||
let selectedTables = localSelectedTables.value;
|
||||
if (restrictTables.value && Array.isArray(props.selectedTables)) {
|
||||
const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables);
|
||||
const changed = nextSelectedTables.length !== props.selectedTables.length || nextSelectedTables.some((table, index) => table !== props.selectedTables?.[index]);
|
||||
selectedTables = nextSelectedTables;
|
||||
localSelectedTables.value = nextSelectedTables;
|
||||
if (changed) emit("update:selectedTables", nextSelectedTables);
|
||||
}
|
||||
if (restrictTables.value) reconcileTableMappings(selectedTables);
|
||||
}
|
||||
|
||||
localSelectedTables.value = nextSelectedTables;
|
||||
emit("update:selectedTables", nextSelectedTables);
|
||||
function reconcileTargetTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) {
|
||||
targetTableListLoaded.value = true;
|
||||
reconcileTableMappings(localSelectedTables.value, tables.map((entry) => entry.name ?? "").filter(Boolean));
|
||||
}
|
||||
|
||||
const tableListCoordinator = createSchemaDiffTableListCoordinator({
|
||||
@@ -146,6 +203,7 @@ const tableListCoordinator = createSchemaDiffTableListCoordinator({
|
||||
getIdentity: getTableIdentity,
|
||||
setTables: setTableList,
|
||||
onSourceTablesLoaded: reconcileSelectedTablesAfterSuccessfulLoad,
|
||||
onTargetTablesLoaded: reconcileTargetTablesAfterSuccessfulLoad,
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -170,7 +228,10 @@ const canConfigureTableSelection = computed(() => isTableIdentityReady("source")
|
||||
|
||||
const canCompare = computed(() => {
|
||||
const hasSelectedTables = props.selectedTables === undefined || props.selectedTables.length > 0;
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
// Unmapped source tables are not a blocker: they flow through the comparison as
|
||||
// source-only tables (CREATE TABLE in the target), matching the pre-mapping behavior.
|
||||
const hasValidTableMappings = !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target");
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && hasValidTableMappings && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
@@ -558,13 +619,53 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
</div>
|
||||
|
||||
<!-- Target Same-Name Match -->
|
||||
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-1.5 rounded-lg border p-3 text-xs">
|
||||
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
|
||||
<!-- Source → target table mapping -->
|
||||
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-2 rounded-lg border p-3 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-medium">{{ t("diff.targetTableMatching") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ t("diff.matchedTables", { matched: matchedTableCount, total: localSelectedTables.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="missingTargetTables.length" class="text-destructive">
|
||||
{{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
|
||||
<div class="overflow-x-auto rounded border">
|
||||
<table class="w-full min-w-[520px]">
|
||||
<thead class="border-b bg-muted/30 text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceTables") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetTable") }}</th>
|
||||
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="match in tableMatches" :key="match.sourceTable" class="border-b last:border-b-0">
|
||||
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.sourceTable">{{ match.sourceTable }}</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<SearchableSelect
|
||||
:model-value="match.targetTable ?? ''"
|
||||
@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"
|
||||
:options="targetTableOptions(match.sourceTable)"
|
||||
:placeholder="t('diff.selectTargetTable')"
|
||||
:search-placeholder="t('diff.searchTargetTable')"
|
||||
:empty-text="t('diff.noTargetTables')"
|
||||
:disabled="!targetTableListLoaded"
|
||||
:clearable="true"
|
||||
:clear-selected-option="true"
|
||||
trigger-class="h-7 w-full text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : match.kind === 'manual' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
|
||||
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="targetTableListLoaded && missingTargetTables.length" class="text-muted-foreground">
|
||||
{{ t("diff.unmatchedTableWarning", { count: missingTargetTables.length }) }}
|
||||
</div>
|
||||
<div v-if="tableMappingConflictSource" class="text-destructive">
|
||||
{{ t("diff.targetTableInUse") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad";
|
||||
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
|
||||
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
|
||||
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
|
||||
import {
|
||||
convertToSchemaDiffObjects,
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
normalizeSchemaDiffDependencyGraph,
|
||||
} from "@/lib/schema/schemaDiff";
|
||||
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
|
||||
import { swapSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
@@ -409,6 +410,22 @@ function getDbType(): DatabaseType {
|
||||
}
|
||||
|
||||
function handleSwap() {
|
||||
const currentOptions = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
|
||||
const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);
|
||||
const swappedSelectedTables = Array.isArray(currentOptions.selectedTables) ? swappedMappings.map((mapping) => mapping.sourceTable) : undefined;
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(
|
||||
normalizeSchemaDiffCompareOptions(
|
||||
{
|
||||
...currentOptions,
|
||||
selectedTables: swappedSelectedTables,
|
||||
tableMappings: swappedMappings,
|
||||
},
|
||||
getDbType(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const tempConn = sourceConnectionId.value;
|
||||
const tempDb = sourceDatabase.value;
|
||||
const tempSchema = sourceSchema.value;
|
||||
@@ -432,6 +449,12 @@ function handleSelectedTablesUpdate(value?: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTableMappingsUpdate(value: SchemaDiffTableMapping[]) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, tableMappings: value }, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
if (activeConfig.value) {
|
||||
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
|
||||
@@ -559,6 +582,10 @@ async function handleCompare() {
|
||||
targetRules: tgtRules,
|
||||
sourceOwners: srcOwners,
|
||||
targetOwners: tgtOwners,
|
||||
// Mappings only apply to an explicit table selection; the all-tables path
|
||||
// (selectedTables === undefined) must stay mapping-free so the Rust side
|
||||
// cannot reclassify added/removed tables from stale persisted mappings.
|
||||
tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings,
|
||||
databaseType: dbType,
|
||||
targetSchema: schemaDiffDeployTargetSchema(dbType, targetDatabase.value, targetSchema.value),
|
||||
ignoreComments: ignoreComments.value,
|
||||
@@ -915,7 +942,7 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) {
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
if (config.options) {
|
||||
updateActiveConfigOptions(config.options);
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,8 +1084,8 @@ const targetConnectionInfo = computed(() => {
|
||||
v-model:ignore-comments="ignoreComments"
|
||||
:configs="configs"
|
||||
:active-config-id="activeConfigId"
|
||||
:options="activeConfig?.options"
|
||||
:selected-tables="activeConfig?.options?.selectedTables"
|
||||
:options="schemaDiffPanelOptions"
|
||||
:selected-tables="schemaDiffPanelOptions.selectedTables"
|
||||
:table-list-loader="schemaDiffTableListLoader"
|
||||
:loading="loading"
|
||||
:recent-configs="recentConfigs"
|
||||
@@ -1069,6 +1096,7 @@ const targetConnectionInfo = computed(() => {
|
||||
@load-history-config="handleLoadHistoryConfig"
|
||||
@delete-history-config="handleDeleteHistoryConfig"
|
||||
@update:field-mappings="handleFieldMappingsUpdate"
|
||||
@update:table-mappings="handleTableMappingsUpdate"
|
||||
@update:selected-tables="handleSelectedTablesUpdate"
|
||||
@open-field-mapping="showFieldMappingDialog = true"
|
||||
/>
|
||||
|
||||
@@ -107,6 +107,14 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
expect(targetMatch).toBeLessThan(options);
|
||||
});
|
||||
|
||||
it("wires source-to-target table mappings through the selector and compare request", () => {
|
||||
expect(configStepSource).toContain('@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"');
|
||||
expect(configStepSource).toContain("tableMatchStatus.${match.kind}");
|
||||
expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"');
|
||||
expect(dialogSource).toContain("tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings");
|
||||
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
|
||||
});
|
||||
|
||||
it("keeps focused and selected deployment SQL projections separate", () => {
|
||||
expect(dialogSource).toContain('const focusedDeploySql = ref("");');
|
||||
expect(dialogSource).toContain('const selectedDeploySql = ref("");');
|
||||
|
||||
@@ -6050,6 +6050,17 @@ export default {
|
||||
autoMatchHint: "Target tables are matched by the same name.",
|
||||
matchedTables: "{matched}/{total} tables matched",
|
||||
missingTargetTables: "Missing same-name target tables: {tables}",
|
||||
targetTableMatching: "Target table matching",
|
||||
selectTargetTable: "Select target table",
|
||||
searchTargetTable: "Search target tables...",
|
||||
noTargetTables: "No target tables available",
|
||||
tableMatchStatus: {
|
||||
automatic: "Automatic",
|
||||
manual: "Manual",
|
||||
unmatched: "Unmatched",
|
||||
},
|
||||
unmatchedTableWarning: "{count} selected source table(s) have no target mapping",
|
||||
targetTableInUse: "This target table is already used by another source table",
|
||||
sourceObject: "Source object",
|
||||
targetObject: "Target object",
|
||||
operation: "Operation",
|
||||
|
||||
@@ -5727,6 +5727,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "Las tablas de destino se emparejan por el mismo nombre.",
|
||||
matchedTables: "{matched}/{total} tablas coinciden",
|
||||
missingTargetTables: "Faltan tablas de destino con el mismo nombre: {tables}",
|
||||
targetTableMatching: "Emparejamiento de tablas de destino",
|
||||
selectTargetTable: "Selecciona una tabla de destino",
|
||||
searchTargetTable: "Buscar tablas de destino...",
|
||||
noTargetTables: "No hay tablas de destino disponibles",
|
||||
tableMatchStatus: {
|
||||
automatic: "Automático",
|
||||
manual: "Manual",
|
||||
unmatched: "Sin coincidencia",
|
||||
},
|
||||
unmatchedTableWarning: "{count} tabla(s) de origen seleccionada(s) no tienen una tabla de destino",
|
||||
targetTableInUse: "Esta tabla de destino ya está usada por otra tabla de origen",
|
||||
sourceObject: "Objeto de origen",
|
||||
targetObject: "Objeto de destino",
|
||||
operation: "Operación",
|
||||
|
||||
@@ -5727,6 +5727,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "Le tabelle di destinazione vengono abbinate per nome.",
|
||||
matchedTables: "{matched}/{total} tabelle abbinate",
|
||||
missingTargetTables: "Tabelle di destinazione omonime mancanti: {tables}",
|
||||
targetTableMatching: "Abbinamento delle tabelle di destinazione",
|
||||
selectTargetTable: "Seleziona la tabella di destinazione",
|
||||
searchTargetTable: "Cerca tabelle di destinazione...",
|
||||
noTargetTables: "Nessuna tabella di destinazione disponibile",
|
||||
tableMatchStatus: {
|
||||
automatic: "Automatico",
|
||||
manual: "Manuale",
|
||||
unmatched: "Non abbinata",
|
||||
},
|
||||
unmatchedTableWarning: "{count} tabella/e sorgente selezionata/e senza abbinamento di destinazione",
|
||||
targetTableInUse: "Questa tabella di destinazione è già usata da un'altra tabella sorgente",
|
||||
sourceObject: "Oggetto sorgente",
|
||||
targetObject: "Oggetto destinazione",
|
||||
operation: "Operazione",
|
||||
|
||||
@@ -5821,6 +5821,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "ターゲットテーブルは同じ名前でマッチングされます。",
|
||||
matchedTables: "{matched}/{total}テーブルがマッチしました",
|
||||
missingTargetTables: "同名のターゲットテーブルがありません: {tables}",
|
||||
targetTableMatching: "ターゲットテーブルのマッピング",
|
||||
selectTargetTable: "ターゲットテーブルを選択",
|
||||
searchTargetTable: "ターゲットテーブルを検索...",
|
||||
noTargetTables: "利用可能なターゲットテーブルがありません",
|
||||
tableMatchStatus: {
|
||||
automatic: "自動マッチ",
|
||||
manual: "手動マッチ",
|
||||
unmatched: "未マッチ",
|
||||
},
|
||||
unmatchedTableWarning: "選択したソーステーブルのうち {count} 件にターゲットマッピングがありません",
|
||||
targetTableInUse: "このターゲットテーブルは別のソーステーブルで使用されています",
|
||||
sourceObject: "ソースオブジェクト",
|
||||
targetObject: "ターゲットオブジェクト",
|
||||
operation: "操作",
|
||||
|
||||
@@ -5459,6 +5459,26 @@ export default withEnglishFallback({
|
||||
syncSummary: "성공 {success}, 실패 {failed}",
|
||||
configSaved: "설정을 저장했습니다",
|
||||
configDeleted: "설정을 삭제했습니다",
|
||||
tableSelection: "테이블",
|
||||
chooseTables: "테이블 선택...",
|
||||
compareAllTables: "모든 테이블 비교",
|
||||
tableSelectionUnrestricted: "정규식 필터 후 모든 테이블을 비교합니다. 특정 테이블을 선택하면 비교 범위를 제한할 수 있습니다.",
|
||||
sourceTables: "소스 테이블",
|
||||
targetTable: "대상 테이블",
|
||||
autoMatchHint: "대상 테이블은 같은 이름으로 자동 매칭됩니다.",
|
||||
matchedTables: "{matched}/{total}개 테이블 매칭됨",
|
||||
missingTargetTables: "같은 이름의 대상 테이블 누락: {tables}",
|
||||
targetTableMatching: "대상 테이블 매핑",
|
||||
selectTargetTable: "대상 테이블 선택",
|
||||
searchTargetTable: "대상 테이블 검색...",
|
||||
noTargetTables: "사용 가능한 대상 테이블이 없습니다",
|
||||
tableMatchStatus: {
|
||||
automatic: "자동 매칭",
|
||||
manual: "수동 매칭",
|
||||
unmatched: "매칭되지 않음",
|
||||
},
|
||||
unmatchedTableWarning: "선택한 소스 테이블 중 {count}개에 대상 매핑이 없습니다",
|
||||
targetTableInUse: "이 대상 테이블은 다른 소스 테이블에서 이미 사용 중입니다",
|
||||
sourceObject: "소스 객체",
|
||||
targetObject: "대상 객체",
|
||||
operation: "작업",
|
||||
|
||||
@@ -5729,6 +5729,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "As tabelas de destino são correspondidas pelo mesmo nome.",
|
||||
matchedTables: "{matched}/{total} tabelas correspondidas",
|
||||
missingTargetTables: "Tabelas de destino com o mesmo nome ausentes: {tables}",
|
||||
targetTableMatching: "Mapeamento das tabelas de destino",
|
||||
selectTargetTable: "Selecione a tabela de destino",
|
||||
searchTargetTable: "Pesquisar tabelas de destino...",
|
||||
noTargetTables: "Nenhuma tabela de destino disponível",
|
||||
tableMatchStatus: {
|
||||
automatic: "Automático",
|
||||
manual: "Manual",
|
||||
unmatched: "Sem correspondência",
|
||||
},
|
||||
unmatchedTableWarning: "{count} tabela(s) de origem selecionada(s) sem mapeamento de destino",
|
||||
targetTableInUse: "Esta tabela de destino já está sendo usada por outra tabela de origem",
|
||||
sourceObject: "Objeto de origem",
|
||||
targetObject: "Objeto de destino",
|
||||
operation: "Operação",
|
||||
|
||||
@@ -6040,6 +6040,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "Hedef tablolar aynı ada göre eşleştirilir.",
|
||||
matchedTables: "{matched}/{total} tablo eşleşti",
|
||||
missingTargetTables: "Aynı adlı hedef tablolar eksik: {tables}",
|
||||
targetTableMatching: "Hedef tablo eşleştirme",
|
||||
selectTargetTable: "Hedef tabloyu seçin",
|
||||
searchTargetTable: "Hedef tabloları arayın...",
|
||||
noTargetTables: "Kullanılabilir hedef tablo yok",
|
||||
tableMatchStatus: {
|
||||
automatic: "Otomatik",
|
||||
manual: "Manuel",
|
||||
unmatched: "Eşleşmedi",
|
||||
},
|
||||
unmatchedTableWarning: "Seçilen {count} kaynak tablonun hedef eşlemesi yok",
|
||||
targetTableInUse: "Bu hedef tablo başka bir kaynak tablo tarafından zaten kullanılıyor",
|
||||
sourceObject: "Kaynak nesne",
|
||||
targetObject: "Hedef nesne",
|
||||
operation: "İşlem",
|
||||
|
||||
@@ -6117,6 +6117,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "目标表按同名自动匹配。",
|
||||
matchedTables: "已匹配 {matched}/{total} 张表",
|
||||
missingTargetTables: "缺少同名的目标表:{tables}",
|
||||
targetTableMatching: "目标表匹配",
|
||||
selectTargetTable: "请选择目标表",
|
||||
searchTargetTable: "搜索目标表...",
|
||||
noTargetTables: "暂无目标表",
|
||||
tableMatchStatus: {
|
||||
automatic: "自动匹配",
|
||||
manual: "手动匹配",
|
||||
unmatched: "未匹配",
|
||||
},
|
||||
unmatchedTableWarning: "还有 {count} 张源表未匹配目标表",
|
||||
targetTableInUse: "该目标表已被其他源表使用",
|
||||
noDeployScriptAll: "暂无所选对象的部署脚本",
|
||||
saveConfigPrompt: "请输入配置名称:",
|
||||
defaultConfigName: "默认",
|
||||
|
||||
@@ -5063,6 +5063,17 @@ export default withEnglishFallback({
|
||||
autoMatchHint: "目標資料表會依相同名稱進行比對。",
|
||||
matchedTables: "已比對 {matched}/{total} 張資料表",
|
||||
missingTargetTables: "缺少同名的目標資料表:{tables}",
|
||||
targetTableMatching: "目標資料表比對",
|
||||
selectTargetTable: "請選擇目標資料表",
|
||||
searchTargetTable: "搜尋目標資料表...",
|
||||
noTargetTables: "沒有可用的目標資料表",
|
||||
tableMatchStatus: {
|
||||
automatic: "自動比對",
|
||||
manual: "手動比對",
|
||||
unmatched: "未比對",
|
||||
},
|
||||
unmatchedTableWarning: "還有 {count} 張來源資料表未比對目標資料表",
|
||||
targetTableInUse: "此目標資料表已被其他來源資料表使用",
|
||||
sourceObject: "來源物件",
|
||||
targetObject: "目標物件",
|
||||
saveConfig: "儲存配置",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
export type SchemaDiffTableFilterPriority = "include" | "exclude";
|
||||
|
||||
export interface SchemaDiffTableMapping {
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
}
|
||||
|
||||
export interface SchemaDiffCompareOptions {
|
||||
tables: boolean;
|
||||
primaryKeys: boolean;
|
||||
@@ -30,6 +35,7 @@ export interface SchemaDiffCompareOptions {
|
||||
* list so newly added tables keep entering unrestricted comparisons and configs stay small.
|
||||
*/
|
||||
selectedTables: string[] | undefined;
|
||||
tableMappings?: SchemaDiffTableMapping[];
|
||||
detectRenames: boolean;
|
||||
renameThreshold: number;
|
||||
detectTableRenames: boolean;
|
||||
@@ -72,7 +78,7 @@ export interface SchemaDiffOptionItem {
|
||||
}
|
||||
|
||||
export type BooleanSchemaDiffCompareOptionKey = {
|
||||
[K in keyof SchemaDiffCompareOptions]: SchemaDiffCompareOptions[K] extends boolean ? K : never;
|
||||
[K in keyof SchemaDiffCompareOptions]-?: NonNullable<SchemaDiffCompareOptions[K]> extends boolean ? K : never;
|
||||
}[keyof SchemaDiffCompareOptions];
|
||||
|
||||
export type SchemaDiffOptionsMap = Partial<Record<string, SchemaDiffOptionItem[]>>;
|
||||
@@ -98,6 +104,7 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
selectedTables: undefined,
|
||||
tableMappings: [],
|
||||
detectRenames: false,
|
||||
renameThreshold: 0.5,
|
||||
detectTableRenames: false,
|
||||
@@ -130,6 +137,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
selectedTables: undefined,
|
||||
tableMappings: [],
|
||||
detectRenames: false,
|
||||
renameThreshold: 0.5,
|
||||
detectTableRenames: false,
|
||||
@@ -149,9 +157,11 @@ export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOpt
|
||||
}
|
||||
|
||||
export function normalizeSchemaDiffCompareOptions(options: Partial<SchemaDiffCompareOptions> | null | undefined, dbType = "postgres"): SchemaDiffCompareOptions {
|
||||
const defaults = getDefaultOptionsForDbType(dbType);
|
||||
return {
|
||||
...getDefaultOptionsForDbType(dbType),
|
||||
...defaults,
|
||||
...options,
|
||||
tableMappings: Array.isArray(options?.tableMappings) ? options.tableMappings.map((mapping) => ({ ...mapping })) : defaults.tableMappings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user