feat(data-compare): support per-table match column configuration
This commit is contained in:
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>
|
||||
|
||||
+399
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "データを比較",
|
||||
|
||||
@@ -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: "데이터 비교",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "开始比较数据",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user