feat(schema-diff): support selecting tables to compare

This commit is contained in:
jearhe authored and GitHub committed 2026-08-18 15:31:00 +08:00
1 parent 0facea2740
commit b3b6d29099
21 files changed
+933 -69

No files matched your search

@@ -19,6 +19,7 @@ import type { ColumnInfo, DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
type CompareColumn = ColumnInfo;
@@ -89,7 +90,6 @@ const sourceTable = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const sourceTables = ref<string[]>([]);
const sourceTableSearch = ref("");
const selectedSourceTables = ref<Set<string>>(new Set());
const targetConnectionId = ref("");
@@ -122,12 +122,11 @@ let syncPlanRequestId = 0;
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
const filteredSourceTables = computed(() => {
const query = sourceTableSearch.value.trim().toLowerCase();
if (!query) return sourceTables.value;
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
const sourceTableSelection = computed<string[]>({
get: () => [...selectedSourceTables.value],
set: (value: string[]) => resetSelectedSourceTables(value),
});
const allFilteredTablesSelected = computed(() => filteredSourceTables.value.length > 0 && filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)));
const compareTasksPreview = computed(() =>
selectedSourceTableNames.value.map((table) => {
const target = isBatchCompare.value ? table : targetTable.value || table;
@@ -221,23 +220,6 @@ function resetSelectedSourceTables(nextTables: Iterable<string>) {
selectedSourceTables.value = new Set(nextTables);
}
function toggleSourceTable(table: string) {
const next = new Set(selectedSourceTables.value);
if (next.has(table)) next.delete(table);
else next.add(table);
resetSelectedSourceTables(next);
}
function toggleSelectAllSourceTables() {
const next = new Set(selectedSourceTables.value);
if (allFilteredTablesSelected.value) {
filteredSourceTables.value.forEach((table) => next.delete(table));
} else {
filteredSourceTables.value.forEach((table) => next.add(table));
}
resetSelectedSourceTables(next);
}
function buildCompareTasks(): DataCompareTableTask[] {
if (!selectedSourceTableNames.value.length) return [];
if (!isBatchCompare.value) {
@@ -804,7 +786,6 @@ watch(sourceConnectionId, (id) => {
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
});
@@ -823,7 +804,6 @@ watch(sourceDatabase, () => {
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
@@ -839,7 +819,6 @@ watch(sourceSchema, () => {
clearResult();
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
});
@@ -947,41 +926,13 @@ watch(
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<div class="space-y-2 rounded-lg border p-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.sourceTables") }}</Label>
<div v-if="sourceTables.length" class="text-[11px] text-muted-foreground">
{{
t("dataCompare.selectedTables", {
selected: selectedSourceTableNames.length,
total: sourceTables.length,
})
}}
</div>
</div>
<Input v-if="sourceTables.length > 5" v-model="sourceTableSearch" class="h-7 text-xs" :placeholder="t('dataCompare.searchTables')" />
<div class="flex items-center gap-2">
<Button v-if="sourceTables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="toggleSelectAllSourceTables">
{{ allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables") }}
</Button>
</div>
<div v-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.selectSourceTables") }}
</div>
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.noTables") }}
</div>
<div v-else class="max-h-40 overflow-auto rounded border">
<button v-for="table in filteredSourceTables" :key="table" type="button" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50" @click="toggleSourceTable(table)">
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
<TableMultiSelect
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
v-model="sourceTableSelection"
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
/>
</div>
<div class="flex items-center pt-6">
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch";
import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
@@ -28,6 +31,8 @@ const props = defineProps<{
targetSchema: string;
ignoreComments: boolean;
options: SchemaDiffCompareOptions;
selectedTables?: string[];
tableListLoader: SchemaDiffTableListLoader;
loading: boolean;
recentConfigs: SchemaDiffConfig[];
}>();
@@ -49,6 +54,7 @@ const emit = defineEmits<{
(e: "swap"): void;
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
(e: "deleteHistoryConfig", configId: string): void;
(e: "update:selectedTables", value?: string[]): void;
}>();
const sourceDatabases = ref<string[]>([]);
@@ -68,8 +74,94 @@ const targetDbType = computed(() => targetConfig.value?.db_type || "");
const showFieldMapping = computed(() => sourceDbType.value && targetDbType.value && sourceDbType.value !== targetDbType.value);
const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
// ---- Visual (explicit) table selection ----
const sourceTableList = ref<string[]>([]);
const targetTableList = ref<string[]>([]);
const restrictTables = ref(false);
const localSelectedTables = ref<string[]>([]);
const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value));
const missingTargetTables = computed(() => matchResult.value.missing);
// Keep the visual restriction in sync with the (persisted) config options.
// `undefined` = not restricted (compare all tables, then regex filter);
// an array = explicitly restricted to exactly those tables.
watch(
() => props.selectedTables,
(value) => {
restrictTables.value = Array.isArray(value);
localSelectedTables.value = value && Array.isArray(value) ? [...value] : [];
},
{ immediate: true },
);
function handleToggleRestrict(enabled: boolean) {
restrictTables.value = enabled;
emit("update:selectedTables", enabled ? [...localSelectedTables.value] : undefined);
}
function handleUpdateSelectedTables(value: string[]) {
localSelectedTables.value = value;
if (restrictTables.value) emit("update:selectedTables", [...value]);
}
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
return side === "source" ? { connectionId: props.sourceConnectionId, database: props.sourceDatabase, schema: props.sourceSchema } : { connectionId: props.targetConnectionId, database: props.targetDatabase, schema: props.targetSchema };
}
function isTableIdentityReady(side: SchemaDiffTableSide): boolean {
const identity = getTableIdentity(side);
const config = side === "source" ? sourceConfig.value : targetConfig.value;
return !!identity.connectionId && !!identity.database && (!isSchemaAware(config?.db_type) || !!identity.schema);
}
function setTableList(side: SchemaDiffTableSide, tables: Array<{ name?: string }>) {
const names = tables.map((entry) => entry.name ?? "").filter(Boolean);
if (side === "source") sourceTableList.value = names;
else targetTableList.value = names;
}
function reconcileSelectedTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) {
if (!restrictTables.value || !Array.isArray(props.selectedTables)) return;
const availableTables = tables.map((entry) => entry.name ?? "").filter(Boolean);
const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables);
if (nextSelectedTables.length === props.selectedTables.length && nextSelectedTables.every((table, index) => table === props.selectedTables?.[index])) return;
localSelectedTables.value = nextSelectedTables;
emit("update:selectedTables", nextSelectedTables);
}
const tableListCoordinator = createSchemaDiffTableListCoordinator({
loader: props.tableListLoader,
getIdentity: getTableIdentity,
setTables: setTableList,
onSourceTablesLoaded: reconcileSelectedTablesAfterSuccessfulLoad,
});
watch(
() => [restrictTables.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value],
() => {
const shouldLoad = shouldLoadSchemaDiffTableList("source", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("source"));
tableListCoordinator.refresh("source", shouldLoad).catch(() => {});
},
{ immediate: true },
);
watch(
() => [restrictTables.value, localSelectedTables.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value],
() => {
const shouldLoad = shouldLoadSchemaDiffTableList("target", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("target"));
tableListCoordinator.refresh("target", shouldLoad).catch(() => {});
},
{ immediate: true },
);
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
const canCompare = computed(() => {
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
const hasSelectedTables = props.selectedTables === undefined || props.selectedTables.length > 0;
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
});
async function loadDatabases(connectionId: string, side: "source" | "target") {
@@ -175,6 +267,25 @@ watch(
{ immediate: true },
);
// A visual selection belongs to the current source connection/database/schema. Clear it
// when that identity changes so a saved table name cannot silently restrict a different
// source. If the source and selected tables change together, it is a saved-config load;
// let the selectedTables watcher restore that config instead of clearing it again.
watch(
() => ({
source: [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] as const,
selectedTables: props.selectedTables,
}),
(current, previous) => {
if (current.source.every((value, index) => value === previous.source[index])) return;
if (current.selectedTables !== previous.selectedTables) return;
if (current.selectedTables === undefined && !restrictTables.value) return;
restrictTables.value = false;
localSelectedTables.value = [];
emit("update:selectedTables", undefined);
},
);
watch(
() => props.targetDatabase,
async (db) => {
@@ -324,6 +435,20 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
/>
</div>
<!-- Source Table Selection -->
<div class="mt-3 space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ t("diff.tableSelection") }}</Label>
<Button v-if="restrictTables || canConfigureTableSelection" variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="handleToggleRestrict(!restrictTables)">
{{ restrictTables ? t("diff.compareAllTables") : t("diff.chooseTables") }}
</Button>
</div>
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
{{ t("diff.tableSelectionUnrestricted") }}
</div>
<TableMultiSelect v-if="restrictTables" :key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`" :model-value="localSelectedTables" @update:model-value="handleUpdateSelectedTables" :tables="sourceTableList" :title="t('diff.sourceTables')" :empty-text="t('dataCompare.noTables')" />
</div>
<!-- Source Info -->
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
@@ -409,6 +534,17 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
/>
</div>
<!-- Target Same-Name Match -->
<div v-if="restrictTables && localSelectedTables.length" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
</div>
</div>
<!-- Target Info -->
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
@@ -19,6 +19,7 @@ import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan } from "@/lib/schema/schemaDiffMetadataLoad";
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
import type { ObjectSourceKind, TableInfo } from "@/types/database";
@@ -56,6 +57,10 @@ const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const schemaDiffTableListLoader = createSchemaDiffTableListLoader({
ensureConnected: (connectionId) => store.ensureConnected(connectionId),
listTables: (connectionId, database, schema) => api.listTables(connectionId, database, schema),
});
const props = defineProps<{
prefillConnectionId?: string;
@@ -326,6 +331,12 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
}
}
function handleSelectedTablesUpdate(value?: string[]) {
if (activeConfig.value) {
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, selectedTables: value }, getDbType()));
}
}
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
if (activeConfig.value) {
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
@@ -396,11 +407,13 @@ async function handleCompare() {
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
const tableFilter = compileSchemaDiffTableFilter(opts);
await store.ensureConnected(sourceConnectionId.value);
await store.ensureConnected(targetConnectionId.value);
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts);
const sourceTableIdentity: SchemaDiffTableIdentity = { connectionId: sourceConnectionId.value, database: sourceDatabase.value, schema: sourceSchema.value };
const targetTableIdentity: SchemaDiffTableIdentity = { connectionId: targetConnectionId.value, database: targetDatabase.value, schema: targetSchema.value };
const [srcTables, tgtTables] = await Promise.all([schemaDiffTableListLoader.load(sourceTableIdentity), schemaDiffTableListLoader.load(targetTableIdentity)]);
// Explicit (visual) table selection is applied here, BEFORE any per-table
// metadata details are loaded, so metadata requests only happen for the
// final table set. `undefined`/empty means no restriction (legacy path).
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts, opts.selectedTables);
const sourceDetails = await loadSchemaDetails(sourceTables, {
connectionId: sourceConnectionId.value,
@@ -871,6 +884,8 @@ const targetConnectionInfo = computed(() => {
:configs="configs"
:active-config-id="activeConfigId"
:options="activeConfig?.options"
:selected-tables="activeConfig?.options?.selectedTables"
:table-list-loader="schemaDiffTableListLoader"
:loading="loading"
:recent-configs="recentConfigs"
@compare="handleCompare"
@@ -880,6 +895,7 @@ const targetConnectionInfo = computed(() => {
@load-history-config="handleLoadHistoryConfig"
@delete-history-config="handleDeleteHistoryConfig"
@update:field-mappings="handleFieldMappingsUpdate"
@update:selected-tables="handleSelectedTablesUpdate"
@open-field-mapping="showFieldMappingDialog = true"
/>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { CheckSquare, Square } from "@lucide/vue";
import { filterTableNames, isEveryFilteredSelected, toggleSelectFiltered, toggleTableName } from "@/lib/diff/tableMultiSelect";
/**
* Shared, searchable multi-select for a set of table names. Used by both Data
* Compare and Schema Diff. It only knows about "a list of table names to
* multi-select" — no connection/database/diff-engine concepts.
*/
const props = withDefaults(
defineProps<{
/** All available table names. */
tables: string[];
/** Currently selected table names (v-model). */
modelValue: string[];
/** Optional header label; falls back to `tableMultiSelect.tables`. */
title?: string;
/** Show the search box (only rendered when there are more than 5 tables). */
searchable?: boolean;
/** Text shown when `tables` is empty. */
emptyText?: string;
disabled?: boolean;
}>(),
{
title: "",
searchable: true,
emptyText: "",
disabled: false,
},
);
const emit = defineEmits<{ (e: "update:modelValue", value: string[]): void }>();
const { t } = useI18n();
const search = ref("");
const filteredTables = computed(() => filterTableNames(props.tables, search.value));
const selectedSet = computed(() => new Set(props.modelValue));
const allFilteredSelected = computed(() => isEveryFilteredSelected(props.modelValue, filteredTables.value));
const showSearch = computed(() => props.searchable && props.tables.length > 5);
function toggle(table: string) {
if (props.disabled) return;
emit("update:modelValue", toggleTableName(props.modelValue, table));
}
function toggleSelectAll() {
if (props.disabled) return;
emit("update:modelValue", toggleSelectFiltered(props.modelValue, filteredTables.value));
}
</script>
<template>
<div class="space-y-2 rounded-lg border p-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ title || t("tableMultiSelect.tables") }}</Label>
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
{{ t("tableMultiSelect.selected", { selected: modelValue.length, total: tables.length }) }}
</div>
</div>
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="t('tableMultiSelect.search')" :disabled="disabled" />
<div class="flex items-center gap-2">
<Button v-if="tables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="disabled" @click="toggleSelectAll">
{{ allFilteredSelected ? t("tableMultiSelect.deselectAll") : t("tableMultiSelect.selectAll") }}
</Button>
</div>
<div v-if="tables.length === 0" class="py-3 text-center text-xs text-muted-foreground">
{{ 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)">
<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>
</button>
</div>
</div>
</template>