feat(compare): keep schema and data compares running in background
This commit is contained in:
1 parent
29c6d1fbed
commit
dc7b905220
15 files changed
+1643
-535
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,60 +13,27 @@ import { useToast } from "@/composables/useToast";
|
||||
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "@/lib/dataGrid/dataCompare";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import type { DataCompareCellValue, DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare";
|
||||
import { inferCompareKeyColumns } from "@/lib/dataGrid/dataCompare";
|
||||
import {
|
||||
buildDataCompareSyncPlanTables,
|
||||
emptyDataCompareSyncPlan,
|
||||
getDataCompareSession,
|
||||
startDataCompareSession,
|
||||
type CompareColumn,
|
||||
type DataCompareSession,
|
||||
type DataCompareTableResult,
|
||||
type DataCompareTableStatus,
|
||||
type DataCompareTableTask,
|
||||
type DiffKind,
|
||||
type SelectableDataCompareModifiedRow,
|
||||
type SelectableDataCompareRow,
|
||||
} from "@/composables/useDataCompareSession";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
|
||||
|
||||
type CompareColumn = ColumnInfo;
|
||||
|
||||
interface DataCompareTableTask {
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
}
|
||||
|
||||
type DataCompareTableStatus = "different" | "same" | "error";
|
||||
type DiffKind = "added" | "removed" | "modified";
|
||||
|
||||
interface SelectableDataCompareRow extends DataCompareRow {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface SelectableDataCompareModifiedRow extends DataCompareModifiedRow {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface SelectableDataCompareResult {
|
||||
added: SelectableDataCompareRow[];
|
||||
removed: SelectableDataCompareRow[];
|
||||
modified: SelectableDataCompareModifiedRow[];
|
||||
}
|
||||
|
||||
interface DataCompareTableResult {
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
keyColumns: string[];
|
||||
columns: string[];
|
||||
columnInfo: CompareColumn[];
|
||||
status: DataCompareTableStatus;
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
sourceRowCount: number;
|
||||
targetRowCount: number;
|
||||
sourceTruncated: boolean;
|
||||
targetTruncated: boolean;
|
||||
databaseType?: DatabaseType;
|
||||
preSyncStatements?: string[];
|
||||
diff: SelectableDataCompareResult;
|
||||
expanded: boolean;
|
||||
showAll: Record<DiffKind, boolean>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PREVIEW_LIMIT_OPTIONS = [50, 100, 200, 500];
|
||||
const SYNC_EXECUTE_BATCH_SIZE = 500;
|
||||
|
||||
@@ -80,6 +47,7 @@ const props = defineProps<{
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTable?: string;
|
||||
sessionId?: string | null;
|
||||
}>();
|
||||
|
||||
const sourceConnectionId = ref("");
|
||||
@@ -102,7 +70,7 @@ const targetTables = ref<string[]>([]);
|
||||
const keyColumnsText = ref("");
|
||||
const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1]));
|
||||
const batchResults = ref<DataCompareTableResult[]>([]);
|
||||
const syncPlan = ref<DataCompareSyncPlan>(emptySyncPlan());
|
||||
const syncPlan = ref<DataCompareSyncPlan>(emptyDataCompareSyncPlan());
|
||||
const comparing = ref(false);
|
||||
const planningSync = ref(false);
|
||||
const compareProgressCurrent = ref(0);
|
||||
@@ -116,8 +84,12 @@ const showAdded = ref(true);
|
||||
const showRemoved = ref(true);
|
||||
const showModified = ref(true);
|
||||
|
||||
const activeSessionId = ref<string | null>(props.sessionId ?? null);
|
||||
let syncPlanRequestId = 0;
|
||||
let initializingPrefill = false;
|
||||
let initializingPrefillGeneration = 0;
|
||||
let componentUnmounted = false;
|
||||
let shownSessionError = "";
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
@@ -196,21 +168,6 @@ const compareProgressLabel = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function emptySyncPlan(): DataCompareSyncPlan {
|
||||
return {
|
||||
insertCount: 0,
|
||||
updateCount: 0,
|
||||
deleteCount: 0,
|
||||
statementCount: 0,
|
||||
syncStatements: [],
|
||||
syncSql: "",
|
||||
};
|
||||
}
|
||||
|
||||
function targetDatabaseType(): DatabaseType | undefined {
|
||||
return store.getConfig(targetConnectionId.value)?.db_type;
|
||||
}
|
||||
|
||||
function resetSelectedSourceTables(nextTables: Iterable<string>) {
|
||||
selectedSourceTables.value = new Set(nextTables);
|
||||
}
|
||||
@@ -229,16 +186,77 @@ function buildCompareTasks(): DataCompareTableTask[] {
|
||||
|
||||
function clearResult() {
|
||||
batchResults.value = [];
|
||||
syncPlan.value = emptySyncPlan();
|
||||
syncPlan.value = emptyDataCompareSyncPlan();
|
||||
syncErrors.value = [];
|
||||
compareProgressCurrent.value = 0;
|
||||
compareProgressTotal.value = 0;
|
||||
compareProgressTable.value = "";
|
||||
executedCount.value = 0;
|
||||
executeTotal.value = 0;
|
||||
planningSync.value = false;
|
||||
syncPlanRequestId++;
|
||||
}
|
||||
|
||||
function comparisonEndpointLabel(connectionId: string, database: string, schema: string): string {
|
||||
const connection = store.getConfig(connectionId);
|
||||
return [connection?.name || connectionId, database, schema].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
async function restoreDataCompareSession(session: DataCompareSession): Promise<void> {
|
||||
const config = session.config;
|
||||
const generation = ++initializingPrefillGeneration;
|
||||
initializingPrefill = true;
|
||||
try {
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
sourceDatabase.value = config.sourceDatabase;
|
||||
sourceSchema.value = config.sourceSchema;
|
||||
sourceDatabases.value = [...config.sourceDatabases];
|
||||
sourceSchemas.value = [...config.sourceSchemas];
|
||||
sourceTables.value = [...config.sourceTables];
|
||||
resetSelectedSourceTables(config.selectedSourceTables);
|
||||
sourceTable.value = config.selectedSourceTables.length === 1 ? (config.selectedSourceTables[0] ?? "") : "";
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
targetDatabases.value = [...config.targetDatabases];
|
||||
targetSchemas.value = [...config.targetSchemas];
|
||||
targetTables.value = [...config.targetTables];
|
||||
targetTable.value = config.targetTable;
|
||||
keyColumnsText.value = config.keyColumns.join(", ");
|
||||
batchResults.value = session.batchResults;
|
||||
syncPlan.value = session.syncPlan;
|
||||
syncErrors.value = [];
|
||||
compareProgressCurrent.value = session.progress?.current ?? 0;
|
||||
compareProgressTotal.value = session.progress?.total ?? 0;
|
||||
compareProgressTable.value = session.progress?.table ?? "";
|
||||
comparing.value = session.status === "running";
|
||||
} finally {
|
||||
await nextTick();
|
||||
if (generation === initializingPrefillGeneration) initializingPrefill = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDataCompareSession(session: DataCompareSession | undefined): void {
|
||||
if (!session) return;
|
||||
batchResults.value = session.batchResults;
|
||||
syncPlan.value = session.syncPlan;
|
||||
if (session.status === "running") {
|
||||
comparing.value = true;
|
||||
compareProgressCurrent.value = session.progress?.current ?? 0;
|
||||
compareProgressTotal.value = session.progress?.total ?? 0;
|
||||
compareProgressTable.value = session.progress?.table ?? "";
|
||||
return;
|
||||
}
|
||||
comparing.value = false;
|
||||
compareProgressCurrent.value = 0;
|
||||
compareProgressTotal.value = 0;
|
||||
compareProgressTable.value = "";
|
||||
if (session.status === "failed" && session.error && shownSessionError !== session.error) {
|
||||
shownSessionError = session.error;
|
||||
toast(session.error, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function swapSourceTarget() {
|
||||
const previousSelectedTables = [...selectedSourceTableNames.value];
|
||||
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
|
||||
@@ -393,32 +411,6 @@ function resultStatusClass(status: DataCompareTableStatus): string {
|
||||
return "bg-destructive/15 text-destructive";
|
||||
}
|
||||
|
||||
function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
|
||||
return {
|
||||
added: diff.added.map((row) => ({ ...row, selected: true })),
|
||||
removed: diff.removed.map((row) => ({ ...row, selected: true })),
|
||||
modified: diff.modified.map((row) => ({ ...row, selected: true })),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedDiff(table: DataCompareTableResult): DataCompareResult {
|
||||
return {
|
||||
added: table.diff.added.filter((row) => row.selected).map(stripSelectedRow),
|
||||
removed: table.diff.removed.filter((row) => row.selected).map(stripSelectedRow),
|
||||
modified: table.diff.modified.filter((row) => row.selected).map(stripSelectedModifiedRow),
|
||||
};
|
||||
}
|
||||
|
||||
function stripSelectedRow(row: SelectableDataCompareRow): DataCompareRow {
|
||||
const { selected: _selected, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function stripSelectedModifiedRow(row: SelectableDataCompareModifiedRow): DataCompareModifiedRow {
|
||||
const { selected: _selected, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function hasDiffRows(table: DataCompareTableResult, kind: DiffKind): boolean {
|
||||
return table.diff[kind].length > 0;
|
||||
}
|
||||
@@ -480,27 +472,26 @@ function selectedDiffCount(kind: DiffKind) {
|
||||
return batchResults.value.reduce((sum, table) => sum + selectedRows(table, kind), 0);
|
||||
}
|
||||
|
||||
function buildSyncPlanTables(): DataCompareSyncPlanTableOptions[] {
|
||||
return batchResults.value
|
||||
.filter((table) => table.status === "different")
|
||||
.map((table) => ({
|
||||
tableName: table.targetTable,
|
||||
schema: targetSchema.value,
|
||||
columns: table.columns,
|
||||
keyColumns: table.keyColumns,
|
||||
columnInfo: table.columnInfo,
|
||||
diff: buildSelectedDiff(table),
|
||||
databaseType: table.databaseType,
|
||||
preSyncStatements: table.preSyncStatements ?? [],
|
||||
}))
|
||||
.filter((table) => table.preSyncStatements.length > 0 || table.diff.added.length > 0 || table.diff.removed.length > 0 || table.diff.modified.length > 0);
|
||||
function buildSyncPlanTables() {
|
||||
return buildDataCompareSyncPlanTables(batchResults.value, targetSchema.value);
|
||||
}
|
||||
|
||||
function updateSyncPlan(nextPlan: DataCompareSyncPlan, sessionId = activeSessionId.value): void {
|
||||
if (!componentUnmounted) syncPlan.value = nextPlan;
|
||||
const session = getDataCompareSession(sessionId);
|
||||
if (session?.status === "completed") {
|
||||
session.syncPlan = nextPlan;
|
||||
session.batchResults = batchResults.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildSyncPlan() {
|
||||
if (componentUnmounted) return;
|
||||
const requestId = ++syncPlanRequestId;
|
||||
const sessionId = activeSessionId.value;
|
||||
const tables = buildSyncPlanTables();
|
||||
if (tables.length === 0) {
|
||||
syncPlan.value = emptySyncPlan();
|
||||
updateSyncPlan(emptyDataCompareSyncPlan(), sessionId);
|
||||
planningSync.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -508,17 +499,17 @@ async function rebuildSyncPlan() {
|
||||
try {
|
||||
const plan = await api.buildDataCompareSyncPlan({ tables });
|
||||
if (requestId !== syncPlanRequestId) return;
|
||||
syncPlan.value = plan;
|
||||
updateSyncPlan(plan, sessionId);
|
||||
} catch (e: any) {
|
||||
if (requestId !== syncPlanRequestId) return;
|
||||
syncPlan.value = emptySyncPlan();
|
||||
toast(e?.message || String(e), 5000);
|
||||
updateSyncPlan(emptyDataCompareSyncPlan(), sessionId);
|
||||
if (!componentUnmounted) toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
if (requestId === syncPlanRequestId) planningSync.value = false;
|
||||
if (!componentUnmounted && requestId === syncPlanRequestId) planningSync.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCompare() {
|
||||
function startCompare(): void {
|
||||
if (!canCompare.value || comparing.value) return;
|
||||
const tasks = buildCompareTasks();
|
||||
if (tasks.length === 0) {
|
||||
@@ -526,174 +517,39 @@ async function startCompare() {
|
||||
return;
|
||||
}
|
||||
|
||||
comparing.value = true;
|
||||
clearResult();
|
||||
compareProgressTotal.value = tasks.length;
|
||||
|
||||
const sourceColumnCache = new Map<string, CompareColumn[]>();
|
||||
const targetColumnCache = new Map<string, CompareColumn[]>();
|
||||
const results: DataCompareTableResult[] = [];
|
||||
const currentTargetDatabaseType = targetDatabaseType();
|
||||
|
||||
try {
|
||||
await Promise.all([store.ensureConnected(sourceConnectionId.value), store.ensureConnected(targetConnectionId.value)]);
|
||||
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
compareProgressCurrent.value = index + 1;
|
||||
compareProgressTable.value = task.sourceTable;
|
||||
|
||||
try {
|
||||
if (!targetTables.value.includes(task.targetTable)) {
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : [];
|
||||
const sourceColumnNames = sourceColumns.map((column) => column.name);
|
||||
const sourceKeyColumns = resolvedKeys.map((key) => matchColumnNameIgnoreCase(key, sourceColumnNames) ?? key);
|
||||
const preparation = await api.prepareDataCompareMissingTarget({
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
sourceTable: task.sourceTable,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: sourceKeyColumns,
|
||||
});
|
||||
results.push({
|
||||
sourceTable: task.sourceTable,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: sourceKeyColumns,
|
||||
columns: sourceColumns.map((column) => column.name),
|
||||
columnInfo: sourceColumns,
|
||||
status: "different",
|
||||
added: preparation.result.added.length,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
sourceRowCount: preparation.sourceRowCount,
|
||||
targetRowCount: 0,
|
||||
sourceTruncated: preparation.sourceTruncated,
|
||||
targetTruncated: false,
|
||||
databaseType: currentTargetDatabaseType,
|
||||
preSyncStatements: preparation.preSyncStatements,
|
||||
diff: toSelectableDiff(preparation.result),
|
||||
expanded: preparation.result.added.length > 0,
|
||||
showAll: {
|
||||
added: false,
|
||||
removed: false,
|
||||
modified: false,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
|
||||
if (resolvedKeys.length === 0) {
|
||||
throw new Error(t("dataCompare.noKeyColumns"));
|
||||
}
|
||||
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const targetColumns = await loadColumnsWithCache(targetColumnCache, targetConnectionId.value, targetDatabase.value, targetSchema.value, task.targetTable);
|
||||
const matched = intersectCompareColumns(sourceColumns, targetColumns);
|
||||
const columns = matched.columns;
|
||||
const columnInfo = columns.map((column) => targetColumns.find((target) => target.name === column)).filter((column): column is CompareColumn => !!column);
|
||||
const canonicalKeyColumns: string[] = [];
|
||||
const missingKeys: string[] = [];
|
||||
for (const key of resolvedKeys) {
|
||||
const canonical = matchColumnNameIgnoreCase(key, columns);
|
||||
if (!canonical) {
|
||||
missingKeys.push(key);
|
||||
} else if (!canonicalKeyColumns.includes(canonical)) {
|
||||
canonicalKeyColumns.push(canonical);
|
||||
}
|
||||
}
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
|
||||
}
|
||||
if (columns.length === 0) {
|
||||
throw new Error(t("dataCompare.noCommonColumns"));
|
||||
}
|
||||
|
||||
const preparation = await api.prepareDataCompareFromTables({
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
sourceTable: task.sourceTable,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
targetTable: task.targetTable,
|
||||
columns,
|
||||
keyColumns: canonicalKeyColumns,
|
||||
sourceColumns: matched.sourceColumns,
|
||||
});
|
||||
|
||||
const added = preparation.result.added.length;
|
||||
const removed = preparation.result.removed.length;
|
||||
const modified = preparation.result.modified.length;
|
||||
const status: DataCompareTableStatus = added || removed || modified ? "different" : "same";
|
||||
|
||||
results.push({
|
||||
sourceTable: task.sourceTable,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: canonicalKeyColumns,
|
||||
columns,
|
||||
columnInfo,
|
||||
status,
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
sourceRowCount: preparation.sourceRowCount,
|
||||
targetRowCount: preparation.targetRowCount,
|
||||
sourceTruncated: preparation.sourceTruncated,
|
||||
targetTruncated: preparation.targetTruncated,
|
||||
databaseType: currentTargetDatabaseType,
|
||||
diff: toSelectableDiff(preparation.result),
|
||||
expanded: status === "different",
|
||||
showAll: {
|
||||
added: false,
|
||||
removed: false,
|
||||
modified: false,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
sourceTable: task.sourceTable,
|
||||
targetTable: task.targetTable,
|
||||
keyColumns: keyColumns.value,
|
||||
columns: [],
|
||||
columnInfo: [],
|
||||
status: "error",
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
sourceRowCount: 0,
|
||||
targetRowCount: 0,
|
||||
sourceTruncated: false,
|
||||
targetTruncated: false,
|
||||
databaseType: currentTargetDatabaseType,
|
||||
preSyncStatements: [],
|
||||
diff: { added: [], removed: [], modified: [] },
|
||||
expanded: false,
|
||||
showAll: {
|
||||
added: false,
|
||||
removed: false,
|
||||
modified: false,
|
||||
},
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
batchResults.value = results;
|
||||
await rebuildSyncPlan();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
comparing.value = false;
|
||||
compareProgressCurrent.value = 0;
|
||||
compareProgressTotal.value = 0;
|
||||
compareProgressTable.value = "";
|
||||
}
|
||||
shownSessionError = "";
|
||||
const session = startDataCompareSession(
|
||||
{
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
sourceDatabases: [...sourceDatabases.value],
|
||||
sourceSchemas: [...sourceSchemas.value],
|
||||
sourceTables: [...sourceTables.value],
|
||||
selectedSourceTables: [...selectedSourceTables.value],
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
targetDatabases: [...targetDatabases.value],
|
||||
targetSchemas: [...targetSchemas.value],
|
||||
targetTables: [...targetTables.value],
|
||||
targetTable: targetTable.value,
|
||||
keyColumns: [...keyColumns.value],
|
||||
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
|
||||
},
|
||||
tasks,
|
||||
{
|
||||
ensureConnected: (connectionId) => store.ensureConnected(connectionId),
|
||||
getConfig: (connectionId) => store.getConfig(connectionId),
|
||||
formatError: (kind, columns) => {
|
||||
if (kind === "missingKeyColumns") return t("dataCompare.missingKeyColumns", { columns: columns ?? "" });
|
||||
return kind === "noCommonColumns" ? t("dataCompare.noCommonColumns") : t("dataCompare.noKeyColumns");
|
||||
},
|
||||
},
|
||||
);
|
||||
activeSessionId.value = session.id;
|
||||
applyDataCompareSession(session);
|
||||
}
|
||||
|
||||
async function copySql() {
|
||||
@@ -798,6 +654,7 @@ watch(sourceConnectionId, (id) => {
|
||||
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
watch(targetConnectionId, (id) => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
targetDatabase.value = "";
|
||||
targetSchema.value = "";
|
||||
@@ -817,6 +674,7 @@ watch(sourceDatabase, () => {
|
||||
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
watch(targetDatabase, () => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
targetSchema.value = "";
|
||||
targetSchemas.value = [];
|
||||
@@ -833,12 +691,14 @@ watch(sourceSchema, () => {
|
||||
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
watch(targetSchema, () => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
targetTables.value = [];
|
||||
targetTable.value = "";
|
||||
if (targetSchema.value) loadTables("target").catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
watch(selectedSourceTableNames, (tables, previous) => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
sourceTable.value = tables.length === 1 ? tables[0] : "";
|
||||
if (tables.length !== 1) {
|
||||
@@ -856,13 +716,27 @@ watch(selectedSourceTableNames, (tables, previous) => {
|
||||
}
|
||||
inferKeyColumns().catch(() => {});
|
||||
});
|
||||
watch(targetTable, () => clearResult());
|
||||
watch(targetTable, () => {
|
||||
if (initializingPrefill) return;
|
||||
clearResult();
|
||||
});
|
||||
watch(
|
||||
open,
|
||||
async (value) => {
|
||||
[() => open.value, () => props.sessionId],
|
||||
async ([value, sessionId]) => {
|
||||
if (!value) return;
|
||||
clearResult();
|
||||
shownSessionError = "";
|
||||
const session = getDataCompareSession(sessionId);
|
||||
if (session) {
|
||||
activeSessionId.value = session.id;
|
||||
await restoreDataCompareSession(session);
|
||||
applyDataCompareSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
activeSessionId.value = null;
|
||||
if (props.prefillConnectionId) {
|
||||
const generation = ++initializingPrefillGeneration;
|
||||
initializingPrefill = true;
|
||||
try {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
@@ -877,12 +751,26 @@ watch(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
initializingPrefill = false;
|
||||
await nextTick();
|
||||
if (generation === initializingPrefillGeneration) initializingPrefill = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => {
|
||||
const session = getDataCompareSession(activeSessionId.value);
|
||||
return session ? { id: session.id, version: session.version } : null;
|
||||
},
|
||||
() => {
|
||||
if (!componentUnmounted) applyDataCompareSession(getDataCompareSession(activeSessionId.value));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
componentUnmounted = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -901,6 +789,7 @@ watch(
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<ConnectionTreeSelect
|
||||
v-model="sourceConnectionId"
|
||||
:disabled="comparing"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
@@ -915,7 +804,7 @@ watch(
|
||||
:placeholder="t('diff.selectDatabase')"
|
||||
:search-placeholder="t('diff.searchDatabase')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="!sourceDatabases.length"
|
||||
:disabled="comparing || !sourceDatabases.length"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -924,6 +813,7 @@ watch(
|
||||
v-if="sourceSchemas.length"
|
||||
v-model="sourceSchema"
|
||||
:options="sourceSchemas"
|
||||
:disabled="comparing"
|
||||
:placeholder="t('diff.selectSchema')"
|
||||
:search-placeholder="t('diff.searchSchema')"
|
||||
:empty-text="t('common.noResults')"
|
||||
@@ -938,11 +828,12 @@ watch(
|
||||
:tables="sourceTables"
|
||||
:title="t('dataCompare.sourceTables')"
|
||||
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
|
||||
:disabled="comparing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" @click="swapSourceTarget">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing" @click="swapSourceTarget">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -951,6 +842,7 @@ watch(
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<ConnectionTreeSelect
|
||||
v-model="targetConnectionId"
|
||||
:disabled="comparing"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
@@ -965,7 +857,7 @@ watch(
|
||||
:placeholder="t('diff.selectDatabase')"
|
||||
:search-placeholder="t('diff.searchDatabase')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="!targetDatabases.length"
|
||||
:disabled="comparing || !targetDatabases.length"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -974,6 +866,7 @@ watch(
|
||||
v-if="targetSchemas.length"
|
||||
v-model="targetSchema"
|
||||
:options="targetSchemas"
|
||||
:disabled="comparing"
|
||||
:placeholder="t('diff.selectSchema')"
|
||||
:search-placeholder="t('diff.searchSchema')"
|
||||
:empty-text="t('common.noResults')"
|
||||
@@ -990,6 +883,7 @@ watch(
|
||||
:placeholder="t('dataCompare.selectTable')"
|
||||
:search-placeholder="t('dataCompare.searchTable')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="comparing"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -1018,12 +912,17 @@ watch(
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
|
||||
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" />
|
||||
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" :disabled="comparing" />
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ t("dataCompare.keyColumnsAutoHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="comparing" class="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span>{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasResults" class="space-y-3">
|
||||
<div class="rounded-lg border p-3 text-sm space-y-2">
|
||||
<div>{{ summary }}</div>
|
||||
@@ -1211,7 +1110,8 @@ watch(
|
||||
|
||||
<DialogFooter v-else class="flex items-center gap-2">
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
|
||||
<span v-if="comparing" class="text-xs text-muted-foreground mr-auto">{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
|
||||
<span v-else-if="executing" class="text-xs text-muted-foreground mr-auto">
|
||||
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
|
||||
</span>
|
||||
<span v-else-if="planningSync" class="text-xs text-muted-foreground mr-auto">
|
||||
|
||||
@@ -18,18 +18,19 @@ import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue
|
||||
|
||||
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
|
||||
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
|
||||
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 { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
|
||||
import { createSchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
|
||||
import { getSchemaDiffSession, schemaDiffSessionObjects, startSchemaDiffSession, type SchemaDiffSession } from "@/composables/useSchemaDiffSession";
|
||||
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
|
||||
import {
|
||||
convertToSchemaDiffObjects,
|
||||
detectDestructiveSchemaDiffStatements,
|
||||
groupDiffObjects,
|
||||
injectColumnRenameSql,
|
||||
schemaDiffDeployTargetSchema,
|
||||
normalizeDialectKind,
|
||||
databaseTypeToDialectKind,
|
||||
findSchemaDiffObject,
|
||||
flattenSchemaDiffObjects,
|
||||
schemaDiffSelectionTargets,
|
||||
@@ -39,8 +40,6 @@ import {
|
||||
setSchemaDiffObjectSelected,
|
||||
setSchemaDiffObjectSelectedWithDependencies,
|
||||
summarizeSchemaDiffOperations,
|
||||
databaseTypeToDialectKind,
|
||||
normalizeDialectKind,
|
||||
type OperationGroup,
|
||||
type SchemaDiffObject,
|
||||
type DiffOperationType,
|
||||
@@ -53,7 +52,7 @@ import {
|
||||
type DependencyGraph,
|
||||
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";
|
||||
@@ -71,6 +70,7 @@ const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
sessionId?: string | null;
|
||||
}>();
|
||||
|
||||
// Wizard state
|
||||
@@ -117,7 +117,10 @@ interface SchemaDiffProgress {
|
||||
const loading = ref(false);
|
||||
const schemaDiffProgress = ref<SchemaDiffProgress | null>(null);
|
||||
const schemaDiffHasExtraObjectPhase = ref(false);
|
||||
let comparisonRequestId = 0;
|
||||
const activeSessionId = ref<string | null>(props.sessionId ?? null);
|
||||
let appliedSessionResult: SchemaDiffPreparation | null = null;
|
||||
let shownSessionError = "";
|
||||
let componentUnmounted = false;
|
||||
const diffObjects = ref<SchemaDiffObject[]>([]);
|
||||
const diffGroups = ref<OperationGroup[]>([]);
|
||||
const selectedObjectId = ref<string | null>(null);
|
||||
@@ -221,6 +224,7 @@ let resizeObserver: ResizeObserver | null = null;
|
||||
let saveTimeout: number | null = null;
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (componentUnmounted) return;
|
||||
const el = document.querySelector('[data-slot="dialog-content"]') as HTMLElement;
|
||||
if (!el) return;
|
||||
|
||||
@@ -254,7 +258,9 @@ watch(
|
||||
() => step.value,
|
||||
(newStep) => {
|
||||
if (newStep === "result") {
|
||||
setTimeout(setupResizeObserver, 100);
|
||||
setTimeout(() => {
|
||||
if (!componentUnmounted) setupResizeObserver();
|
||||
}, 100);
|
||||
} else {
|
||||
teardownResizeObserver();
|
||||
}
|
||||
@@ -262,6 +268,7 @@ watch(
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
componentUnmounted = true;
|
||||
teardownResizeObserver();
|
||||
});
|
||||
|
||||
@@ -325,7 +332,6 @@ const schemaDiffNextProgressLabel = computed(() => {
|
||||
});
|
||||
|
||||
function resetComparisonResultState() {
|
||||
comparisonRequestId++;
|
||||
selectedDeploySqlGeneration++;
|
||||
focusedDeploySqlGeneration++;
|
||||
loading.value = false;
|
||||
@@ -354,32 +360,108 @@ function resetComparisonResultState() {
|
||||
deployResult.value = null;
|
||||
}
|
||||
|
||||
// Watch for prefilled values
|
||||
function comparisonEndpointLabel(connectionId: string, database: string, schema: string): string {
|
||||
const connection = store.getConfig(connectionId);
|
||||
return [connection?.name || connectionId, database, schema].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function restoreSchemaDiffSession(session: SchemaDiffSession): void {
|
||||
const config = session.config;
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
sourceDatabase.value = config.sourceDatabase;
|
||||
sourceSchema.value = config.sourceSchema;
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
ignoreComments.value = config.ignoreComments;
|
||||
ensureDefaultConfig(config.targetDbType);
|
||||
updateActiveConfigConnection({
|
||||
sourceConnectionId: config.sourceConnectionId,
|
||||
sourceDatabase: config.sourceDatabase,
|
||||
sourceSchema: config.sourceSchema,
|
||||
targetConnectionId: config.targetConnectionId,
|
||||
targetDatabase: config.targetDatabase,
|
||||
targetSchema: config.targetSchema,
|
||||
});
|
||||
updateActiveConfigOptions(config.options);
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(config.targetDbType, config.options);
|
||||
}
|
||||
|
||||
function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
|
||||
if (!session) return;
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(session.config.targetDbType, session.config.options);
|
||||
if (session.status === "running") {
|
||||
loading.value = true;
|
||||
step.value = "compare";
|
||||
schemaDiffProgress.value = session.progress ?? { phase: "loading-table-lists" };
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
schemaDiffProgress.value = null;
|
||||
if (session.status === "failed") {
|
||||
step.value = "config";
|
||||
if (session.error && shownSessionError !== session.error) {
|
||||
shownSessionError = session.error;
|
||||
toast(session.error, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!session.result) return;
|
||||
|
||||
step.value = "result";
|
||||
if (appliedSessionResult === session.result) return;
|
||||
appliedSessionResult = session.result;
|
||||
rollbackSql.value = session.result.rollbackSyncSql ?? "";
|
||||
rollbackCompleteness.value = session.result.rollbackCompleteness ?? "complete";
|
||||
missingRollbackObjects.value = session.result.missingRollbackObjects ?? [];
|
||||
renameCandidates.value = session.result.renameCandidates ?? [];
|
||||
compatibilityWarnings.value = session.result.compatibilityWarnings ?? [];
|
||||
permissionDiffs.value = session.result.permissionDiffs ?? [];
|
||||
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(session.result.dependencyGraph);
|
||||
diffObjects.value = schemaDiffSessionObjects(session.result);
|
||||
diffGroups.value = groupDiffObjects(diffObjects.value);
|
||||
lastDiffResult.value = session.result;
|
||||
deploySqlMode.value = "forward";
|
||||
void regenerateSelectedDeploySql();
|
||||
void regenerateFocusedDeploySql();
|
||||
}
|
||||
|
||||
// A Compare session deliberately outlives this v-if-mounted dialog. Closing the
|
||||
// dialog only removes the view; the session runner keeps updating its task.
|
||||
watch(
|
||||
() => open.value,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetComparisonResultState();
|
||||
ensureDefaultConfig();
|
||||
if (props.prefillConnectionId) {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
if (props.prefillDatabase) {
|
||||
sourceDatabase.value = props.prefillDatabase;
|
||||
}
|
||||
if (props.prefillSchema) {
|
||||
sourceSchema.value = props.prefillSchema;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comparisonRequestId++;
|
||||
loading.value = false;
|
||||
schemaDiffProgress.value = null;
|
||||
schemaDiffHasExtraObjectPhase.value = false;
|
||||
[() => open.value, () => props.sessionId],
|
||||
([isOpen]) => {
|
||||
if (!isOpen) return;
|
||||
const session = getSchemaDiffSession(props.sessionId);
|
||||
activeSessionId.value = session?.id ?? null;
|
||||
appliedSessionResult = null;
|
||||
shownSessionError = "";
|
||||
resetComparisonResultState();
|
||||
if (session) {
|
||||
restoreSchemaDiffSession(session);
|
||||
applySchemaDiffSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDefaultConfig();
|
||||
if (props.prefillConnectionId) {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
||||
if (props.prefillSchema) sourceSchema.value = props.prefillSchema;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const session = getSchemaDiffSession(activeSessionId.value);
|
||||
return [session?.status, session?.progress, session?.result, session?.error] as const;
|
||||
},
|
||||
() => applySchemaDiffSession(getSchemaDiffSession(activeSessionId.value)),
|
||||
);
|
||||
|
||||
// Config sync
|
||||
watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, targetDatabase, targetSchema], ([srcConn, srcDb, srcSchema, tgtConn, tgtDb, tgtSchema]) => {
|
||||
updateActiveConfigConnection({
|
||||
@@ -462,195 +544,35 @@ function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompare() {
|
||||
const requestId = ++comparisonRequestId;
|
||||
const isCurrentRequest = () => requestId === comparisonRequestId;
|
||||
function handleCompare(): void {
|
||||
const sourceConfig = store.getConfig(sourceConnectionId.value);
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const targetDbType = (targetConfig?.db_type || "mysql") as DatabaseType;
|
||||
const sourceDbType = sourceConfig?.db_type || targetDbType;
|
||||
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, targetDbType);
|
||||
|
||||
loading.value = true;
|
||||
step.value = "compare";
|
||||
schemaDiffProgress.value = { phase: "loading-table-lists" };
|
||||
schemaDiffHasExtraObjectPhase.value = false;
|
||||
|
||||
// Reset Phase 4 state to prevent stale data from previous compares
|
||||
rollbackSql.value = "";
|
||||
rollbackCompleteness.value = "complete";
|
||||
missingRollbackObjects.value = [];
|
||||
renameCandidates.value = [];
|
||||
compatibilityWarnings.value = [];
|
||||
permissionDiffs.value = [];
|
||||
dependencyGraph.value = null;
|
||||
|
||||
try {
|
||||
const sourceConfig = store.getConfig(sourceConnectionId.value);
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const dbType = targetConfig?.db_type || "mysql";
|
||||
const sourceDbType = sourceConfig?.db_type || dbType;
|
||||
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
|
||||
const isPostgresLike = isSchemaDiffPostgresLike(dbType);
|
||||
const hasExtraObjectPhase = shouldLoadSchemaDiffExtraObjects(dbType, opts);
|
||||
schemaDiffHasExtraObjectPhase.value = hasExtraObjectPhase;
|
||||
const tableFilter = compileSchemaDiffTableFilter(opts);
|
||||
|
||||
const sourceTableIdentity: SchemaDiffTableIdentity = { connectionId: sourceConnectionId.value, database: sourceDatabase.value, schema: sourceSchema.value };
|
||||
const targetTableIdentity: SchemaDiffTableIdentity = { connectionId: targetConnectionId.value, database: targetDatabase.value, schema: targetSchema.value };
|
||||
// The dialog can remain open while either database changes, so Compare must not reuse
|
||||
// the table list cached while configuring the comparison.
|
||||
const [srcTables, tgtTables] = await Promise.all([schemaDiffTableListLoader.load(sourceTableIdentity, { refresh: true }), schemaDiffTableListLoader.load(targetTableIdentity, { refresh: true })]);
|
||||
if (!isCurrentRequest()) return;
|
||||
// Explicit (visual) table selection is applied here, BEFORE any per-table
|
||||
// metadata details are loaded, so metadata requests only happen for the
|
||||
// final table set. `undefined`/empty means no restriction (legacy path).
|
||||
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts, opts.selectedTables);
|
||||
|
||||
schemaDiffProgress.value = { phase: "loading-source-details", current: 0, total: sourceTables.length };
|
||||
const sourceDetails = await loadSchemaDetails(
|
||||
sourceTables,
|
||||
{
|
||||
connectionId: sourceConnectionId.value,
|
||||
database: sourceDatabase.value,
|
||||
schema: sourceSchema.value,
|
||||
dbType: sourceDbType,
|
||||
options: opts,
|
||||
onProgress: (progress) => {
|
||||
if (isCurrentRequest()) schemaDiffProgress.value = { phase: "loading-source-details", ...progress };
|
||||
},
|
||||
},
|
||||
api,
|
||||
);
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
schemaDiffProgress.value = { phase: "loading-target-details", current: 0, total: targetTables.length };
|
||||
const targetDetails = await loadSchemaDetails(
|
||||
targetTables,
|
||||
{
|
||||
connectionId: targetConnectionId.value,
|
||||
database: targetDatabase.value,
|
||||
schema: targetSchema.value,
|
||||
dbType,
|
||||
options: opts,
|
||||
onProgress: (progress) => {
|
||||
if (isCurrentRequest()) schemaDiffProgress.value = { phase: "loading-target-details", ...progress };
|
||||
},
|
||||
},
|
||||
api,
|
||||
);
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
// Fetch new object types for PostgreSQL-like databases
|
||||
const promises: Promise<any>[] = [];
|
||||
if (isPostgresLike && opts?.functions) {
|
||||
promises.push(api.listFunctions(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
||||
promises.push(api.listFunctions(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
||||
}
|
||||
if (isPostgresLike && opts?.sequences) {
|
||||
promises.push(api.listSequences(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, !!opts?.sequenceLastValues));
|
||||
promises.push(api.listSequences(targetConnectionId.value, targetDatabase.value, targetSchema.value, !!opts?.sequenceLastValues));
|
||||
}
|
||||
if (isPostgresLike && opts?.rules) {
|
||||
promises.push(api.listRules(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
||||
promises.push(api.listRules(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
||||
}
|
||||
if (isPostgresLike && opts?.owners) {
|
||||
promises.push(api.listOwners(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
||||
promises.push(api.listOwners(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
||||
}
|
||||
|
||||
if (hasExtraObjectPhase) schemaDiffProgress.value = { phase: "loading-extra-objects" };
|
||||
const results = await Promise.all(promises);
|
||||
if (!isCurrentRequest()) return;
|
||||
let idx = 0;
|
||||
const srcFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
|
||||
const tgtFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
|
||||
const srcSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
|
||||
const tgtSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
|
||||
const srcRules = opts?.rules && isPostgresLike ? results[idx++] : [];
|
||||
const tgtRules = opts?.rules && isPostgresLike ? results[idx++] : [];
|
||||
const srcOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
|
||||
const tgtOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
|
||||
|
||||
schemaDiffProgress.value = { phase: "comparing" };
|
||||
const result = await api.prepareSchemaDiff({
|
||||
sourceTables,
|
||||
targetTables,
|
||||
sourceDetails,
|
||||
targetDetails,
|
||||
sourceFunctions: srcFunctions,
|
||||
targetFunctions: tgtFunctions,
|
||||
sourceSequences: srcSequences,
|
||||
targetSequences: tgtSequences,
|
||||
sourceRules: srcRules,
|
||||
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),
|
||||
appliedSessionResult = null;
|
||||
shownSessionError = "";
|
||||
resetComparisonResultState();
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(targetDbType, options);
|
||||
const session = startSchemaDiffSession(
|
||||
{
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
sourceDbType,
|
||||
targetDbType,
|
||||
options,
|
||||
ignoreComments: ignoreComments.value,
|
||||
cascadeDelete: opts?.cascadeDelete ?? false,
|
||||
compareColumnOrder: opts.compareColumnOrder,
|
||||
ignoreTableNameCase: opts.ignoreTableNameCase,
|
||||
ignoreColumnNameCase: opts.ignoreColumnNameCase,
|
||||
detectRenames: opts?.detectRenames ?? false,
|
||||
detectTableRenames: opts?.detectTableRenames ?? false,
|
||||
renameThreshold: opts?.renameThreshold ?? 0.5,
|
||||
enableRollback: opts?.enableRollback ?? false,
|
||||
batchPatterns: opts?.batchPatterns
|
||||
? opts.batchPatterns
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
sourceDialect: opts?.sourceDialect ? normalizeDialectKind(opts.sourceDialect) : sourceConfig?.db_type ? databaseTypeToDialectKind(sourceConfig.db_type) : undefined,
|
||||
targetDialect: opts?.targetDialect ? normalizeDialectKind(opts.targetDialect) : targetConfig?.db_type ? databaseTypeToDialectKind(targetConfig.db_type) : undefined,
|
||||
compatibilityThreshold: opts?.compatibilityThreshold ?? 0.5,
|
||||
fieldMappings:
|
||||
opts?.fieldMappings?.map((m: FieldMappingEntry) => ({
|
||||
sourceType: m.sourceType,
|
||||
targetType: m.targetType,
|
||||
paramStrategy: m.paramStrategy ?? "preserve",
|
||||
customParams: m.customParams,
|
||||
})) || [],
|
||||
});
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
// Extract new result fields
|
||||
rollbackSql.value = result.rollbackSyncSql ?? "";
|
||||
rollbackCompleteness.value = result.rollbackCompleteness ?? "complete";
|
||||
missingRollbackObjects.value = result.missingRollbackObjects ?? [];
|
||||
renameCandidates.value = result.renameCandidates ?? [];
|
||||
compatibilityWarnings.value = result.compatibilityWarnings ?? [];
|
||||
permissionDiffs.value = result.permissionDiffs ?? [];
|
||||
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(result.dependencyGraph);
|
||||
|
||||
// Convert to unified objects
|
||||
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates);
|
||||
|
||||
// Group by operation type and object kind
|
||||
diffGroups.value = groupDiffObjects(diffObjects.value);
|
||||
|
||||
// Save the structured result; both script previews are generated from the
|
||||
// current selection projection below rather than from the initial full SQL.
|
||||
lastDiffResult.value = result;
|
||||
deploySqlMode.value = "forward";
|
||||
schemaDiffProgress.value = { phase: "generating" };
|
||||
await regenerateSelectedDeploySql();
|
||||
await regenerateFocusedDeploySql();
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
schemaDiffProgress.value = null;
|
||||
step.value = "result";
|
||||
} catch (e: any) {
|
||||
if (!isCurrentRequest()) return;
|
||||
schemaDiffProgress.value = null;
|
||||
schemaDiffHasExtraObjectPhase.value = false;
|
||||
toast(e?.message || String(e), 5000);
|
||||
step.value = "config";
|
||||
} finally {
|
||||
if (isCurrentRequest()) loading.value = false;
|
||||
}
|
||||
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
|
||||
},
|
||||
{ tableListLoader: schemaDiffTableListLoader },
|
||||
);
|
||||
activeSessionId.value = session.id;
|
||||
applySchemaDiffSession(session);
|
||||
}
|
||||
|
||||
function handleToggleGroup(operationType: DiffOperationType) {
|
||||
@@ -738,10 +660,10 @@ async function regenerateSelectedDeploySql() {
|
||||
try {
|
||||
plan = await api.generateSchemaSyncPlan(input, buildSchemaSyncPlanOptions(options));
|
||||
} catch (error: any) {
|
||||
if (generation === selectedDeploySqlGeneration) toast(error?.message || String(error), 5000);
|
||||
if (!componentUnmounted && generation === selectedDeploySqlGeneration) toast(error?.message || String(error), 5000);
|
||||
return;
|
||||
}
|
||||
if (generation !== selectedDeploySqlGeneration) return;
|
||||
if (componentUnmounted || generation !== selectedDeploySqlGeneration) return;
|
||||
|
||||
const formatted = formatSchemaSyncPlan(plan, input, options);
|
||||
rollbackCompleteness.value = plan.rollbackCompleteness ?? "complete";
|
||||
@@ -767,10 +689,10 @@ async function regenerateFocusedDeploySql(objectId: string | null = selectedObje
|
||||
try {
|
||||
plan = await api.generateSchemaSyncPlan(input, buildSchemaSyncPlanOptions(options));
|
||||
} catch (error: any) {
|
||||
if (generation === focusedDeploySqlGeneration && selectedObjectId.value === objectId) toast(error?.message || String(error), 5000);
|
||||
if (!componentUnmounted && generation === focusedDeploySqlGeneration && selectedObjectId.value === objectId) toast(error?.message || String(error), 5000);
|
||||
return;
|
||||
}
|
||||
if (generation !== focusedDeploySqlGeneration || selectedObjectId.value !== objectId) return;
|
||||
if (componentUnmounted || generation !== focusedDeploySqlGeneration || selectedObjectId.value !== objectId) return;
|
||||
|
||||
const formatted = formatSchemaSyncPlan(plan, input, options);
|
||||
focusedForwardDeploySql.value = formatted.forwardSql;
|
||||
|
||||
+141
@@ -4,6 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
|
||||
import type { DataCompareSession } from "@/composables/useDataCompareSession";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -11,8 +12,21 @@ const mocks = vi.hoisted(() => ({
|
||||
listSchemas: vi.fn().mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]),
|
||||
listTables: vi.fn().mockResolvedValue([{ name: "CODEX_7467_META", table_type: "TABLE" }]),
|
||||
getColumns: vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_primary_key: true }]),
|
||||
buildDataCompareSyncPlan: vi.fn(),
|
||||
}));
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
session: null as DataCompareSession | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useDataCompareSession", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/composables/useDataCompareSession")>();
|
||||
return {
|
||||
...actual,
|
||||
getDataCompareSession: (id: string | null | undefined) => (id && sessionMocks.session?.id === id ? sessionMocks.session : undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => {
|
||||
const connections = [
|
||||
{ id: "oracle-11g", name: "Oracle XE 11g", db_type: "oracle", driver_profile: "oracle", database: "XE" },
|
||||
@@ -43,6 +57,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
listSchemas: mocks.listSchemas,
|
||||
listTables: mocks.listTables,
|
||||
getColumns: mocks.getColumns,
|
||||
buildDataCompareSyncPlan: mocks.buildDataCompareSyncPlan,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
@@ -57,9 +72,91 @@ async function flushAsyncSetup() {
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.textContent = "";
|
||||
sessionMocks.session = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function completedSession(): DataCompareSession {
|
||||
return {
|
||||
id: "compare-session",
|
||||
version: 1,
|
||||
status: "completed",
|
||||
config: {
|
||||
sourceConnectionId: "oracle-11g",
|
||||
sourceDatabase: "DBX_TEST",
|
||||
sourceSchema: "DBX_TEST",
|
||||
sourceDatabases: ["DBX_TEST"],
|
||||
sourceSchemas: ["DBX_TEST"],
|
||||
sourceTables: ["ORDERS"],
|
||||
selectedSourceTables: ["ORDERS"],
|
||||
targetConnectionId: "oracle-jdbc-11g",
|
||||
targetDatabase: "REPORTING",
|
||||
targetSchema: "DBX_TEST",
|
||||
targetDatabases: ["REPORTING"],
|
||||
targetSchemas: ["DBX_TEST"],
|
||||
targetTables: ["ORDERS"],
|
||||
targetTable: "ORDERS",
|
||||
keyColumns: ["ID"],
|
||||
label: "Oracle source → Oracle target",
|
||||
},
|
||||
progress: null,
|
||||
batchResults: [
|
||||
{
|
||||
sourceTable: "ORDERS",
|
||||
targetTable: "ORDERS",
|
||||
keyColumns: ["ID"],
|
||||
columns: ["ID", "NAME"],
|
||||
columnInfo: [],
|
||||
status: "different",
|
||||
added: 2,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
sourceRowCount: 2,
|
||||
targetRowCount: 0,
|
||||
sourceTruncated: false,
|
||||
targetTruncated: false,
|
||||
databaseType: "oracle",
|
||||
diff: {
|
||||
added: [
|
||||
{ key: "1", keyValues: { ID: 1 }, values: { ID: 1, NAME: "first" }, selected: true },
|
||||
{ key: "2", keyValues: { ID: 2 }, values: { ID: 2, NAME: "second" }, selected: true },
|
||||
],
|
||||
removed: [],
|
||||
modified: [],
|
||||
},
|
||||
expanded: true,
|
||||
showAll: { added: true, removed: true, modified: true },
|
||||
},
|
||||
],
|
||||
syncPlan: {
|
||||
insertCount: 2,
|
||||
updateCount: 0,
|
||||
deleteCount: 0,
|
||||
statementCount: 2,
|
||||
syncStatements: ["INSERT 1", "INSERT 2"],
|
||||
syncSql: "INSERT 1;\nINSERT 2;",
|
||||
},
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
finishedAt: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSessionDialog(session: DataCompareSession): App {
|
||||
sessionMocks.session = session;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(DataCompareDialog, { open: true, sessionId: session.id }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("DataCompareDialog source prefill", () => {
|
||||
it("keeps the Oracle source table after loading database and schema prefills", async () => {
|
||||
const container = document.createElement("div");
|
||||
@@ -148,3 +245,47 @@ describe("DataCompareDialog source prefill", () => {
|
||||
expect(document.body.textContent).toContain("CODEX_7467_META");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataCompareDialog session restore", () => {
|
||||
it("keeps restored fields, results, and sync SQL after queued watchers flush", async () => {
|
||||
const session = completedSession();
|
||||
mountSessionDialog(session);
|
||||
|
||||
await flushAsyncSetup();
|
||||
|
||||
expect(mocks.listSchemas).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("ORDERS");
|
||||
expect(document.body.textContent).toContain("ID=1");
|
||||
expect(document.querySelector<HTMLTextAreaElement>("textarea[readonly]")?.value).toBe("INSERT 1;\nINSERT 2;");
|
||||
});
|
||||
|
||||
it("persists the latest selection plan when the dialog closes during planning", async () => {
|
||||
const session = completedSession();
|
||||
let resolvePlan!: (plan: DataCompareSession["syncPlan"]) => void;
|
||||
mocks.buildDataCompareSyncPlan.mockReturnValueOnce(new Promise((resolve) => (resolvePlan = resolve)));
|
||||
const app = mountSessionDialog(session);
|
||||
await flushAsyncSetup();
|
||||
|
||||
const firstRow = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("ID=1"));
|
||||
expect(firstRow).toBeDefined();
|
||||
firstRow?.click();
|
||||
await nextTick();
|
||||
expect(mocks.buildDataCompareSyncPlan).toHaveBeenCalledOnce();
|
||||
|
||||
mountedApps.splice(mountedApps.indexOf(app), 1);
|
||||
app.unmount();
|
||||
resolvePlan({
|
||||
insertCount: 1,
|
||||
updateCount: 0,
|
||||
deleteCount: 0,
|
||||
statementCount: 1,
|
||||
syncStatements: ["INSERT 2"],
|
||||
syncSql: "INSERT 2;",
|
||||
});
|
||||
await flushAsyncSetup();
|
||||
|
||||
expect(session.batchResults[0]?.diff.added[0]?.selected).toBe(false);
|
||||
expect(session.syncPlan.syncStatements).toEqual(["INSERT 2"]);
|
||||
expect(session.syncPlan.syncSql).toBe("INSERT 2;");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../SchemaDiffDialog.vue", import.meta.url), "utf8");
|
||||
const sessionSource = readFileSync(new URL("../../../composables/useSchemaDiffSession.ts", import.meta.url), "utf8");
|
||||
const configStepSource = readFileSync(new URL("../SchemaDiffConfigStep.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
@@ -43,20 +44,20 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
|
||||
it("clears stale result and progress state whenever a new dialog session opens", () => {
|
||||
expect(dialogSource).toContain("function resetComparisonResultState() {");
|
||||
expect(dialogSource).toMatch(/if \(isOpen\) \{\s+resetComparisonResultState\(\);/);
|
||||
expect(dialogSource).toMatch(/if \(!isOpen\) return;[\s\S]*?resetComparisonResultState\(\);/);
|
||||
expect(dialogSource).toContain("diffObjects.value = [];");
|
||||
expect(dialogSource).toContain("lastDiffResult.value = null;");
|
||||
expect(dialogSource).toContain("schemaDiffProgress.value = null;");
|
||||
});
|
||||
|
||||
it("shows real metadata progress and indeterminate comparison phases without fabricated percentages", () => {
|
||||
expect(dialogSource).toContain('phase: "loading-table-lists"');
|
||||
expect(dialogSource).toContain('phase: "loading-source-details"');
|
||||
expect(dialogSource).toContain('phase: "loading-target-details"');
|
||||
expect(dialogSource).toContain('phase: "loading-extra-objects"');
|
||||
expect(dialogSource).toContain('phase: "comparing"');
|
||||
expect(dialogSource).toContain('phase: "generating"');
|
||||
expect(dialogSource).toContain("onProgress: (progress) =>");
|
||||
expect(sessionSource).toContain('phase: "loading-table-lists"');
|
||||
expect(sessionSource).toContain('phase: "loading-source-details"');
|
||||
expect(sessionSource).toContain('phase: "loading-target-details"');
|
||||
expect(sessionSource).toContain('phase: "loading-extra-objects"');
|
||||
expect(sessionSource).toContain('phase: "comparing"');
|
||||
expect(sessionSource).toContain('phase: "generating"');
|
||||
expect(sessionSource).toContain("onProgress: (progress) =>");
|
||||
expect(dialogSource).toContain("schemaDiffProgressCount");
|
||||
expect(dialogSource).toContain('role="progressbar"');
|
||||
expect(dialogSource).toContain("schema-diff-progress-indeterminate");
|
||||
@@ -72,15 +73,17 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
});
|
||||
|
||||
it("clears progress before preserving the existing comparison error flow", () => {
|
||||
expect(dialogSource).toMatch(/\} catch \(e: any\) \{\s+if \(!isCurrentRequest\(\)\) return;\s+schemaDiffProgress\.value = null;/);
|
||||
expect(dialogSource).toContain("toast(e?.message || String(e), 5000);");
|
||||
expect(sessionSource).toContain("session.error = message;");
|
||||
expect(sessionSource).toContain('session.status = "failed";');
|
||||
expect(dialogSource).toContain("toast(session.error, 5000);");
|
||||
expect(dialogSource).toContain('step.value = "config";');
|
||||
});
|
||||
|
||||
it("invalidates an active comparison when the dialog closes", () => {
|
||||
expect(dialogSource).toContain("const requestId = ++comparisonRequestId;");
|
||||
expect(dialogSource).toContain("if (!isCurrentRequest()) return;");
|
||||
expect(dialogSource).toMatch(/\} else \{\s+comparisonRequestId\+\+;\s+loading\.value = false;\s+schemaDiffProgress\.value = null;/);
|
||||
it("keeps an active comparison running after the dialog unmounts", () => {
|
||||
expect(dialogSource).toContain("const session = startSchemaDiffSession(");
|
||||
expect(dialogSource).toContain("onBeforeUnmount(() => {");
|
||||
expect(dialogSource).toContain("componentUnmounted = true;");
|
||||
expect(dialogSource).not.toContain("comparisonRequestId");
|
||||
});
|
||||
|
||||
it("does not restore persisted table matches before source and target identities are ready", () => {
|
||||
@@ -111,9 +114,9 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
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("ignoreTableNameCase: opts.ignoreTableNameCase");
|
||||
expect(dialogSource).toContain("ignoreColumnNameCase: opts.ignoreColumnNameCase");
|
||||
expect(sessionSource).toContain("tableMappings: options.selectedTables === undefined ? undefined : options.tableMappings");
|
||||
expect(sessionSource).toContain("ignoreTableNameCase: options.ignoreTableNameCase");
|
||||
expect(sessionSource).toContain("ignoreColumnNameCase: options.ignoreColumnNameCase");
|
||||
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user