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("");');
|
||||
|
||||
Reference in new issue
Block a user