diff --git a/apps/desktop/src/components/diff/CompareKeyColumnsSelect.vue b/apps/desktop/src/components/diff/CompareKeyColumnsSelect.vue new file mode 100644 index 0000000000..78cafb7140 --- /dev/null +++ b/apps/desktop/src/components/diff/CompareKeyColumnsSelect.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index d4ee57133a..21344d527c 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -3,7 +3,6 @@ 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"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue"; @@ -13,14 +12,13 @@ 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, DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare"; -import { inferCompareKeyColumns } from "@/lib/dataGrid/dataCompare"; +import { inferCompareKeyColumns, normalizeKeyColumns, sameKeyColumns, type CompareKeyColumnOption, type DataCompareCellValue, type DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare"; import { buildDataCompareSyncPlanTables, emptyDataCompareSyncPlan, getDataCompareSession, + normalizeKeyColumnOverrides, startDataCompareSession, - type CompareColumn, type DataCompareSession, type DataCompareTableResult, type DataCompareTableStatus, @@ -29,6 +27,7 @@ import { type SelectableDataCompareModifiedRow, type SelectableDataCompareRow, } from "@/composables/useDataCompareSession"; +import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue"; import * as api from "@/lib/backend/api"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import TableMultiSelect from "@/components/diff/TableMultiSelect.vue"; @@ -67,7 +66,6 @@ const targetDatabases = ref([]); const targetSchemas = ref([]); const targetTables = ref([]); -const keyColumnsText = ref(""); const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1])); const batchResults = ref([]); const syncPlan = ref(emptyDataCompareSyncPlan()); @@ -84,6 +82,21 @@ const showAdded = ref(true); const showRemoved = ref(true); const showModified = ref(true); +/** + * Explicit match columns per source table. An absent entry means "auto": the + * primary key of that table is inferred, so a batch compare never shares one + * global key column list across different tables. + */ +const keyColumnOverrides = ref>({}); +/** Column metadata per source table, keyed by table name; cleared with the source endpoint. */ +const tableColumns = ref>({}); +const tableColumnErrors = ref>({}); +const loadingColumnTables = ref([]); +const expandedKeyColumnTable = ref(""); +let columnRequests = new Map>(); +/** Invalidates in-flight metadata responses after a source endpoint change. */ +let columnMetadataGeneration = 0; + const activeSessionId = ref(props.sessionId ?? null); let syncPlanRequestId = 0; let initializingPrefill = false; @@ -112,13 +125,52 @@ const compareTasksPreview = computed(() => ); const matchedTaskCount = computed(() => compareTasksPreview.value.filter((task) => task.matched).length); const missingTargetTables = computed(() => compareTasksPreview.value.filter((task) => !task.matched).map((task) => task.targetTable || task.sourceTable)); -const canCompare = computed(() => sourceConnectionId.value && sourceDatabase.value && sourceSchema.value && selectedSourceTableNames.value.length > 0 && targetConnectionId.value && targetDatabase.value && targetSchema.value); -const keyColumns = computed(() => - keyColumnsText.value - .split(",") - .map((value) => value.trim()) - .filter(Boolean), + +interface KeyColumnRow { + table: string; + matched: boolean; + manual: boolean; + columns: string[]; + loading: boolean; + hint: string; + /** Set when the column metadata of this table could not be loaded. */ + error?: string; + /** `missing` means neither a primary key nor a manual selection is available yet. */ + status: "auto" | "manual" | "missing"; +} + +const keyColumnRows = computed(() => + selectedSourceTableNames.value.map((table) => { + const matched = !!compareTasksPreview.value.find((task) => task.sourceTable === table)?.matched; + const manual = hasKeyColumnOverride(table); + const columns = effectiveKeyColumns(table); + const error = tableColumnErrors.value[table]; + return { + table, + matched, + manual, + columns, + loading: isTableColumnsLoading(table), + hint: error || keyColumnHint(table, { matched, manual, columns }), + error, + status: manual ? "manual" : columns.length > 0 ? "auto" : "missing", + }; + }), ); +const singleKeyColumnRow = computed(() => (selectedSourceTableNames.value.length === 1 ? keyColumnRows.value[0] : undefined)); +/** + * A single-table compare whose target table exists but whose match columns are + * unknown: primary key inference found nothing and the user has not picked + * columns yet. Blocking here is safe because no other table depends on it. + * Unloaded metadata is deliberately not treated as "no primary key". + */ +const singleTableNeedsKeyColumns = computed(() => { + const row = singleKeyColumnRow.value; + if (!row || !row.matched) return false; + if (!isTableColumnsKnown(row.table)) return false; + return row.columns.length === 0; +}); +const canCompare = computed(() => sourceConnectionId.value && sourceDatabase.value && sourceSchema.value && selectedSourceTableNames.value.length > 0 && targetConnectionId.value && targetDatabase.value && targetSchema.value && !singleTableNeedsKeyColumns.value); const detailPreviewLimitNumber = computed(() => Number(detailPreviewLimit.value) || PREVIEW_LIMIT_OPTIONS[1]); const sameTableCount = computed(() => batchResults.value.filter((item) => item.status === "same").length); @@ -172,6 +224,157 @@ function resetSelectedSourceTables(nextTables: Iterable) { selectedSourceTables.value = new Set(nextTables); } +// --- Match columns (key columns) ------------------------------------------------- +// +// Every selected source table owns its match columns: the primary key is the +// default, and a manual picker selection is stored as a per-table override in +// `keyColumnOverrides`. The session receives the same per-table shape, so a +// batch compare is never forced to share one global key column list. + +function resetTableColumnMetadata() { + columnMetadataGeneration += 1; + columnRequests = new Map(); + tableColumns.value = {}; + tableColumnErrors.value = {}; + loadingColumnTables.value = []; + expandedKeyColumnTable.value = ""; +} + +function columnsForTable(table: string): CompareKeyColumnOption[] { + return tableColumns.value[table] ?? []; +} + +function isTableColumnsKnown(table: string): boolean { + return tableColumns.value[table] !== undefined; +} + +function isTableColumnsLoading(table: string): boolean { + return loadingColumnTables.value.includes(table); +} + +function hasKeyColumnOverride(table: string): boolean { + return keyColumnOverrides.value[table] !== undefined; +} + +function inferredKeyColumns(table: string): string[] { + return inferCompareKeyColumns(columnsForTable(table)); +} + +/** Effective match columns of a table: the manual override, otherwise the inferred primary key. */ +function effectiveKeyColumns(table: string): string[] { + const override = keyColumnOverrides.value[table]; + return override === undefined ? inferredKeyColumns(table) : override; +} + +async function fetchTableColumns(table: string): Promise { + const connectionId = sourceConnectionId.value; + const database = sourceDatabase.value; + const schema = sourceSchema.value; + if (!connectionId || !database || !schema || !table) return []; + const generation = columnMetadataGeneration; + loadingColumnTables.value = [...loadingColumnTables.value, table]; + try { + const columns = (await api.getColumns(connectionId, database, schema, table)) as CompareKeyColumnOption[]; + if (generation !== columnMetadataGeneration) return []; + tableColumns.value = { ...tableColumns.value, [table]: columns }; + delete tableColumnErrors.value[table]; + return columns; + } catch (error: any) { + if (generation === columnMetadataGeneration) tableColumnErrors.value = { ...tableColumnErrors.value, [table]: error?.message || String(error) }; + return []; + } finally { + if (generation === columnMetadataGeneration) loadingColumnTables.value = loadingColumnTables.value.filter((item) => item !== table); + } +} + +function loadTableColumns(table: string): Promise { + const cached = tableColumns.value[table]; + if (cached) return Promise.resolve(cached); + const pending = columnRequests.get(table); + if (pending) return pending; + const request = fetchTableColumns(table).finally(() => columnRequests.delete(table)); + columnRequests.set(table, request); + return request; +} + +/** Loads metadata for the selected tables with bounded concurrency, so a wide selection cannot flood the backend. */ +async function prefetchSelectedTableColumns(tables: string[]): Promise { + const pending = tables.filter((table) => !tableColumns.value[table]); + if (pending.length === 0) return; + let cursor = 0; + const workers = Array.from({ length: Math.min(4, pending.length) }, async () => { + while (cursor < pending.length) await loadTableColumns(pending[cursor++]); + }); + await Promise.all(workers); +} + +function setTableKeyColumns(table: string, columns: string[]) { + const normalized = normalizeKeyColumns(columns); + const next = { ...keyColumnOverrides.value }; + if (sameKeyColumns(normalized, inferredKeyColumns(table))) delete next[table]; + else next[table] = normalized; + keyColumnOverrides.value = next; +} + +function clearTableKeyColumnsOverride(table: string) { + if (keyColumnOverrides.value[table] === undefined) return; + const next = { ...keyColumnOverrides.value }; + delete next[table]; + keyColumnOverrides.value = next; +} + +/** Drops overrides and metadata of tables that are no longer selected. */ +function pruneKeyColumnState(tables: string[]) { + const keep = new Set(tables); + const nextOverrides: Record = {}; + let overridesChanged = false; + for (const [table, columns] of Object.entries(keyColumnOverrides.value)) { + if (keep.has(table)) nextOverrides[table] = columns; + else overridesChanged = true; + } + if (overridesChanged) keyColumnOverrides.value = nextOverrides; + + const nextColumns: Record = {}; + const nextErrors: Record = {}; + let columnsChanged = false; + for (const [table, columns] of Object.entries(tableColumns.value)) { + if (keep.has(table)) { + nextColumns[table] = columns; + if (tableColumnErrors.value[table]) nextErrors[table] = tableColumnErrors.value[table]; + } else { + columnsChanged = true; + } + } + if (columnsChanged) { + tableColumns.value = nextColumns; + tableColumnErrors.value = nextErrors; + } + if (expandedKeyColumnTable.value && !keep.has(expandedKeyColumnTable.value)) expandedKeyColumnTable.value = ""; +} + +function toggleKeyColumnTable(table: string) { + expandedKeyColumnTable.value = expandedKeyColumnTable.value === table ? "" : table; + if (expandedKeyColumnTable.value) void loadTableColumns(table); +} + +function keyColumnHint(table: string, state: { matched: boolean; manual: boolean; columns: string[] }): string { + if (!state.matched) return t("dataCompare.keyColumnsTargetMissingHint"); + if (state.manual) return t("dataCompare.keyColumnsManualHint", { columns: state.columns.length > 0 ? state.columns.join(", ") : t("dataCompare.keyColumnsNoneSelected") }); + if (state.columns.length > 0) return t("dataCompare.keyColumnsAutoSummary", { columns: state.columns.join(", ") }); + if (!isTableColumnsKnown(table)) return t("dataCompare.keyColumnsStatusAuto"); + return t("dataCompare.keyColumnsNoPrimaryKey"); +} + +/** Only the selected tables are sent, so a deselected table can never leak a stale override. */ +function buildSessionKeyColumnsByTable(): Record { + const overrides: Record = {}; + for (const table of selectedSourceTableNames.value) { + const override = keyColumnOverrides.value[table]; + if (override !== undefined) overrides[table] = [...override]; + } + return overrides; +} + function buildCompareTasks(): DataCompareTableTask[] { if (!selectedSourceTableNames.value.length) return []; if (!isBatchCompare.value) { @@ -222,7 +425,10 @@ async function restoreDataCompareSession(session: DataCompareSession): Promise, connectionId: string, database: string, schema: string, table: string): Promise { - const key = `${connectionId}:${database}:${schema}:${table}`; - const cached = cache.get(key); - if (cached) return cached; - const columns = (await api.getColumns(connectionId, database, schema, table)) as CompareColumn[]; - cache.set(key, columns); - return columns; -} - -async function inferKeyColumnsForTable(table: string, sourceColumnCache?: Map): Promise { - if (!sourceConnectionId.value || !sourceDatabase.value || !sourceSchema.value || !table) return []; - const columns = sourceColumnCache ? await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, table) : (((await api.getColumns(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, table)) as CompareColumn[]) ?? []); - return inferCompareKeyColumns(columns); -} - -async function inferKeyColumns() { - const table = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : ""; - if (!table) return; - const inferred = await inferKeyColumnsForTable(table); - keyColumnsText.value = inferred.join(", "); -} - function resultStatusLabel(status: DataCompareTableStatus): string { if (status === "different") return t("dataCompare.statusDifferent"); if (status === "same") return t("dataCompare.statusSame"); @@ -535,7 +720,7 @@ function startCompare(): void { targetSchemas: [...targetSchemas.value], targetTables: [...targetTables.value], targetTable: targetTable.value, - keyColumns: [...keyColumns.value], + keyColumnsByTable: buildSessionKeyColumnsByTable(), label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`, }, tasks, @@ -651,6 +836,8 @@ watch(sourceConnectionId, (id) => { sourceTables.value = []; sourceTable.value = ""; resetSelectedSourceTables([]); + // Column metadata and match columns belong to the previous source endpoint. + resetTableColumnMetadata(); loadDatabases(id, "source").catch((e) => toast(String(e), 5000)); }); watch(targetConnectionId, (id) => { @@ -671,6 +858,7 @@ watch(sourceDatabase, () => { sourceTables.value = []; sourceTable.value = ""; resetSelectedSourceTables([]); + resetTableColumnMetadata(); loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000)); }); watch(targetDatabase, () => { @@ -688,6 +876,7 @@ watch(sourceSchema, () => { sourceTables.value = []; sourceTable.value = ""; resetSelectedSourceTables([]); + resetTableColumnMetadata(); if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000)); }); watch(targetSchema, () => { @@ -700,9 +889,12 @@ watch(targetSchema, () => { watch(selectedSourceTableNames, (tables, previous) => { if (initializingPrefill) return; clearResult(); + // Drop the match columns and metadata of tables that are no longer compared, + // so a table that is selected again starts from its own primary key. + pruneKeyColumnState(tables); sourceTable.value = tables.length === 1 ? tables[0] : ""; if (tables.length !== 1) { - keyColumnsText.value = ""; + void prefetchSelectedTableColumns(tables); return; } const table = tables[0]; @@ -711,10 +903,7 @@ watch(selectedSourceTableNames, (tables, previous) => { } else if (previous?.length === 1 && targetTable.value === previous[0]) { targetTable.value = ""; } - if (table !== previous?.[0]) { - keyColumnsText.value = ""; - } - inferKeyColumns().catch(() => {}); + void prefetchSelectedTableColumns(tables); }); watch(targetTable, () => { if (initializingPrefill) return; @@ -748,6 +937,7 @@ watch( if (sourceTables.value.includes(props.prefillTable)) { resetSelectedSourceTables([props.prefillTable]); sourceTable.value = props.prefillTable; + await loadTableColumns(props.prefillTable); } } } finally { @@ -917,10 +1107,59 @@ onBeforeUnmount(() => {
- - -
- {{ t("dataCompare.keyColumnsAutoHint") }} +
+ + + {{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }} + + +
+ +
+ {{ t("dataCompare.keyColumnsSelectTableHint") }} +
+ + +
+ +
+ {{ singleKeyColumnRow.hint }} +
+
+ + +
+
+
+ {{ t("dataCompare.keyColumnsPerTableHint") }} +
+
+
+ +
+ +
+ {{ row.hint }} + +
+
+
+
+
diff --git a/apps/desktop/src/components/diff/TableMultiSelect.vue b/apps/desktop/src/components/diff/TableMultiSelect.vue index 92648930bb..beb216f85a 100644 --- a/apps/desktop/src/components/diff/TableMultiSelect.vue +++ b/apps/desktop/src/components/diff/TableMultiSelect.vue @@ -79,7 +79,7 @@ function toggleSelectAll() { {{ emptyText || t("tableMultiSelect.noTables") }}
-