feat(data-compare): support per-table match column configuration

This commit is contained in:
jearhe authored and GitHub committed 2026-09-19 08:52:27 +08:00
1 parent aa4e0a30fd
commit d10216e302
18 files changed
+1359 -120

No files matched your search

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { HTMLAttributes } from "vue";
import { CheckSquare, ChevronDown, Search, Square } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { filterDatabaseOptions } from "@/lib/database/databaseOptionSearch";
import { cn } from "@/lib/common/utils";
import type { CompareKeyColumnOption } from "@/lib/dataGrid/dataCompare";
/**
* Searchable multi-select for database table columns, used to configure the
* match columns (key columns) of one Data Compare task.
*
* It is deliberately read-only over the column metadata it receives: options
* come from `getColumns`, so a user can only pick columns that really exist on
* the table. Primary keys are labelled instead of pre-filtered, because a
* composite unique key is a legitimate explicit choice.
*/
const props = withDefaults(
defineProps<{
/** Currently selected column names. */
modelValue: string[];
/** All columns of the table, in table order. */
columns: CompareKeyColumnOption[];
disabled?: boolean;
/** Column metadata is still being fetched for this table. */
loading?: boolean;
/** Shown on the trigger when nothing is selected. */
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
/** Extra classes for the popover content, mirroring SearchableSelect. */
contentClass?: HTMLAttributes["class"];
}>(),
{
disabled: false,
loading: false,
placeholder: "",
searchPlaceholder: "",
emptyText: "",
},
);
const emit = defineEmits<{ "update:modelValue": [value: string[]] }>();
const { t } = useI18n();
const open = ref(false);
const searchText = ref("");
const searchInput = ref<InstanceType<typeof Input>>();
const selectedSet = computed(() => new Set(props.modelValue.map((column) => column.toLowerCase())));
const columnNames = computed(() => props.columns.map((column) => column.name));
const filteredColumns = computed(() => {
const allowed = new Set(filterDatabaseOptions(columnNames.value, searchText.value));
return props.columns.filter((column) => allowed.has(column.name));
});
const selectedLabel = computed(() => props.modelValue.join(", "));
const selectedCountLabel = computed(() => t("dataCompare.keyColumnsSelectedCount", { selected: props.modelValue.length, total: props.columns.length }));
function isSelected(column: CompareKeyColumnOption): boolean {
return selectedSet.value.has(column.name.toLowerCase());
}
function toggleColumn(column: CompareKeyColumnOption): void {
if (props.disabled) return;
const next = isSelected(column)
? props.modelValue.filter((name) => name.toLowerCase() !== column.name.toLowerCase())
: // Append in click order so a composite key keeps the order the user picked.
[...props.modelValue, column.name];
emit("update:modelValue", next);
}
watch(open, async (value) => {
if (!value) {
searchText.value = "";
return;
}
await nextTick();
const input = searchInput.value?.$el as HTMLInputElement | undefined;
input?.focus();
});
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:disabled="disabled"
:title="selectedLabel || placeholder"
:class="
cn(
'dbx-compare-key-columns-trigger dbx-control-chrome h-8 w-full min-w-0 justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs font-normal shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50',
)
"
>
<span v-if="loading" class="truncate text-muted-foreground">{{ t("dataCompare.keyColumnsLoading") }}</span>
<span v-else-if="selectedLabel" class="truncate font-mono">{{ selectedLabel }}</span>
<span v-else class="truncate text-muted-foreground">{{ placeholder || t("dataCompare.keyColumnsSelectPlaceholder") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" :class="cn('w-[var(--reka-popover-trigger-width)] min-w-56 max-w-[min(30rem,calc(100vw-1rem))] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)">
<div class="rounded-md border bg-popover p-1.5 shadow-md">
<div class="relative rounded-md border bg-background">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
{{ searchPlaceholder || t("dataCompare.keyColumnsSearch") }}
</span>
<Input ref="searchInput" :model-value="searchText" class="h-6 border-0 pl-6 pr-2 text-xs caret-foreground shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown.escape="open = false" />
</div>
<div class="dbx-compare-key-columns-list max-h-64 overflow-y-auto py-1">
<div v-if="loading" class="px-2 py-2 text-xs text-muted-foreground">{{ t("dataCompare.keyColumnsLoading") }}</div>
<template v-else-if="filteredColumns.length">
<button
v-for="column in filteredColumns"
:key="column.name"
type="button"
:data-column-name="column.name"
:data-primary-key="column.is_primary_key ? 'true' : 'false'"
:disabled="disabled"
class="flex h-7 w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
@click="toggleColumn(column)"
>
<CheckSquare v-if="isSelected(column)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
<span v-if="column.is_primary_key" class="shrink-0 rounded bg-amber-500/15 px-1 text-[10px] font-medium text-amber-700 dark:text-amber-400">
{{ t("dataCompare.keyColumnsPrimaryKeyBadge") }}
</span>
</button>
</template>
<div v-else class="px-2 py-2 text-xs text-muted-foreground">
{{ emptyText || t("dataCompare.keyColumnsEmpty") }}
</div>
</div>
<div class="border-t px-2 pt-1.5 text-[11px] text-muted-foreground">{{ selectedCountLabel }}</div>
</div>
</PopoverContent>
</Popover>
</template>
<style>
.dbx-compare-key-columns-list {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, var(--foreground) 30%, transparent) transparent;
}
.dbx-compare-key-columns-list::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.dbx-compare-key-columns-list::-webkit-scrollbar-track {
background: transparent;
}
.dbx-compare-key-columns-list::-webkit-scrollbar-thumb {
border: 1px solid transparent;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
background-clip: padding-box;
}
.dbx-compare-key-columns-list:hover::-webkit-scrollbar-thumb {
border: 0;
background: color-mix(in oklch, var(--foreground) 48%, transparent);
}
.dark .dbx-compare-key-columns-list {
scrollbar-color: rgb(82, 82, 91) transparent;
}
.dark .dbx-compare-key-columns-list::-webkit-scrollbar-thumb {
background: rgb(82, 82, 91);
background-clip: padding-box;
}
.dark .dbx-compare-key-columns-list:hover::-webkit-scrollbar-thumb {
background: rgb(113, 113, 122);
}
</style>
@@ -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<string[]>([]);
const targetSchemas = ref<string[]>([]);
const targetTables = ref<string[]>([]);
const keyColumnsText = ref("");
const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1]));
const batchResults = ref<DataCompareTableResult[]>([]);
const syncPlan = ref<DataCompareSyncPlan>(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<Record<string, string[]>>({});
/** Column metadata per source table, keyed by table name; cleared with the source endpoint. */
const tableColumns = ref<Record<string, CompareKeyColumnOption[]>>({});
const tableColumnErrors = ref<Record<string, string>>({});
const loadingColumnTables = ref<string[]>([]);
const expandedKeyColumnTable = ref("");
let columnRequests = new Map<string, Promise<CompareKeyColumnOption[]>>();
/** Invalidates in-flight metadata responses after a source endpoint change. */
let columnMetadataGeneration = 0;
const activeSessionId = ref<string | null>(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<KeyColumnRow[]>(() =>
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<string>) {
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<CompareKeyColumnOption[]> {
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<CompareKeyColumnOption[]> {
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<void> {
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<string, string[]> = {};
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<string, CompareKeyColumnOption[]> = {};
const nextErrors: Record<string, string> = {};
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<string, string[]> {
const overrides: Record<string, string[]> = {};
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<v
targetSchemas.value = [...config.targetSchemas];
targetTables.value = [...config.targetTables];
targetTable.value = config.targetTable;
keyColumnsText.value = config.keyColumns.join(", ");
// Restore the per-table match columns so a reopened session keeps every
// table's own selection instead of collapsing back to one global list.
keyColumnOverrides.value = normalizeKeyColumnOverrides(config.keyColumnsByTable);
resetTableColumnMetadata();
batchResults.value = session.batchResults;
syncPlan.value = session.syncPlan;
syncErrors.value = [];
@@ -230,6 +436,7 @@ async function restoreDataCompareSession(session: DataCompareSession): Promise<v
compareProgressTotal.value = session.progress?.total ?? 0;
compareProgressTable.value = session.progress?.table ?? "";
comparing.value = session.status === "running";
void prefetchSelectedTableColumns(selectedSourceTableNames.value);
} finally {
await nextTick();
if (generation === initializingPrefillGeneration) initializingPrefill = false;
@@ -377,28 +584,6 @@ async function loadTables(side: "source" | "target") {
}
}
async function loadColumnsWithCache(cache: Map<string, CompareColumn[]>, connectionId: string, database: string, schema: string, table: string): Promise<CompareColumn[]> {
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<string, CompareColumn[]>): Promise<string[]> {
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(() => {
</div>
<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')" :disabled="comparing" />
<div class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsAutoHint") }}
<div class="flex flex-wrap items-center gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<span v-if="singleKeyColumnRow" class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium" :class="singleKeyColumnRow.manual ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">
{{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }}
</span>
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
<div v-if="!selectedSourceTableNames.length" class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsSelectTableHint") }}
</div>
<!-- Single table: pick match columns straight from the real database columns. -->
<div v-else-if="singleKeyColumnRow" class="space-y-1">
<CompareKeyColumnsSelect :model-value="singleKeyColumnRow.columns" :columns="columnsForTable(singleKeyColumnRow.table)" :loading="singleKeyColumnRow.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)" />
<div class="text-[11px]" data-key-column-hint :class="singleKeyColumnRow.error || singleKeyColumnRow.status === 'missing' ? 'text-destructive' : 'text-muted-foreground'">
{{ singleKeyColumnRow.hint }}
</div>
</div>
<!-- Batch compare: every table keeps its own match columns. -->
<div v-else class="space-y-1">
<div class="rounded-lg border">
<div class="border-b bg-muted/20 px-2 py-1 text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsPerTableHint") }}
</div>
<div class="max-h-56 divide-y overflow-auto">
<div v-for="row in keyColumnRows" :key="row.table">
<button type="button" class="dbx-compare-key-table-row flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-muted/40" :data-key-column-table="row.table" @click="toggleKeyColumnTable(row.table)">
<ChevronDown v-if="expandedKeyColumnTable === row.table" class="h-3.5 w-3.5 shrink-0" />
<ChevronRight v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono">{{ row.table }}</span>
<span class="min-w-0 max-w-[45%] shrink-0 truncate font-mono" :class="row.columns.length > 0 ? (row.manual ? 'text-foreground' : 'text-muted-foreground') : 'text-destructive'">
{{ row.loading ? t("dataCompare.keyColumnsLoading") : row.columns.length > 0 ? row.columns.join(", ") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="row.status === 'manual' ? 'bg-primary/15 text-primary' : row.status === 'auto' ? 'bg-muted text-muted-foreground' : 'bg-destructive/15 text-destructive'">
{{ row.status === "manual" ? t("dataCompare.keyColumnsStatusManual") : row.status === "auto" ? t("dataCompare.keyColumnsStatusAuto") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
</button>
<div v-if="expandedKeyColumnTable === row.table" class="space-y-1 border-t bg-muted/10 px-2 py-2">
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<span data-key-column-hint :class="row.error || (row.status === 'missing' && row.matched) ? 'text-destructive' : 'text-muted-foreground'">{{ row.hint }}</span>
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(row.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -79,7 +79,7 @@ function toggleSelectAll() {
{{ emptyText || t("tableMultiSelect.noTables") }}
</div>
<div v-else class="max-h-40 overflow-auto rounded border">
<button v-for="table in filteredTables" :key="table" type="button" :disabled="disabled" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50 disabled:opacity-50" @click="toggle(table)">
<button v-for="table in filteredTables" :key="table" type="button" :data-table-name="table" :disabled="disabled" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50 disabled:opacity-50" @click="toggle(table)">
<CheckSquare v-if="selectedSet.has(table)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<span class="truncate">{{ table }}</span>
@@ -0,0 +1,399 @@
// @vitest-environment happy-dom
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 SOURCE_CONNECTION_ID = "oracle-11g";
const TARGET_CONNECTION_ID = "oracle-jdbc-11g";
interface MockColumn {
name: string;
data_type: string;
is_primary_key: boolean;
}
function column(name: string, isPrimaryKey = false): MockColumn {
return { name, data_type: "NUMBER", is_primary_key: isPrimaryKey };
}
const TABLES: Record<string, { source: MockColumn[]; target: MockColumn[] }> = {
USERS: {
source: [column("USER_ID", true), column("EMAIL"), column("NAME")],
target: [column("USER_ID", true), column("EMAIL"), column("NAME")],
},
ORDERS: {
source: [column("TENANT_ID", true), column("ORDER_ID", true), column("ORDER_NO"), column("AMOUNT")],
target: [column("TENANT_ID", true), column("ORDER_ID", true), column("ORDER_NO"), column("AMOUNT")],
},
PAYMENTS: {
source: [column("TENANT_ID"), column("PAYMENT_NO")],
target: [column("TENANT_ID"), column("PAYMENT_NO")],
},
CASES: {
source: [column("snid", true), column("username")],
target: [column("SNID", true), column("USERNAME")],
},
};
const TABLE_NAMES = Object.keys(TABLES);
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
listSchemas: vi.fn(),
listTables: vi.fn(),
getColumns: vi.fn(),
buildDataCompareSyncPlan: vi.fn(),
executeBatch: vi.fn(),
prepareDataCompareFromTables: vi.fn(),
prepareDataCompareMissingTarget: 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" },
{
id: "oracle-jdbc-11g",
name: "Oracle JDBC 11g",
db_type: "jdbc",
driver_profile: "oracle",
connection_string: "jdbc:oracle:thin:@//localhost:1521/XE",
jdbc_driver_class: "oracle.jdbc.OracleDriver",
},
];
return {
useConnectionStore: () => ({
connections,
sidebarLayout: {
groups: [{ id: "oracle", name: "Oracle", collapsed: false }],
order: [{ type: "group", id: "oracle", children: connections.map((connection) => ({ type: "connection", id: connection.id })) }],
},
getConfig: (id: string) => connections.find((connection) => connection.id === id),
ensureConnected: mocks.ensureConnected,
}),
};
});
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
listSchemas: mocks.listSchemas,
listTables: mocks.listTables,
getColumns: mocks.getColumns,
buildDataCompareSyncPlan: mocks.buildDataCompareSyncPlan,
executeBatch: mocks.executeBatch,
prepareDataCompareFromTables: mocks.prepareDataCompareFromTables,
prepareDataCompareMissingTarget: mocks.prepareDataCompareMissingTarget,
}));
const mountedApps: App[] = [];
async function flushAsyncSetup() {
for (let index = 0; index < 12; index += 1) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.textContent = "";
sessionMocks.session = null;
vi.clearAllMocks();
});
function mountDialog(props: Record<string, unknown>): App {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () => h(DataCompareDialog, { open: true, ...props }),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
return app;
}
/** The dialog prefills only the source side; the target must be selected for a task to match. */
async function selectMatchedTarget(): Promise<void> {
const targetConnectionTrigger = document.querySelectorAll<HTMLButtonElement>("button.dbx-diff-connection-trigger")[1];
targetConnectionTrigger?.click();
await flushAsyncSetup();
document.querySelector<HTMLButtonElement>(`[data-picker-connection="${TARGET_CONNECTION_ID}"]`)?.click();
await flushAsyncSetup();
const targetDatabaseTrigger = document.querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")[2];
targetDatabaseTrigger?.click();
await flushAsyncSetup();
const reporting = [...document.querySelectorAll<HTMLButtonElement>(".dbx-searchable-select-list button")].find((button) => button.textContent?.trim() === "REPORTING");
reporting?.click();
await flushAsyncSetup();
}
async function openWithPrefilledSource(table: string, options: { matchedTarget?: boolean } = {}) {
mountDialog({ prefillConnectionId: SOURCE_CONNECTION_ID, prefillDatabase: "DBX_TEST", prefillSchema: "DBX_TEST", prefillTable: table });
await flushAsyncSetup();
if (options.matchedTarget !== false) await selectMatchedTarget();
}
function keyColumnTrigger(): HTMLButtonElement | undefined {
return document.querySelector<HTMLButtonElement>("button.dbx-compare-key-columns-trigger") ?? undefined;
}
function keyColumnHint(): string {
return document.querySelector<HTMLElement>("[data-key-column-hint]")?.textContent?.trim() ?? "";
}
function keyColumnRow(table: string): HTMLButtonElement | undefined {
return document.querySelector<HTMLButtonElement>(`button.dbx-compare-key-table-row[data-key-column-table="${table}"]`) ?? undefined;
}
function tableSelectionRow(table: string): HTMLButtonElement | undefined {
return document.querySelector<HTMLButtonElement>(`[data-table-name="${table}"]`) ?? undefined;
}
function buttonWithText(text: string): HTMLButtonElement | undefined {
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes(text)) ?? undefined;
}
function compareButton(): HTMLButtonElement | undefined {
return buttonWithText(i18n.global.t("dataCompare.compare"));
}
async function togglePickerColumn(columnName: string): Promise<void> {
// The popover stays open between toggles, so only open it when it is closed.
if (!document.querySelector(".dbx-compare-key-columns-list")) {
keyColumnTrigger()?.click();
await flushAsyncSetup();
}
const option = document.querySelector<HTMLButtonElement>(`.dbx-compare-key-columns-list button[data-column-name="${columnName}"]`);
expect(option).toBeDefined();
option?.click();
await flushAsyncSetup();
}
function comparedKeyColumns(): { table: string; keys: string[] }[] {
return mocks.prepareDataCompareFromTables.mock.calls.map((call) => ({
table: (call[0] as { targetTable: string }).targetTable,
keys: (call[0] as { keyColumns: string[] }).keyColumns,
}));
}
function preparationFor(table: string) {
return {
result: { added: [{ key: "1", keyValues: { USER_ID: 1 }, values: { USER_ID: 1, EMAIL: "a@b.c" } }], removed: [], modified: [] },
syncStatements: [`INSERT INTO ${table} (USER_ID) VALUES (1)`],
syncSql: `INSERT INTO ${table} (USER_ID) VALUES (1)`,
preSyncStatements: [],
sourceRowCount: 1,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
};
}
function configuredSession(): DataCompareSession {
return {
id: "compare-session",
version: 1,
status: "completed",
config: {
sourceConnectionId: SOURCE_CONNECTION_ID,
sourceDatabase: "DBX_TEST",
sourceSchema: "DBX_TEST",
sourceDatabases: ["DBX_TEST"],
sourceSchemas: ["DBX_TEST"],
sourceTables: TABLE_NAMES,
selectedSourceTables: ["USERS", "ORDERS"],
targetConnectionId: TARGET_CONNECTION_ID,
targetDatabase: "REPORTING",
targetSchema: "DBX_TEST",
targetDatabases: ["REPORTING"],
targetSchemas: ["DBX_TEST"],
targetTables: TABLE_NAMES,
targetTable: "",
keyColumnsByTable: { ORDERS: ["ORDER_NO"] },
label: "Oracle source → Oracle target",
},
progress: null,
batchResults: [],
syncPlan: { insertCount: 0, updateCount: 0, deleteCount: 0, statementCount: 0, syncStatements: [], syncSql: "" },
error: null,
startedAt: 1,
finishedAt: 2,
};
}
function setupMocks() {
mocks.listSchemas.mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]);
mocks.listTables.mockResolvedValue(TABLE_NAMES.map((name) => ({ name, table_type: "TABLE" })));
mocks.getColumns.mockImplementation((connectionId: string, _database: string, _schema: string, table: string) => {
const entry = TABLES[table];
if (!entry) return Promise.resolve([]);
return Promise.resolve(connectionId === SOURCE_CONNECTION_ID ? entry.source : entry.target);
});
mocks.prepareDataCompareFromTables.mockImplementation((options: { targetTable: string }) => Promise.resolve(preparationFor(options.targetTable)));
mocks.prepareDataCompareMissingTarget.mockImplementation((options: { sourceTable: string }) => Promise.resolve(preparationFor(options.sourceTable)));
mocks.buildDataCompareSyncPlan.mockResolvedValue({ insertCount: 1, updateCount: 0, deleteCount: 0, statementCount: 1, syncStatements: ["INSERT"], syncSql: "INSERT" });
}
describe("DataCompareDialog match columns", () => {
it("auto-selects the single primary key of a single-table compare and labels it", async () => {
setupMocks();
await openWithPrefilledSource("USERS");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("USER_ID");
expect(keyColumnHint()).toBe(i18n.global.t("dataCompare.keyColumnsAutoSummary", { columns: "USER_ID" }));
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusAuto"));
expect(compareButton()?.disabled).toBe(false);
});
it("keeps the declared order of a composite primary key", async () => {
setupMocks();
await openWithPrefilledSource("ORDERS");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("TENANT_ID, ORDER_ID");
expect(keyColumnHint()).toBe(i18n.global.t("dataCompare.keyColumnsAutoSummary", { columns: "TENANT_ID, ORDER_ID" }));
});
it("offers the real database columns with a primary-key badge", async () => {
setupMocks();
await openWithPrefilledSource("ORDERS");
keyColumnTrigger()?.click();
await flushAsyncSetup();
const options = [...document.querySelectorAll<HTMLButtonElement>(".dbx-compare-key-columns-list button")];
expect(options.map((option) => option.dataset.columnName)).toEqual(["TENANT_ID", "ORDER_ID", "ORDER_NO", "AMOUNT"]);
expect(options.map((option) => option.dataset.primaryKey)).toEqual(["true", "true", "false", "false"]);
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.keyColumnsSelectedCount", { selected: 2, total: 4 }));
});
it("requires an explicit selection when the table has no primary key", async () => {
setupMocks();
await openWithPrefilledSource("PAYMENTS");
expect(keyColumnTrigger()?.textContent?.trim()).toBe(i18n.global.t("dataCompare.keyColumnsSelectPlaceholder"));
expect(keyColumnHint()).toBe(i18n.global.t("dataCompare.keyColumnsNoPrimaryKey"));
expect(compareButton()?.disabled).toBe(true);
await togglePickerColumn("TENANT_ID");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("TENANT_ID");
expect(compareButton()?.disabled).toBe(false);
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusManual"));
});
it("lets the user override the inferred primary key and reset back to it", async () => {
setupMocks();
await openWithPrefilledSource("ORDERS");
await togglePickerColumn("TENANT_ID");
await togglePickerColumn("ORDER_ID");
await togglePickerColumn("ORDER_NO");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("ORDER_NO");
expect(keyColumnHint()).toBe(i18n.global.t("dataCompare.keyColumnsManualHint", { columns: "ORDER_NO" }));
buttonWithText(i18n.global.t("dataCompare.keyColumnsResetAuto"))?.click();
await flushAsyncSetup();
expect(keyColumnTrigger()?.textContent?.trim()).toBe("TENANT_ID, ORDER_ID");
});
it("configures match columns per table in a batch compare", async () => {
setupMocks();
await openWithPrefilledSource("USERS");
tableSelectionRow("ORDERS")?.click();
await flushAsyncSetup();
expect(keyColumnRow("USERS")?.textContent).toContain("USER_ID");
expect(keyColumnRow("ORDERS")?.textContent).toContain("TENANT_ID, ORDER_ID");
expect(keyColumnRow("USERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusAuto"));
expect(keyColumnRow("ORDERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusAuto"));
keyColumnRow("ORDERS")?.click();
await flushAsyncSetup();
await togglePickerColumn("TENANT_ID");
await togglePickerColumn("ORDER_ID");
await togglePickerColumn("ORDER_NO");
expect(keyColumnRow("ORDERS")?.textContent).toContain("ORDER_NO");
expect(keyColumnRow("ORDERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusManual"));
// The other table must keep its own primary key.
expect(keyColumnRow("USERS")?.textContent).toContain("USER_ID");
expect(keyColumnRow("USERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusAuto"));
compareButton()?.click();
await flushAsyncSetup();
expect(comparedKeyColumns()).toEqual([
{ table: "USERS", keys: ["USER_ID"] },
{ table: "ORDERS", keys: ["ORDER_NO"] },
]);
});
it("restores per-table match columns when a session is reopened", async () => {
setupMocks();
sessionMocks.session = configuredSession();
mountDialog({ sessionId: "compare-session" });
await flushAsyncSetup();
expect(keyColumnRow("USERS")?.textContent).toContain("USER_ID");
expect(keyColumnRow("USERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusAuto"));
expect(keyColumnRow("ORDERS")?.textContent).toContain("ORDER_NO");
expect(keyColumnRow("ORDERS")?.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusManual"));
});
it("drops a stale manual selection after switching to another source table", async () => {
setupMocks();
await openWithPrefilledSource("USERS");
await togglePickerColumn("EMAIL");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("USER_ID, EMAIL");
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.keyColumnsStatusManual"));
tableSelectionRow("USERS")?.click();
await flushAsyncSetup();
tableSelectionRow("ORDERS")?.click();
await flushAsyncSetup();
expect(keyColumnTrigger()?.textContent?.trim()).toBe("TENANT_ID, ORDER_ID");
expect(keyColumnHint()).toBe(i18n.global.t("dataCompare.keyColumnsAutoSummary", { columns: "TENANT_ID, ORDER_ID" }));
});
it("sends the source spelling of a match column whose target column differs in case", async () => {
setupMocks();
await openWithPrefilledSource("CASES");
expect(keyColumnTrigger()?.textContent?.trim()).toBe("snid");
expect(compareButton()?.disabled).toBe(false);
compareButton()?.click();
await flushAsyncSetup();
const options = mocks.prepareDataCompareFromTables.mock.calls[0]?.[0] as { keyColumns: string[]; columns: string[]; sourceColumns: string[]; sourceTable: string };
expect(options.sourceTable).toBe("CASES");
expect(options.keyColumns).toEqual(["SNID"]);
expect(options.columns).toEqual(["SNID", "USERNAME"]);
expect(options.sourceColumns).toEqual(["snid", "username"]);
expect(document.body.textContent).not.toContain(i18n.global.t("dataCompare.missingKeyColumns", { columns: "snid" }));
});
});
@@ -1,5 +1,7 @@
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
import type { ColumnInfo } from "@/types/database";
import type { DataCompareSession, DataCompareSessionConfig } from "../useDataCompareSession.ts";
const apiMock = vi.hoisted(() => ({
buildDataCompareSyncPlan: vi.fn(),
@@ -18,27 +20,76 @@ vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/composables/useDialogSources", () => ({ openDataCompareSession: openMock }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => trackerMock }));
const { startDataCompareSession } = await import("../useDataCompareSession.ts");
const { normalizeKeyColumnOverrides, startDataCompareSession } = await import("../useDataCompareSession.ts");
test("runs a data compare session independently of the dialog and retains its result", async () => {
apiMock.getColumns.mockResolvedValue([
{ name: "id", is_primary_key: true },
{ name: "name", is_primary_key: false },
]);
apiMock.prepareDataCompareFromTables.mockResolvedValue({
result: {
added: [{ key: "1", keyValues: { id: 1 }, values: { id: 1, name: "new" } }],
removed: [],
modified: [],
},
syncStatements: ["INSERT INTO users (id, name) VALUES (1, 'new')"],
syncSql: "INSERT INTO users (id, name) VALUES (1, 'new')",
function column(name: string, isPrimaryKey = false): ColumnInfo {
return { name, data_type: "TEXT", is_nullable: true, column_default: null, is_primary_key: isPrimaryKey, extra: null };
}
const TABLES: Record<string, ColumnInfo[]> = {
users: [column("user_id", true), column("email"), column("name")],
orders: [column("tenant_id", true), column("order_id", true), column("amount")],
payments: [column("payment_no", true), column("tenant_id"), column("amount")],
logs: [column("category"), column("created_at")],
legacy: [column("snid", true), column("username")],
};
function preparationFor(table: string) {
return {
result: { added: [{ key: "1", keyValues: { id: 1 }, values: { id: 1 } }], removed: [], modified: [] },
syncStatements: [`INSERT INTO ${table} (id) VALUES (1)`],
syncSql: `INSERT INTO ${table} (id) VALUES (1)`,
preSyncStatements: [],
sourceRowCount: 1,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
});
};
}
function baseConfig(overrides: Partial<DataCompareSessionConfig> = {}): DataCompareSessionConfig {
return {
sourceConnectionId: "source",
sourceDatabase: "app",
sourceSchema: "public",
sourceDatabases: ["app"],
sourceSchemas: ["public"],
sourceTables: Object.keys(TABLES),
selectedSourceTables: [],
targetConnectionId: "target",
targetDatabase: "warehouse",
targetSchema: "public",
targetDatabases: ["warehouse"],
targetSchemas: ["public"],
targetTables: Object.keys(TABLES),
targetTable: "",
keyColumnsByTable: {},
label: "app → warehouse",
...overrides,
};
}
const dependencies = {
ensureConnected: () => Promise.resolve(undefined),
getConfig: () => ({ db_type: "mysql" as const }),
};
async function runSession(input: DataCompareSessionConfig, tasks: { sourceTable: string; targetTable: string }[]): Promise<DataCompareSession> {
const session = startDataCompareSession(input, tasks, dependencies);
for (let attempt = 0; attempt < 50 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
return session;
}
function keyColumnsOf(call: number): string[] {
return apiMock.prepareDataCompareFromTables.mock.calls[call]?.[0]?.keyColumns as string[];
}
beforeEach(() => {
vi.clearAllMocks();
apiMock.getColumns.mockImplementation((_connectionId: string, _database: string, _schema: string, table: string) => Promise.resolve(TABLES[table] ?? []));
apiMock.prepareDataCompareFromTables.mockImplementation((options: { targetTable: string }) => Promise.resolve(preparationFor(options.targetTable)));
apiMock.buildDataCompareSyncPlan.mockResolvedValue({
insertCount: 1,
updateCount: 0,
@@ -47,36 +98,10 @@ test("runs a data compare session independently of the dialog and retains its re
syncStatements: ["INSERT"],
syncSql: "INSERT",
});
});
const session = startDataCompareSession(
{
sourceConnectionId: "source",
sourceDatabase: "app",
sourceSchema: "public",
sourceDatabases: ["app"],
sourceSchemas: ["public"],
sourceTables: ["users"],
selectedSourceTables: ["users"],
targetConnectionId: "target",
targetDatabase: "warehouse",
targetSchema: "public",
targetDatabases: ["warehouse"],
targetSchemas: ["public"],
targetTables: ["users"],
targetTable: "users",
keyColumns: ["id"],
label: "app → warehouse",
},
[{ sourceTable: "users", targetTable: "users" }],
{
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: () => ({ db_type: "mysql" }),
},
);
for (let attempt = 0; attempt < 20 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
test("runs a data compare session independently of the dialog and retains its result", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users"], targetTable: "users" }), [{ sourceTable: "users", targetTable: "users" }]);
assert.equal(session.status, "completed");
assert.equal(session.batchResults.length, 1);
@@ -91,6 +116,117 @@ test("runs a data compare session independently of the dialog and retains its re
assert.equal(openMock.mock.calls.at(-1)?.[0], session.id);
});
beforeEach(() => {
vi.clearAllMocks();
test("infers the primary key of every table when no match columns are configured", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users", "orders", "payments"] }), [
{ sourceTable: "users", targetTable: "users" },
{ sourceTable: "orders", targetTable: "orders" },
{ sourceTable: "payments", targetTable: "payments" },
]);
assert.equal(session.status, "completed");
assert.deepEqual(
session.batchResults.map((result) => result.keyColumns),
[["user_id"], ["tenant_id", "order_id"], ["payment_no"]],
);
assert.deepEqual(
apiMock.prepareDataCompareFromTables.mock.calls.map((call) => call[0].keyColumns),
[["user_id"], ["tenant_id", "order_id"], ["payment_no"]],
);
});
test("gives one table its own match columns without changing the other tables", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users", "orders", "payments"], keyColumnsByTable: { payments: ["tenant_id", "payment_no"] } }), [
{ sourceTable: "users", targetTable: "users" },
{ sourceTable: "orders", targetTable: "orders" },
{ sourceTable: "payments", targetTable: "payments" },
]);
assert.equal(session.status, "completed");
assert.deepEqual(
session.batchResults.map((result) => ({ table: result.sourceTable, keys: result.keyColumns })),
[
{ table: "users", keys: ["user_id"] },
{ table: "orders", keys: ["tenant_id", "order_id"] },
{ table: "payments", keys: ["tenant_id", "payment_no"] },
],
);
assert.deepEqual(keyColumnsOf(2), ["tenant_id", "payment_no"]);
});
test("keeps a manual match-column selection on one table even when another table is overridden too", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users", "orders"], keyColumnsByTable: { users: ["email"], orders: ["amount"] } }), [
{ sourceTable: "users", targetTable: "users" },
{ sourceTable: "orders", targetTable: "orders" },
]);
assert.equal(session.status, "completed");
assert.deepEqual(keyColumnsOf(0), ["email"]);
assert.deepEqual(keyColumnsOf(1), ["amount"]);
});
test("reports a missing key column only for the table that configures it", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users", "orders"], keyColumnsByTable: { users: ["does_not_exist"] } }), [
{ sourceTable: "users", targetTable: "users" },
{ sourceTable: "orders", targetTable: "orders" },
]);
assert.equal(session.status, "completed");
assert.equal(session.batchResults[0]?.status, "error");
assert.match(session.batchResults[0]?.error ?? "", /does_not_exist/);
assert.equal(session.batchResults[1]?.status, "different");
});
test("never falls back to the first column when a table has no primary key", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["logs"], targetTable: "logs" }), [{ sourceTable: "logs", targetTable: "logs" }]);
assert.equal(session.batchResults.length, 1);
assert.equal(session.batchResults[0]?.status, "error");
assert.equal(apiMock.prepareDataCompareFromTables.mock.calls.length, 0);
});
test("treats an explicitly cleared selection as missing match columns instead of restoring the primary key", async () => {
const session = await runSession(baseConfig({ selectedSourceTables: ["users"], targetTable: "users", keyColumnsByTable: { users: [] } }), [{ sourceTable: "users", targetTable: "users" }]);
assert.equal(session.batchResults[0]?.status, "error");
assert.equal(apiMock.prepareDataCompareFromTables.mock.calls.length, 0);
});
test("canonicalizes a manual match column against an upper-cased target column", async () => {
apiMock.getColumns.mockImplementation((connectionId: string, _database: string, _schema: string, table: string) => {
if (table !== "legacy") return Promise.resolve(TABLES[table] ?? []);
return Promise.resolve(connectionId === "source" ? [column("snid", true), column("username")] : [column("SNID", true), column("USERNAME")]);
});
const session = await runSession(baseConfig({ selectedSourceTables: ["legacy"], targetTable: "legacy", keyColumnsByTable: { legacy: ["snid", "Username"] } }), [{ sourceTable: "legacy", targetTable: "legacy" }]);
assert.equal(session.batchResults[0]?.status, "different");
assert.deepEqual(keyColumnsOf(0), ["SNID", "USERNAME"]);
assert.deepEqual(apiMock.prepareDataCompareFromTables.mock.calls[0]?.[0].sourceColumns, ["snid", "username"]);
assert.deepEqual(apiMock.prepareDataCompareFromTables.mock.calls[0]?.[0].columns, ["SNID", "USERNAME"]);
});
test("uses a per-table override for a source table whose target table is missing", async () => {
apiMock.prepareDataCompareMissingTarget.mockResolvedValue(preparationFor("users"));
const session = await runSession(baseConfig({ selectedSourceTables: ["users"], targetTable: "users", keyColumnsByTable: { users: ["email"] }, targetTables: ["orders"] }), [{ sourceTable: "users", targetTable: "users" }]);
assert.equal(session.batchResults[0]?.status, "different");
assert.deepEqual(session.batchResults[0]?.keyColumns, ["email"]);
assert.deepEqual(apiMock.prepareDataCompareMissingTarget.mock.calls[0]?.[0].keyColumns, ["email"]);
});
test("keeps the configured per-table match columns after cloning the session config", async () => {
const input = baseConfig({ selectedSourceTables: ["users"], targetTable: "users", keyColumnsByTable: { users: [" user_id ", "USER_ID", "email"] } });
const session = await runSession(input, [{ sourceTable: "users", targetTable: "users" }]);
assert.deepEqual(session.config.keyColumnsByTable, { users: ["user_id", "email"] });
assert.deepEqual(keyColumnsOf(0), ["user_id", "email"]);
// The session must not share the caller's arrays.
input.keyColumnsByTable.users?.push("name");
assert.deepEqual(session.config.keyColumnsByTable, { users: ["user_id", "email"] });
});
test("normalizes a per-table match-column map", () => {
assert.deepEqual(normalizeKeyColumnOverrides({ users: [" id ", ""], orders: [], "": ["x"] }), { users: ["id"], orders: [] });
assert.deepEqual(normalizeKeyColumnOverrides(undefined), {});
});
@@ -3,11 +3,18 @@ import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import { openDataCompareSession } from "@/composables/useDialogSources";
import { useExportTracker } from "@/composables/useExportTracker";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase, type DataCompareCellValue, type DataCompareFromTablesPreparation, type DataCompareResult, type DataCompareSyncPlan, type DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase, normalizeKeyColumns, type DataCompareCellValue, type DataCompareFromTablesPreparation, type DataCompareResult, type DataCompareSyncPlan, type DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import type { ColumnInfo, DatabaseType } from "@/types/database";
export type CompareColumn = ColumnInfo;
/**
* One source-table -> target-table compare task.
*
* Match columns are not part of the task itself: they are stored per source
* table in `DataCompareSessionConfig.keyColumnsByTable` so that every task of a
* batch compare can carry its own selection instead of sharing one global list.
*/
export interface DataCompareTableTask {
sourceTable: string;
targetTable: string;
@@ -75,7 +82,15 @@ export interface DataCompareSessionConfig {
targetSchemas: string[];
targetTables: string[];
targetTable: string;
keyColumns: string[];
/**
* Explicit match columns per source table (keyed by the source table name).
*
* An absent entry means "no override": the primary key of that source table is
* inferred automatically. A present entry is an explicit user choice, so an
* empty array means the user cleared the selection and must pick columns
* again — it must not silently fall back to the primary key.
*/
keyColumnsByTable: Record<string, string[]>;
label: string;
}
@@ -130,16 +145,36 @@ function cloneConfig(config: DataCompareSessionConfig): DataCompareSessionConfig
targetDatabases: [...config.targetDatabases],
targetSchemas: [...config.targetSchemas],
targetTables: [...config.targetTables],
keyColumns: [...config.keyColumns],
keyColumnsByTable: normalizeKeyColumnOverrides(config.keyColumnsByTable),
};
}
/**
* Normalizes a per-table match-column map so a cloned session never shares
* arrays with the caller: every entry is trimmed and de-duplicated while an
* explicit empty selection stays an empty array (it must not become "auto").
*/
export function normalizeKeyColumnOverrides(overrides: Record<string, string[] | undefined> | null | undefined): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [table, columns] of Object.entries(overrides ?? {})) {
if (!table) continue;
normalized[table] = normalizeKeyColumns(columns ?? []);
}
return normalized;
}
function sessionError(error: unknown): string {
return error instanceof Error ? error.message : (error as { message?: string } | null)?.message || String(error);
}
function fallbackCompareErrorMessage(kind: DataCompareSessionErrorKind, columns?: string): string {
if (kind === "missingKeyColumns") return `Missing key columns: ${columns ?? ""}`;
if (kind === "noCommonColumns") return "No common columns";
return "No key columns available";
}
function compareErrorMessage(dependencies: DataCompareSessionDependencies, kind: DataCompareSessionErrorKind, columns?: string): string {
return dependencies.formatError?.(kind, columns) ?? (kind === "missingKeyColumns" ? `Missing key columns: ${columns ?? ""}` : kind === "noCommonColumns" ? "No common columns" : "No key columns available");
return dependencies.formatError?.(kind, columns) ?? fallbackCompareErrorMessage(kind, columns);
}
function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
@@ -284,6 +319,18 @@ function buildErrorResult(task: DataCompareTableTask, keyColumns: string[], data
};
}
/**
* Resolves the configured match columns of one compare task.
*
* Returns the explicit per-source-table override when the user configured one
* (an empty array counts as "explicitly nothing selected") and `undefined`
* otherwise, which means the caller must infer the primary key for that table.
*/
function configuredTaskKeyColumns(input: DataCompareSessionConfig, task: DataCompareTableTask): string[] | undefined {
const override = input.keyColumnsByTable[task.sourceTable];
return override === undefined ? undefined : normalizeKeyColumns(override);
}
async function runDataCompareSession(session: DataCompareSession, tasks: DataCompareTableTask[], dependencies: DataCompareSessionDependencies): Promise<void> {
const input = session.config;
const sourceColumnCache = new Map<string, CompareColumn[]>();
@@ -297,10 +344,11 @@ async function runDataCompareSession(session: DataCompareSession, tasks: DataCom
for (const [index, task] of tasks.entries()) {
publishProgress(session, { current: index + 1, total: tasks.length, table: task.sourceTable });
let keyColumns = input.keyColumns;
const configuredKeys = configuredTaskKeyColumns(input, task);
let keyColumns = configuredKeys ?? [];
try {
if (input.targetTables.includes(task.targetTable)) {
const resolvedKeys = input.keyColumns.length > 0 ? input.keyColumns : await inferKeyColumnsForTable(input, task.sourceTable, sourceColumnCache);
const resolvedKeys = configuredKeys ?? (await inferKeyColumnsForTable(input, task.sourceTable, sourceColumnCache));
if (resolvedKeys.length === 0) throw new Error(compareErrorMessage(dependencies, "noKeyColumns"));
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, task.sourceTable);
@@ -335,7 +383,7 @@ async function runDataCompareSession(session: DataCompareSession, tasks: DataCom
results.push(buildTableResult(task, preparation, canonicalKeyColumns, columns, columnInfo, databaseType));
} else {
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, task.sourceTable);
const resolvedKeys = input.keyColumns.length > 0 ? input.keyColumns : [];
const resolvedKeys = configuredTaskKeyColumns(input, task) ?? [];
const sourceColumnNames = sourceColumns.map((column) => column.name);
keyColumns = resolvedKeys.map((key) => matchColumnNameIgnoreCase(key, sourceColumnNames) ?? key);
const preparation = await api.prepareDataCompareMissingTarget({
+18 -2
View File
@@ -6715,8 +6715,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total} cədvəl uyğunlaşdırılıb",
missingTargetTables: "Eyni adlı hədəf cədvəllər çatışmır: {tables}",
keyColumns: "Açar sütunlar",
keyColumnsPlaceholder: "Vergüllə ayrılmış əsas və ya unikal sütunlar",
keyColumnsAutoHint: "Tək cədvəl müqayisəsində və ya toplu müqayisədə hər cədvəl üçün əsas açarları avtomatik aşkarlamaq üçün boş buraxın.",
keyColumnsAutoHint: "Standart olaraq əsas açarlar avtomatik istifadə olunur; digər sütunları əl ilə də seçə bilərsiniz.",
keyColumnsSelectPlaceholder: "Uyğunluq sütunlarını seçin",
keyColumnsSearch: "Sütun axtar...",
keyColumnsEmpty: "Mövcud sütun yoxdur",
keyColumnsLoading: "Sütunlar yüklənir...",
keyColumnsSelectedCount: "{selected}/{total} sütun seçildi",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Avtomatik",
keyColumnsStatusManual: "Əl ilə",
keyColumnsAutoSummary: "Əsas açar avtomatik istifadə olunur: {columns}",
keyColumnsManualHint: "Əl ilə seçilmiş uyğunluq sütunları: {columns}",
keyColumnsNoneSelected: "Heç bir sütun seçilməyib",
keyColumnsNeedsSelection: "Sütun seçin",
keyColumnsNoPrimaryKey: "Bu cədvəlin əsas açarı yoxdur. Uyğunluq sütunlarını əl ilə seçin.",
keyColumnsResetAuto: "Əsas açarı istifadə et",
keyColumnsPerTableHint: "Hər cədvəl öz uyğunluq sütunlarını saxlayır; standart olaraq əsas açar istifadə olunur.",
keyColumnsSelectTableHint: "Əvvəlcə mənbə cədvəllərini seçin",
keyColumnsTargetMissingHint: "Eyni adlı hədəf cədvəli yoxdur, uyğunluq sütunu tələb olunmur",
rowCounts: "Mənbədə {source} sətir, hədəfdə {target} sətir; tam müqayisə tamamlandı",
previewLimitOption: "Önbaxış: {count}",
compare: "Verilənləri müqayisə et",
+18 -2
View File
@@ -6943,8 +6943,24 @@ export default {
matchedTables: "{matched}/{total} tables matched",
missingTargetTables: "Missing same-name target tables: {tables}",
keyColumns: "Key Columns",
keyColumnsPlaceholder: "Comma-separated primary or unique columns",
keyColumnsAutoHint: "Leave blank to auto-detect primary keys for single-table compare, or per table in batch compare.",
keyColumnsAutoHint: "Primary keys are used by default; you can also pick other columns as match columns.",
keyColumnsSelectPlaceholder: "Select match columns",
keyColumnsSearch: "Search columns...",
keyColumnsEmpty: "No columns available",
keyColumnsLoading: "Loading columns...",
keyColumnsSelectedCount: "{selected}/{total} columns selected",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Auto",
keyColumnsStatusManual: "Manual",
keyColumnsAutoSummary: "Using the primary key automatically: {columns}",
keyColumnsManualHint: "Manually selected match columns: {columns}",
keyColumnsNoneSelected: "No column selected",
keyColumnsNeedsSelection: "Select columns",
keyColumnsNoPrimaryKey: "This table has no primary key. Select the match columns manually.",
keyColumnsResetAuto: "Use primary key",
keyColumnsPerTableHint: "Each table keeps its own match columns; primary keys are used by default.",
keyColumnsSelectTableHint: "Select source tables first",
keyColumnsTargetMissingHint: "No same-name target table, so no match columns are needed",
rowCounts: "Source {source} rows, target {target} rows; full compare completed",
previewLimitOption: "Preview {count}",
compare: "Compare Data",
+18 -2
View File
@@ -6504,8 +6504,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total} tablas emparejadas",
missingTargetTables: "Faltan tablas de destino con el mismo nombre: {tables}",
keyColumns: "Columnas clave",
keyColumnsPlaceholder: "Columnas primarias o únicas separadas por comas",
keyColumnsAutoHint: "Déjalo vacío para detectar claves primarias automáticamente en comparación simple o por tabla en lote.",
keyColumnsAutoHint: "Las claves primarias se usan de forma predeterminada; también puede seleccionar otras columnas manualmente.",
keyColumnsSelectPlaceholder: "Seleccionar columnas de coincidencia",
keyColumnsSearch: "Buscar columnas...",
keyColumnsEmpty: "No hay columnas disponibles",
keyColumnsLoading: "Cargando columnas...",
keyColumnsSelectedCount: "{selected}/{total} columnas seleccionadas",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Automático",
keyColumnsStatusManual: "Manual",
keyColumnsAutoSummary: "Usando la clave primaria automáticamente: {columns}",
keyColumnsManualHint: "Columnas de coincidencia seleccionadas manualmente: {columns}",
keyColumnsNoneSelected: "Ninguna columna seleccionada",
keyColumnsNeedsSelection: "Seleccione columnas",
keyColumnsNoPrimaryKey: "Esta tabla no tiene clave primaria. Seleccione las columnas de coincidencia manualmente.",
keyColumnsResetAuto: "Usar clave primaria",
keyColumnsPerTableHint: "Cada tabla mantiene sus propias columnas de coincidencia; las claves primarias se usan de forma predeterminada.",
keyColumnsSelectTableHint: "Seleccione primero las tablas de origen",
keyColumnsTargetMissingHint: "No hay tabla de destino con el mismo nombre, no se necesitan columnas de coincidencia",
rowCounts: "Origen {source} filas, destino {target} filas; comparación completa terminada",
previewLimitOption: "Vista previa {count}",
compare: "Comparar datos",
+18 -2
View File
@@ -6504,8 +6504,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total} tabelle associate",
missingTargetTables: "Tabelle di destinazione con lo stesso nome mancanti: {tables}",
keyColumns: "Colonne Chiave",
keyColumnsPlaceholder: "Colonne primarie o uniche separate da virgola",
keyColumnsAutoHint: "Lascia vuoto per rilevare automaticamente le chiavi primarie per il confronto a tabella singola, o per tabella nel confronto batch.",
keyColumnsAutoHint: "Le chiavi primarie vengono usate per impostazione predefinita; puoi anche selezionare altre colonne manualmente.",
keyColumnsSelectPlaceholder: "Seleziona colonne di confronto",
keyColumnsSearch: "Cerca colonne...",
keyColumnsEmpty: "Nessuna colonna disponibile",
keyColumnsLoading: "Caricamento colonne...",
keyColumnsSelectedCount: "{selected}/{total} colonne selezionate",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Automatico",
keyColumnsStatusManual: "Manuale",
keyColumnsAutoSummary: "Chiave primaria usata automaticamente: {columns}",
keyColumnsManualHint: "Colonne di confronto selezionate manualmente: {columns}",
keyColumnsNoneSelected: "Nessuna colonna selezionata",
keyColumnsNeedsSelection: "Seleziona colonne",
keyColumnsNoPrimaryKey: "Questa tabella non ha una chiave primaria. Seleziona manualmente le colonne di confronto.",
keyColumnsResetAuto: "Usa chiave primaria",
keyColumnsPerTableHint: "Ogni tabella mantiene le proprie colonne di confronto; per impostazione predefinita si usa la chiave primaria.",
keyColumnsSelectTableHint: "Seleziona prima le tabelle di origine",
keyColumnsTargetMissingHint: "Nessuna tabella di destinazione con lo stesso nome: non servono colonne di confronto",
rowCounts: "Sorgente {source} righe, destinazione {target} righe; confronto completo completato",
previewLimitOption: "Anteprima {count}",
compare: "Confronta Dati",
+18 -2
View File
@@ -6542,8 +6542,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total}テーブルがマッチしました",
missingTargetTables: "同名のターゲットテーブルが見つかりません: {tables}",
keyColumns: "キー列",
keyColumnsPlaceholder: "カンマ区切りの主キーまたは一意列",
keyColumnsAutoHint: "単一テーブル比較では空白で主キーを自動検出、バッチ比較ではテーブルごとに指定します。",
keyColumnsAutoHint: "既定では主キーを自動使用します。他の列を手動で選択することもできます。",
keyColumnsSelectPlaceholder: "照合列を選択",
keyColumnsSearch: "列を検索...",
keyColumnsEmpty: "選択できる列がありません",
keyColumnsLoading: "列を読み込み中...",
keyColumnsSelectedCount: "{selected}/{total} 列を選択",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "自動",
keyColumnsStatusManual: "手動",
keyColumnsAutoSummary: "主キーを自動使用: {columns}",
keyColumnsManualHint: "手動で選択した照合列: {columns}",
keyColumnsNoneSelected: "列が選択されていません",
keyColumnsNeedsSelection: "選択してください",
keyColumnsNoPrimaryKey: "このテーブルには主キーがありません。照合列を手動で選択してください。",
keyColumnsResetAuto: "主キーに戻す",
keyColumnsPerTableHint: "テーブルごとに照合列を保持します。既定では主キーを使用します。",
keyColumnsSelectTableHint: "先にソーステーブルを選択してください",
keyColumnsTargetMissingHint: "同名のターゲットテーブルがないため照合列は不要です",
rowCounts: "ソース {source}行、ターゲット {target}行、完全比較が完了しました",
previewLimitOption: "{count}件プレビュー",
compare: "データを比較",
+18 -2
View File
@@ -6254,8 +6254,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total}개 테이블 매칭됨",
missingTargetTables: "같은 이름의 대상 테이블 누락: {tables}",
keyColumns: "키 컬럼",
keyColumnsPlaceholder: "쉼표로 구분된 기본 또는 고유 컬럼",
keyColumnsAutoHint: "단일 테이블 비교 시 기본 키를 자동 감지하거나, 일괄 비교 시 테이블별로 자동 감지하려면 비워 두세요.",
keyColumnsAutoHint: "기본적으로 기본 키를 자동 사용합니다. 다른 컬럼을 직접 선택할 수도 있습니다.",
keyColumnsSelectPlaceholder: "매칭 컬럼 선택",
keyColumnsSearch: "컬럼 검색...",
keyColumnsEmpty: "선택할 수 있는 컬럼이 없습니다",
keyColumnsLoading: "컬럼을 불러오는 중...",
keyColumnsSelectedCount: "{selected}/{total}개 컬럼 선택됨",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "자동",
keyColumnsStatusManual: "수동",
keyColumnsAutoSummary: "기본 키를 자동 사용: {columns}",
keyColumnsManualHint: "직접 선택한 매칭 컬럼: {columns}",
keyColumnsNoneSelected: "선택된 컬럼이 없습니다",
keyColumnsNeedsSelection: "선택 필요",
keyColumnsNoPrimaryKey: "이 테이블에는 기본 키가 없습니다. 매칭 컬럼을 직접 선택하세요.",
keyColumnsResetAuto: "기본 키로 되돌리기",
keyColumnsPerTableHint: "각 테이블이 자체 매칭 컬럼을 사용하며 기본값은 기본 키입니다.",
keyColumnsSelectTableHint: "먼저 소스 테이블을 선택하세요",
keyColumnsTargetMissingHint: "대상에 동일한 이름의 테이블이 없어 매칭 컬럼이 필요하지 않습니다",
rowCounts: "소스 {source}행, 대상 {target}행; 전체 비교 완료",
previewLimitOption: "미리보기 {count}",
compare: "데이터 비교",
+18 -2
View File
@@ -6506,8 +6506,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total} tabelas correspondidas",
missingTargetTables: "Tabelas de destino de mesmo nome ausentes: {tables}",
keyColumns: "Colunas-Chave",
keyColumnsPlaceholder: "Colunas primárias ou únicas separadas por vírgula",
keyColumnsAutoHint: "Deixe em branco para detectar automaticamente as chaves primárias na comparação de tabela única, ou por tabela na comparação em lote.",
keyColumnsAutoHint: "As chaves primárias são usadas por padrão; você também pode selecionar outras colunas manualmente.",
keyColumnsSelectPlaceholder: "Selecionar colunas de comparação",
keyColumnsSearch: "Pesquisar colunas...",
keyColumnsEmpty: "Nenhuma coluna disponível",
keyColumnsLoading: "Carregando colunas...",
keyColumnsSelectedCount: "{selected}/{total} colunas selecionadas",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Automático",
keyColumnsStatusManual: "Manual",
keyColumnsAutoSummary: "Usando a chave primária automaticamente: {columns}",
keyColumnsManualHint: "Colunas de comparação selecionadas manualmente: {columns}",
keyColumnsNoneSelected: "Nenhuma coluna selecionada",
keyColumnsNeedsSelection: "Selecione colunas",
keyColumnsNoPrimaryKey: "Esta tabela não tem chave primária. Selecione as colunas de comparação manualmente.",
keyColumnsResetAuto: "Usar chave primária",
keyColumnsPerTableHint: "Cada tabela mantém suas próprias colunas de comparação; as chaves primárias são usadas por padrão.",
keyColumnsSelectTableHint: "Selecione primeiro as tabelas de origem",
keyColumnsTargetMissingHint: "Não há tabela de destino com o mesmo nome, então não são necessárias colunas de comparação",
rowCounts: "Origem com {source} linhas, destino com {target} linhas; comparação completa concluída",
previewLimitOption: "Pré-visualizar {count}",
compare: "Comparar Dados",
+18 -2
View File
@@ -6597,8 +6597,24 @@ export default withEnglishFallback({
matchedTables: "{matched}/{total} tablo eşleşti",
missingTargetTables: "Aynı adlı hedef tablolar eksik: {tables}",
keyColumns: "Anahtar Sütunlar",
keyColumnsPlaceholder: "Virgülle ayrılmış birincil veya benzersiz sütunlar",
keyColumnsAutoHint: "Tek tablo karşılaştırmasında birincil anahtarları otomatik algılamak için boş bırakın; toplu karşılaştırmada her tablo için ayrı ayrı algılanır.",
keyColumnsAutoHint: "Birincil anahtarlar varsayılan olarak kullanılır; diğer sütunları elle de seçebilirsiniz.",
keyColumnsSelectPlaceholder: "Eşleşme sütunlarını seç",
keyColumnsSearch: "Sütun ara...",
keyColumnsEmpty: "Kullanılabilir sütun yok",
keyColumnsLoading: "Sütunlar yükleniyor...",
keyColumnsSelectedCount: "{selected}/{total} sütun seçildi",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "Otomatik",
keyColumnsStatusManual: "Manuel",
keyColumnsAutoSummary: "Birincil anahtar otomatik kullanılıyor: {columns}",
keyColumnsManualHint: "Elle seçilen eşleşme sütunları: {columns}",
keyColumnsNoneSelected: "Sütun seçilmedi",
keyColumnsNeedsSelection: "Sütun seçin",
keyColumnsNoPrimaryKey: "Bu tabloda birincil anahtar yok. Eşleşme sütunlarını elle seçin.",
keyColumnsResetAuto: "Birincil anahtarı kullan",
keyColumnsPerTableHint: "Her tablo kendi eşleşme sütunlarını kullanır; varsayılan olarak birincil anahtar kullanılır.",
keyColumnsSelectTableHint: "Önce kaynak tabloları seçin",
keyColumnsTargetMissingHint: "Aynı adlı hedef tablo yok, eşleşme sütunu gerekmez",
rowCounts: "Kaynak {source} satır, hedef {target} satır; tam karşılaştırma tamamlandı",
previewLimitOption: "Önizleme {count}",
compare: "Verileri Karşılaştır",
+18 -2
View File
@@ -6917,8 +6917,24 @@ export default withEnglishFallback({
matchedTables: "已匹配 {matched}/{total} 张表",
missingTargetTables: "目标端缺少同名表:{tables}",
keyColumns: "匹配字段",
keyColumnsPlaceholder: "用逗号分隔主键或唯一键字段",
keyColumnsAutoHint: "留空时,单表会自动推断主键;多表会对每张表分别自动推断。",
keyColumnsAutoHint: "默认自动使用主键;也可手动选择其他字段作为匹配字段。",
keyColumnsSelectPlaceholder: "选择匹配字段",
keyColumnsSearch: "搜索字段...",
keyColumnsEmpty: "没有可选的字段",
keyColumnsLoading: "正在加载字段...",
keyColumnsSelectedCount: "已选 {selected}/{total} 个字段",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "自动",
keyColumnsStatusManual: "手动",
keyColumnsAutoSummary: "已自动使用主键:{columns}",
keyColumnsManualHint: "手动选择匹配字段:{columns}",
keyColumnsNoneSelected: "未选择任何字段",
keyColumnsNeedsSelection: "需选择",
keyColumnsNoPrimaryKey: "该表没有主键,请手动选择匹配字段",
keyColumnsResetAuto: "恢复自动(主键)",
keyColumnsPerTableHint: "每张表使用各自的匹配字段,默认自动使用主键。",
keyColumnsSelectTableHint: "请先选择源表",
keyColumnsTargetMissingHint: "目标端缺少同名表,无需匹配字段",
rowCounts: "源表 {source} 行,目标表 {target} 行;已完成全量比较",
previewLimitOption: "预览 {count}",
compare: "开始比较数据",
+18 -2
View File
@@ -5836,8 +5836,24 @@ export default withEnglishFallback({
matchedTables: "已比對 {matched}/{total} 張資料表",
missingTargetTables: "目標端缺少同名資料表:{tables}",
keyColumns: "鍵欄位",
keyColumnsPlaceholder: "以逗號分隔主鍵或唯一欄位",
keyColumnsAutoHint: "留空時,單一資料表會自動偵測主鍵;批次比較則會逐表偵測。",
keyColumnsAutoHint: "預設自動使用主鍵;也可手動選擇其他欄位作為匹配欄位。",
keyColumnsSelectPlaceholder: "選擇匹配欄位",
keyColumnsSearch: "搜尋欄位...",
keyColumnsEmpty: "沒有可選的欄位",
keyColumnsLoading: "正在載入欄位...",
keyColumnsSelectedCount: "已選 {selected}/{total} 個欄位",
keyColumnsPrimaryKeyBadge: "PK",
keyColumnsStatusAuto: "自動",
keyColumnsStatusManual: "手動",
keyColumnsAutoSummary: "已自動使用主鍵:{columns}",
keyColumnsManualHint: "手動選擇匹配欄位:{columns}",
keyColumnsNoneSelected: "未選擇任何欄位",
keyColumnsNeedsSelection: "需選擇",
keyColumnsNoPrimaryKey: "此資料表沒有主鍵,請手動選擇匹配欄位",
keyColumnsResetAuto: "恢復自動(主鍵)",
keyColumnsPerTableHint: "每張資料表使用各自的匹配欄位,預設自動使用主鍵。",
keyColumnsSelectTableHint: "請先選擇來源資料表",
keyColumnsTargetMissingHint: "目標端缺少同名資料表,無需匹配欄位",
rowCounts: "來源 {source} 列,目標 {target} 列;已完成完整比較",
previewLimitOption: "預覽 {count}",
compare: "開始比較資料",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "../dataCompare";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase, normalizeKeyColumns, sameKeyColumns } from "../dataCompare";
describe("inferCompareKeyColumns", () => {
it("returns all primary-key columns when a primary key exists", () => {
@@ -71,3 +71,36 @@ describe("matchColumnNameIgnoreCase", () => {
expect(matchColumnNameIgnoreCase("missing", ["snid"])).toBeUndefined();
});
});
describe("normalizeKeyColumns", () => {
it("keeps the given order so a composite key stays stable", () => {
expect(normalizeKeyColumns(["tenant_id", "order_id"])).toEqual(["tenant_id", "order_id"]);
});
it("trims names and drops blanks", () => {
expect(normalizeKeyColumns([" id ", "", " ", "name"])).toEqual(["id", "name"]);
});
it("drops case-insensitive duplicates but keeps the first spelling", () => {
expect(normalizeKeyColumns(["ID", "id", "Id"])).toEqual(["ID"]);
});
it("returns an empty array for an empty selection", () => {
expect(normalizeKeyColumns([])).toEqual([]);
});
});
describe("sameKeyColumns", () => {
it("ignores order and identifier case", () => {
expect(sameKeyColumns(["tenant_id", "order_id"], ["ORDER_ID", "TENANT_ID"])).toBe(true);
});
it("detects different selections and different lengths", () => {
expect(sameKeyColumns(["id"], ["ID", "name"])).toBe(false);
expect(sameKeyColumns(["id"], ["name"])).toBe(false);
});
it("treats two empty selections as equal", () => {
expect(sameKeyColumns([], [])).toBe(true);
});
});
+39 -1
View File
@@ -121,6 +121,9 @@ export interface DataCompareSyncPlan {
syncSql: string;
}
/** Minimal column shape a match-column picker needs, so it can be fed straight from `getColumns`. */
export type CompareKeyColumnOption = Pick<ColumnInfo, "name" | "is_primary_key">;
/**
* Safely infers the columns to use as comparison keys for a table.
*
@@ -134,7 +137,7 @@ export interface DataCompareSyncPlan {
* (category, status, date, ...) and make the whole table comparison fail with
* an unhelpful duplicate-key error.
*/
export function inferCompareKeyColumns(columns: Pick<ColumnInfo, "name" | "is_primary_key">[]): string[] {
export function inferCompareKeyColumns(columns: CompareKeyColumnOption[]): string[] {
const primaryKeys: string[] = [];
for (const column of columns) {
if (column.is_primary_key) primaryKeys.push(column.name);
@@ -142,6 +145,41 @@ export function inferCompareKeyColumns(columns: Pick<ColumnInfo, "name" | "is_pr
return primaryKeys;
}
/**
* Normalizes a match-column selection: trims entries, drops blanks and removes
* case-insensitive duplicates while keeping the caller's order.
*
* Order is preserved because the backend builds each row key from `keyColumns`
* in order; a stable order keeps diff keys identical between runs, and it lets a
* composite primary key keep the column order the table declares.
*/
export function normalizeKeyColumns(columns: readonly string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const column of columns) {
const name = column.trim();
if (!name) continue;
const lowerName = name.toLowerCase();
if (seen.has(lowerName)) continue;
seen.add(lowerName);
normalized.push(name);
}
return normalized;
}
/**
* Compares two match-column selections ignoring order and identifier case.
* Used to decide whether a manual selection still equals the inferred primary
* key, in which case it carries no override.
*/
export function sameKeyColumns(a: readonly string[], b: readonly string[]): boolean {
const normalizedA = normalizeKeyColumns(a);
const normalizedB = normalizeKeyColumns(b);
if (normalizedA.length !== normalizedB.length) return false;
const lowerB = new Set(normalizedB.map((column) => column.toLowerCase()));
return normalizedA.every((column) => lowerB.has(column.toLowerCase()));
}
export interface CompareColumnIntersection {
/** Target-side column names, kept in source order. */
columns: string[];