From 71ccb3d97dc5fddb928f0611b0acac0bee8721fe Mon Sep 17 00:00:00 2001 From: miracle Date: Sun, 9 Aug 2026 14:24:10 +0800 Subject: [PATCH] feat(execution): multi-database SQL execution with scoped targets --- apps/desktop/src/App.vue | 93 +- .../editor/MultiDbExecuteDialog.vue | 1051 +++++++++++++++++ .../export/ExportProgressPopover.vue | 26 +- .../src/components/layout/AppDialogs.vue | 24 + .../src/components/layout/ContentArea.vue | 23 +- .../src/components/layout/EditorToolbar.vue | 18 +- .../__tests__/useDataGridActions.spec.ts | 37 + .../__tests__/useMultiDbExecution.spec.ts | 188 +++ .../src/composables/useDataGridActions.ts | 22 +- .../src/composables/useDatabaseOptions.ts | 75 +- .../src/composables/useExportTracker.ts | 71 +- .../src/composables/useMultiDbExecution.ts | 215 ++++ .../composables/useMultiDbTargetSelection.ts | 336 ++++++ .../src/composables/useSchemaOptions.ts | 29 +- .../src/composables/useSqlExecution.ts | 207 +++- .../executionNamespaceParity.spec.ts | 50 + apps/desktop/src/i18n/locales/en.ts | 79 ++ apps/desktop/src/i18n/locales/es.ts | 79 ++ apps/desktop/src/i18n/locales/it.ts | 79 ++ apps/desktop/src/i18n/locales/ja.ts | 79 ++ apps/desktop/src/i18n/locales/ko.ts | 76 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 79 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 79 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 79 ++ .../database/databaseFeatureSupport.spec.ts | 27 +- .../sqlExecutionTargetCapabilities.spec.ts | 123 ++ apps/desktop/src/lib/backend/tauri.ts | 1 + .../lib/database/databaseFeatureSupport.ts | 35 + .../sqlExecutionTargetCapabilities.ts | 152 +++ .../database/sqlExecutionTargetRegistry.ts | 181 +++ .../productionSafetyStore.scope.spec.ts | 23 + .../queryStore.multiStatementError.spec.ts | 25 + .../__tests__/sqlExecutionDangerStore.spec.ts | 23 + .../sqlExecutionTargetGroupStore.spec.ts | 76 ++ .../src/stores/productionSafetyStore.ts | 18 +- apps/desktop/src/stores/queryStore.ts | 446 +++++-- .../src/stores/sqlExecutionDangerStore.ts | 78 ++ .../stores/sqlExecutionTargetGroupStore.ts | 194 +++ apps/desktop/src/types/database.ts | 4 + apps/desktop/src/types/sqlExecution.ts | 88 ++ crates/dbx-core/src/query_result_export.rs | 5 + .../live_clickhouse_query_result_export.rs | 1 + crates/dbx-core/tests/live_mysql57.rs | 2 + .../live_postgres_query_result_export.rs | 5 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + 46 files changed, 4421 insertions(+), 182 deletions(-) create mode 100644 apps/desktop/src/components/editor/MultiDbExecuteDialog.vue create mode 100644 apps/desktop/src/composables/__tests__/useMultiDbExecution.spec.ts create mode 100644 apps/desktop/src/composables/useMultiDbExecution.ts create mode 100644 apps/desktop/src/composables/useMultiDbTargetSelection.ts create mode 100644 apps/desktop/src/i18n/__tests__/executionNamespaceParity.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/database/sqlExecutionTargetCapabilities.spec.ts create mode 100644 apps/desktop/src/lib/database/sqlExecutionTargetCapabilities.ts create mode 100644 apps/desktop/src/lib/database/sqlExecutionTargetRegistry.ts create mode 100644 apps/desktop/src/stores/__tests__/productionSafetyStore.scope.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/sqlExecutionDangerStore.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/sqlExecutionTargetGroupStore.spec.ts create mode 100644 apps/desktop/src/stores/sqlExecutionDangerStore.ts create mode 100644 apps/desktop/src/stores/sqlExecutionTargetGroupStore.ts create mode 100644 apps/desktop/src/types/sqlExecution.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 7dd12736b3..b2591d9614 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -13,6 +13,8 @@ import WelcomeScreen from "@/components/layout/WelcomeScreen.vue"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; +import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore"; +import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; @@ -25,6 +27,7 @@ import { useFileDrop } from "@/composables/useFileDrop"; import { usePanelResize } from "@/composables/usePanelResize"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import { useSqlExecution } from "@/composables/useSqlExecution"; +import MultiDbExecuteDialog from "@/components/editor/MultiDbExecuteDialog.vue"; import { useDialogSources } from "@/composables/useDialogSources"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { useDataGridActions } from "@/composables/useDataGridActions"; @@ -57,7 +60,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; -import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { isBrowserReloadShortcut, @@ -87,6 +90,7 @@ import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; import { assessProductionSql } from "@/lib/database/productionSafety"; +import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis"; import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge"; @@ -253,6 +257,9 @@ const queryEditorObjectSourceTarget = ref<{ relationName?: string; } | null>(null); const showSaveSqlDialog = ref(false); +const showMultiDbExecuteDialog = ref(false); +const multiExecuteSql = ref(""); +const multiExecuteSourceTabId = ref(""); const saveSqlName = ref(""); const ROOT_SAVED_SQL_FOLDER = "__root__"; const { selection: saveSqlFolderId, pending: saveSqlFolderCreationPending, reset: resetSaveSqlFolderSelection, invalidate: invalidateSaveSqlFolderSelection, select: selectSaveSqlFolder } = useSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER); @@ -343,6 +350,8 @@ async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot) { const blockDangerousRedisCommands = ref(true); const databaseRequiredSignal = ref(0); const databaseRequiredTabId = ref(null); +const sqlExecutionDangerStore = useSqlExecutionDangerStore(); +const productionSafetyStore = useProductionSafetyStore(); function promptActiveDatabaseSelection() { const tab = activeTab.value; @@ -369,6 +378,8 @@ const { sqlParameterDatabaseType, sqlParameterEnabledSyntaxes, onSqlParametersConfirm, + prepareMultiExecute, + executeTargetSql, explainMode, } = useSqlExecution({ activeTab, @@ -378,6 +389,7 @@ const { activeOutputView, blockDangerousRedisCommands, onMissingDatabase: promptActiveDatabaseSelection, + requestDangerConfirmation: (request) => sqlExecutionDangerStore.requestConfirmation(request), }); function requestActiveEditorExecute() { @@ -390,6 +402,72 @@ function requestActiveEditorExecuteInNewResultTab() { void tryExecuteInNewResultTab(); } +const multiExecuteDatabaseType = ref(); +const multiExecuteInitialTargets = ref>([]); +const multiExecuteLaunchId = ref(0); +// Launch-time input only. MultiDbExecuteDialog copies this into its immutable +// batch context before the first target starts; execution never reads this ref. +const multiExecuteSourceOffset = ref(); + +function multiExecuteTargetLabel(target: { connectionId: string; catalog?: string; database: string; schema?: string }): string { + const connection = connectionStore.getConfig(target.connectionId); + return [connection?.name || target.connectionId, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / "); +} + +async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number }; isCancellationRequested: () => boolean }) { + const tab = queryStore.tabs.find((candidate) => candidate.id === input.sourceTabId); + const connection = connectionStore.getConfig(input.target.connectionId); + if (!tab || !connection) return { status: "failed" as const, errorMessage: t("multiDbExecute.targetMissingConnection") }; + return executeTargetSql({ + tab, + connection, + sql: input.sql, + executionTarget: input.target, + resultRun: { + batchId: input.scopeId, + title: multiExecuteTargetLabel(input.target), + target: input.target, + }, + sourceOffset: input.context.sourceOffset, + blockDangerousRedisCommands: blockDangerousRedisCommands.value, + targetLabel: multiExecuteTargetLabel(input.target), + scopeId: input.scopeId, + isCancellationRequested: input.isCancellationRequested, + targetContext: sqlExecutionTargetCapabilities(connection)?.provider.toExecutionContext(input.target, connection), + }); +} + +async function cancelMultiDbTarget(_sourceTabId: string, scopeId?: string): Promise { + if (scopeId) await queryStore.cancelMultiDbExecutionScope(scopeId); +} + +function cancelPendingMultiDbTarget(scopeId: string): void { + sqlExecutionDangerStore.cancelScope(scopeId); + productionSafetyStore.cancelScope(scopeId); +} + +async function requestMultiDbExecute() { + const tab = activeTab.value; + if (!tab || !activeConnection.value || showMultiDbExecuteDialog.value) return; + const sourceTabId = tab.id; + const sourceConnection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value; + const sourceTarget = normalizeSqlExecutionTarget(sourceConnection, { + connectionId: tab.connectionId, + ...(tab.catalog ? { catalog: tab.catalog } : {}), + database: tab.database, + ...(tab.schema ? { schema: tab.schema } : {}), + }); + await prepareMultiExecute(async (sql, sourceOffset) => { + multiExecuteSql.value = sql; + multiExecuteSourceOffset.value = sourceOffset; + multiExecuteLaunchId.value += 1; + multiExecuteSourceTabId.value = sourceTabId; + multiExecuteDatabaseType.value = effectiveDatabaseTypeForConnection(sourceConnection); + multiExecuteInitialTargets.value = [sourceTarget]; + showMultiDbExecuteDialog.value = true; + }); +} + const dialogs = useDialogSources(); const { getDatabaseOptions } = useDatabaseOptions(); const { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, openObjectBrowserTableTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs); @@ -2494,6 +2572,7 @@ onUnmounted(() => { @rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)" @dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)" @execute="requestActiveEditorExecute()" + @multi-execute="requestMultiDbExecute()" @cancel="cancelActiveExecution()" @explain="tryExplain()" @format-sql="formatActiveSql" @@ -2685,6 +2764,18 @@ onUnmounted(() => { @open-database-search-target="openDatabaseSearchTarget" @open-diagram-target="openDiagramTarget" /> + +import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { AlertTriangle, Check, CheckSquare, ChevronDown, ChevronRight, FolderOpen, Layers, Loader2, MinusSquare, Pencil, Play, RefreshCw, Search, Settings2, Square, Trash2, X } from "@lucide/vue"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import { useToast } from "@/composables/useToast"; +import { useMultiDbExecution, type MultiDbExecutionAdapter, type MultiDbExecutionMode } from "@/composables/useMultiDbExecution"; +import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker"; +import { useMultiDbTargetSelection, type MultiDbTargetCatalogOption, type SqlExecutionTargetValidationReason } from "@/composables/useMultiDbTargetSelection"; +import { useSqlExecutionTargetGroupStore } from "@/stores/sqlExecutionTargetGroupStore"; +import { dedupeMultiDbExecutionTargets, multiDbExecutionTargetKey, type MultiDbExecutionTarget, type MultiDbExecutionItemStatus, type SqlExecutionTargetGroup, type SqlExecutionTargetValidation } from "@/types/sqlExecution"; +import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { targetAllowsEmptyDatabase, targetIsSingleDatabase, targetSupportsCatalog, targetSupportsSchema, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities"; + +const open = defineModel("open", { default: false }); + +const props = defineProps<{ + sql: string; + sourceTabId: string; + databaseType?: DatabaseType; + initialTargets: MultiDbExecutionTarget[]; + launchId: number; + executeTarget: MultiDbExecutionAdapter["executeTarget"]; + cancelTarget?: MultiDbExecutionAdapter["cancelTarget"]; + cancelPending?: MultiDbExecutionAdapter["cancelPending"]; + sourceOffset?: number; +}>(); + +const { t } = useI18n(); +const { toast } = useToast(); +const { addMultiDbExecutionTask, updateMultiDbExecutionTask, registerTaskCancelHandler, unregisterTaskCancelHandler } = useExportTracker(); +const targetGroupStore = useSqlExecutionTargetGroupStore(); +const targetSelection = useMultiDbTargetSelection(computed(() => props.databaseType)); +const compatibleConnections = targetSelection.compatibleConnections; + +const selectedTargets = ref([]); +const validationResults = ref([]); +const searchText = ref(""); +const selectedGroupId = ref(); +const manageGroups = ref(false); +const expandedConnections = ref>(new Set()); +const expandedCatalogs = ref>(new Set()); +const expandedDatabases = ref>(new Set()); +const ignoreSelectionChange = ref(false); +const manualSelectionTouched = ref(false); +const validating = ref(false); +const groupNameDialogOpen = ref(false); +const groupName = ref(""); +const groupNameMode = ref<"create" | "update" | "clone" | "rename">("create"); +const editingGroupId = ref(); +const pendingAfterSave = ref<(() => void) | undefined>(); +const deleteGroupId = ref(); +const pendingGroupId = ref(); +const pendingClose = ref(false); +const unsavedPromptOpen = ref(false); +const executionStarted = ref(false); +const executionMode = ref("serial"); +const currentTime = ref(Date.now()); +let elapsedTimer: ReturnType | undefined; +let validationRun = 0; +let trackedBatchId: string | undefined; +let initializedLaunchId = -1; + +const execution = useMultiDbExecution( + { + validateTarget: async (target) => { + const result = await targetSelection.validateTarget(target); + return { + valid: result.state === "valid", + ...(result.state !== "valid" ? { errorMessage: validationReasonLabel(result.reason) } : {}), + }; + }, + executeTarget: props.executeTarget, + cancelTarget: props.cancelTarget, + cancelPending: props.cancelPending, + }, + { sourceTabId: computed(() => props.sourceTabId) }, +); + +const currentGroup = computed(() => (selectedGroupId.value ? targetGroupStore.getGroup(selectedGroupId.value) : undefined)); +const compatibleGroups = computed(() => (props.databaseType ? targetGroupStore.getGroupsByDatabaseType(props.databaseType) : [])); +const allGroups = computed(() => targetGroupStore.getGroupsByDatabaseType()); +const isExecuting = computed(() => execution.isRunning.value); +const batch = computed(() => execution.batch.value); +const selectedTargetKeys = computed(() => new Set(selectedTargets.value.map(multiDbExecutionTargetKey))); +const validationByKey = computed(() => new Map(validationResults.value.map((entry) => [multiDbExecutionTargetKey(entry.target), entry]))); +const invalidTargetCount = computed(() => validationResults.value.filter((entry) => entry.state === "invalid").length); +const needsRecheckCount = computed(() => validationResults.value.filter((entry) => entry.state === "needsRecheck").length); +const allTargetsValid = computed(() => selectedTargets.value.length > 0 && selectedTargets.value.every((target) => validationByKey.value.get(multiDbExecutionTargetKey(target))?.state === "valid")); +const hasUnsavedChanges = computed(() => { + if (selectedGroupId.value && currentGroup.value) { + return !sameTargetList(selectedTargets.value, currentGroup.value.targets); + } + return manualSelectionTouched.value; +}); +const canExecute = computed(() => !isExecuting.value && !!props.sql.trim() && allTargetsValid.value && invalidTargetCount.value === 0 && needsRecheckCount.value === 0); +const canSaveGroup = computed(() => !isExecuting.value && !!props.databaseType && selectedTargets.value.length > 0 && allTargetsValid.value); +const progressCompleted = computed(() => (batch.value?.items ?? []).filter((item) => !["pending", "running"].includes(item.status)).length); +const progressCounts = computed(() => { + const items = batch.value?.items ?? []; + return { + success: items.filter((item) => item.status === "success").length, + failed: items.filter((item) => item.status === "failed").length, + skipped: items.filter((item) => item.status === "skipped").length, + notExecuted: items.filter((item) => item.status === "not_executed" || item.status === "cancelled").length, + }; +}); +const elapsedMs = computed(() => { + const current = batch.value; + if (!current) return 0; + return current.durationMs ?? Math.max(0, (current.completedAt ?? currentTime.value) - current.startedAt); +}); + +function syncBackgroundTask(current: NonNullable): void { + const items = current.items; + const completed = items.filter((item) => !["pending", "running"].includes(item.status)).length; + const failureCount = items.filter((item) => item.status === "failed").length; + const status = current.status === "cancelled" ? "cancelled" : current.status === "completed" ? "completed" : "running"; + updateMultiDbExecutionTask(current.id, { + sourceTabId: current.sourceTabId, + total: items.length, + completed, + successCount: items.filter((item) => item.status === "success").length, + failureCount, + skippedCount: items.filter((item) => item.status === "skipped").length, + notExecutedCount: items.filter((item) => item.status === "not_executed" || item.status === "cancelled").length, + status, + startedAt: current.startedAt, + finishedAt: current.completedAt, + elapsedMs: current.durationMs, + currentTarget: current.items.find((item) => item.status === "running")?.target, + errorMessage: failureCount > 0 ? current.items.find((item) => item.status === "failed")?.errorMessage : undefined, + }); +} + +function executionModeLabel(): string { + return executionMode.value === "serial" ? t("multiDbExecute.serial") : t("multiDbExecute.parallel"); +} + +function toggleExecutionMode(): void { + if (isExecuting.value) return; + executionMode.value = executionMode.value === "serial" ? "parallel" : "serial"; +} + +watch( + batch, + (current) => { + if (!current) return; + if (trackedBatchId !== current.id) { + trackedBatchId = current.id; + addMultiDbExecutionTask(current.id, t("multiDbExecute.title"), current.sourceTabId, () => { + open.value = true; + }); + registerTaskCancelHandler(current.id, () => execution.cancel()); + } + syncBackgroundTask(current); + if (current.status === "completed" || current.status === "cancelled") unregisterTaskCancelHandler(current.id); + }, + { deep: true, flush: "sync" }, +); + +onMounted(() => { + elapsedTimer = setInterval(() => { + currentTime.value = Date.now(); + }, 1000); +}); + +onBeforeUnmount(() => { + if (elapsedTimer) clearInterval(elapsedTimer); + if (trackedBatchId) unregisterTaskCancelHandler(trackedBatchId); +}); + +const searchQuery = computed(() => searchText.value.trim().toLocaleLowerCase()); + +function sameTargetList(left: readonly MultiDbExecutionTarget[], right: readonly MultiDbExecutionTarget[]): boolean { + if (left.length !== right.length) return false; + return left.every((target, index) => multiDbExecutionTargetKey(target) === multiDbExecutionTargetKey(right[index])); +} + +function targetKey(target: MultiDbExecutionTarget): string { + return multiDbExecutionTargetKey(target); +} + +function targetLabel(target: MultiDbExecutionTarget): string { + const connection = targetSelection.connection(target.connectionId); + const connectionName = connection?.name || target.connectionId; + return [connectionName, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / "); +} + +function connectionLabel(connection: ConnectionConfig): string { + return connection.name || connection.id; +} + +function catalogLabel(catalog: MultiDbTargetCatalogOption): string { + return catalog.isInternal ? `${catalog.name} (${t("multiDbExecute.internalCatalog")})` : catalog.name; +} + +function databaseLabel(connection: ConnectionConfig, database: string): string { + if (targetUsesConnectionOnlyScope(connection)) return t("multiDbExecute.connectionOnlyTarget"); + if (database) return database; + if (targetIsSingleDatabase(connection)) return t("multiDbExecute.defaultDatabase"); + return t("multiDbExecute.noDatabase"); +} + +function statusLabel(status: MultiDbExecutionItemStatus): string { + const labels: Record = { + pending: t("multiDbExecute.pending"), + running: t("multiDbExecute.running"), + success: t("multiDbExecute.success"), + failed: t("multiDbExecute.failed"), + skipped: t("multiDbExecute.skipped"), + cancelled: t("multiDbExecute.cancelled"), + not_executed: t("multiDbExecute.notExecuted"), + }; + return labels[status]; +} + +function validationReasonLabel(reason?: string): string { + if (!reason) return t("multiDbExecute.targetValidationFailed"); + const key = reason as SqlExecutionTargetValidationReason; + return t(`multiDbExecute.${key}`); +} + +function isSelected(target: MultiDbExecutionTarget): boolean { + return selectedTargetKeys.value.has(targetKey(target)); +} + +function validationFor(target: MultiDbExecutionTarget): SqlExecutionTargetValidation | undefined { + return validationByKey.value.get(targetKey(target)); +} + +function resolveTargetDatabaseType(target: MultiDbExecutionTarget): DatabaseType | undefined { + return effectiveDatabaseTypeForConnection(targetSelection.connection(target.connectionId)); +} + +function selectionState(targets: MultiDbExecutionTarget[]): "none" | "partial" | "all" { + if (targets.length === 0) return "none"; + const selected = targets.filter(isSelected).length; + if (selected === 0) return "none"; + if (selected === targets.length) return "all"; + return "partial"; +} + +function toggleTargets(targets: MultiDbExecutionTarget[]): void { + if (isExecuting.value || targets.length === 0) return; + const state = selectionState(targets); + const keys = new Set(targets.map(targetKey)); + ignoreSelectionChange.value = true; + if (state === "all") { + selectedTargets.value = selectedTargets.value.filter((target) => !keys.has(targetKey(target))); + } else { + const existing = new Set(selectedTargets.value.map(targetKey)); + selectedTargets.value = [...selectedTargets.value, ...targets.filter((target) => !existing.has(targetKey(target)))]; + } + ignoreSelectionChange.value = false; + manualSelectionTouched.value = true; + markTargetsNeedsRecheck(); + void validateSelectedTargets(); +} + +function toggleTarget(target: MultiDbExecutionTarget): void { + toggleTargets([target]); +} + +function markTargetsNeedsRecheck(): void { + const existing = validationByKey.value; + validationResults.value = selectedTargets.value.map((target) => existing.get(targetKey(target)) ?? { target, state: "needsRecheck" }); +} + +async function validateSelectedTargets(): Promise { + const run = ++validationRun; + if (!selectedTargets.value.length) { + validationResults.value = []; + return false; + } + validating.value = true; + try { + const results = await targetSelection.validateTargets(selectedTargets.value); + if (run !== validationRun) return false; + validationResults.value = results; + return validationResults.value.every((entry) => entry.state === "valid"); + } finally { + if (run === validationRun) validating.value = false; + } +} + +function connectionExpanded(connectionId: string): boolean { + return expandedConnections.value.has(connectionId); +} + +function catalogKey(connectionId: string, catalog?: string): string { + return `${connectionId}:catalog:${catalog || "__internal__"}`; +} + +function databaseKey(connectionId: string, catalog: string | undefined, database: string): string { + return `${connectionId}:database:${catalog || "__internal__"}:${database || "__default__"}`; +} + +function databaseTarget(connectionId: string, catalog: string | undefined, database: string, schema?: string): MultiDbExecutionTarget { + return { + connectionId, + ...(catalog ? { catalog } : {}), + database, + ...(schema ? { schema } : {}), + }; +} + +function databaseTargets(connectionId: string, catalog: string | undefined, database: string): MultiDbExecutionTarget[] { + const connection = targetSelection.connection(connectionId); + if (!connection) return []; + const base = databaseTarget(connectionId, catalog, database); + if (!targetSupportsSchema(connection)) return [base]; + const schemas = targetSelection.schemaNamesForTarget(base); + return schemas.length > 0 ? schemas.map((schema) => databaseTarget(connectionId, catalog, database, schema)) : [base]; +} + +function catalogTargets(connectionId: string, catalog: MultiDbTargetCatalogOption): MultiDbExecutionTarget[] { + const databases = targetSelection.databaseNamesForConnection(connectionId, catalog.targetCatalog); + return databases.flatMap((database) => databaseTargets(connectionId, catalog.targetCatalog, database)); +} + +function connectionTargets(connection: ConnectionConfig): MultiDbExecutionTarget[] { + if (targetUsesConnectionOnlyScope(connection)) return [{ connectionId: connection.id, database: "" }]; + if (targetSupportsCatalog(connection)) { + return targetSelection.catalogsForConnection(connection.id).flatMap((catalog) => catalogTargets(connection.id, catalog)); + } + const databases = targetSelection.databaseNamesForConnection(connection.id); + if (databases.length === 0 && targetAllowsEmptyDatabase(connection)) return [{ connectionId: connection.id, database: "" }]; + return databases.flatMap((database) => databaseTargets(connection.id, undefined, database)); +} + +function queryMatches(...values: Array): boolean { + if (!searchQuery.value) return true; + return values.some((value) => value?.toLocaleLowerCase().includes(searchQuery.value)); +} + +function schemaVisible(connection: ConnectionConfig, catalog: string | undefined, database: string, schema: string): boolean { + return queryMatches(connectionLabel(connection), catalog, database, schema); +} + +function databaseVisible(connection: ConnectionConfig, catalog: string | undefined, database: string): boolean { + if (queryMatches(connectionLabel(connection), catalog, database)) return true; + if (!targetSupportsSchema(connection)) return false; + const base = databaseTarget(connection.id, catalog, database); + return targetSelection.schemaNamesForTarget(base).some((schema) => schemaVisible(connection, catalog, database, schema)); +} + +function catalogVisible(connection: ConnectionConfig, catalog: MultiDbTargetCatalogOption): boolean { + if (queryMatches(connectionLabel(connection), catalog.name)) return true; + return targetSelection.databaseNamesForConnection(connection.id, catalog.targetCatalog).some((database) => databaseVisible(connection, catalog.targetCatalog, database)); +} + +function connectionVisible(connection: ConnectionConfig): boolean { + // Connections are the lazy-loading root of the selector. Do not hide an + // unexpanded compatible connection merely because its database metadata has + // not been requested yet; users must be able to expand it first. + if (!searchQuery.value) return true; + if (!expandedConnections.value.has(connection.id)) return queryMatches(connectionLabel(connection), connection.id); + if (queryMatches(connectionLabel(connection), connection.id)) return true; + if (targetUsesConnectionOnlyScope(connection)) return false; + if (targetSupportsCatalog(connection)) return targetSelection.catalogsForConnection(connection.id).some((catalog) => catalogVisible(connection, catalog)); + return targetSelection.databaseNamesForConnection(connection.id).some((database) => databaseVisible(connection, undefined, database)); +} + +async function toggleConnection(connection: ConnectionConfig): Promise { + const next = new Set(expandedConnections.value); + if (next.has(connection.id)) { + next.delete(connection.id); + } else { + next.add(connection.id); + await targetSelection.loadConnection(connection.id).catch(() => {}); + } + expandedConnections.value = next; +} + +async function toggleConnectionSelection(connection: ConnectionConfig): Promise { + if (isExecuting.value) return; + await targetSelection.loadConnection(connection.id).catch(() => {}); + if (targetUsesConnectionOnlyScope(connection)) { + toggleTargets(connectionTargets(connection)); + return; + } + if (targetSupportsCatalog(connection)) { + await targetSelection.loadCatalogs(connection.id).catch(() => {}); + for (const catalog of targetSelection.catalogsForConnection(connection.id)) { + await targetSelection.loadDatabase(connection.id, catalog.targetCatalog).catch(() => {}); + if (targetSupportsSchema(connection)) { + for (const database of targetSelection.databaseNamesForConnection(connection.id, catalog.targetCatalog)) { + await targetSelection.loadSchemas(databaseTarget(connection.id, catalog.targetCatalog, database)).catch(() => {}); + } + } + } + } else { + const databases = targetSelection.databaseNamesForConnection(connection.id); + if (targetSupportsSchema(connection)) { + for (const database of databases) { + await targetSelection.loadSchemas(databaseTarget(connection.id, undefined, database)).catch(() => {}); + } + } + } + toggleTargets(connectionTargets(connection)); +} + +async function toggleCatalog(connectionId: string, catalog: MultiDbTargetCatalogOption): Promise { + const key = catalogKey(connectionId, catalog.targetCatalog); + const next = new Set(expandedCatalogs.value); + if (next.has(key)) next.delete(key); + else { + next.add(key); + await targetSelection.loadDatabase(connectionId, catalog.targetCatalog).catch(() => {}); + } + expandedCatalogs.value = next; +} + +async function toggleCatalogSelection(connectionId: string, catalog: MultiDbTargetCatalogOption): Promise { + if (isExecuting.value) return; + await targetSelection.loadDatabase(connectionId, catalog.targetCatalog).catch(() => {}); + const connection = targetSelection.connection(connectionId); + if (connection && targetSupportsSchema(connection)) { + for (const database of targetSelection.databaseNamesForConnection(connectionId, catalog.targetCatalog)) { + await targetSelection.loadSchemas(databaseTarget(connectionId, catalog.targetCatalog, database)).catch(() => {}); + } + } + toggleTargets(catalogTargets(connectionId, catalog)); +} + +async function toggleDatabase(connectionId: string, catalog: string | undefined, database: string): Promise { + const key = databaseKey(connectionId, catalog, database); + const next = new Set(expandedDatabases.value); + const connection = targetSelection.connection(connectionId); + if (next.has(key)) next.delete(key); + else { + next.add(key); + if (connection && targetSupportsSchema(connection)) await targetSelection.loadSchemas(databaseTarget(connectionId, catalog, database)).catch(() => {}); + } + expandedDatabases.value = next; +} + +async function toggleDatabaseSelection(connectionId: string, catalog: string | undefined, database: string): Promise { + if (isExecuting.value) return; + const connection = targetSelection.connection(connectionId); + if (connection && targetSupportsSchema(connection)) { + await targetSelection.loadSchemas(databaseTarget(connectionId, catalog, database)).catch(() => {}); + } + toggleTargets(databaseTargets(connectionId, catalog, database)); +} + +async function retryConnection(connectionId: string): Promise { + await targetSelection.loadConnection(connectionId).catch(() => {}); +} + +async function retryDatabase(connectionId: string, catalog?: string): Promise { + await targetSelection.loadDatabase(connectionId, catalog).catch(() => {}); +} + +async function retryDatabaseSchemas(connectionId: string, catalog: string | undefined, database: string): Promise { + await targetSelection.loadSchemas(databaseTarget(connectionId, catalog, database)).catch(() => {}); +} + +function setTargets(targets: MultiDbExecutionTarget[], options: { manual?: boolean } = {}): void { + ignoreSelectionChange.value = true; + selectedTargets.value = dedupeMultiDbExecutionTargets(targets); + ignoreSelectionChange.value = false; + manualSelectionTouched.value = options.manual === true; + validationResults.value = selectedTargets.value.map((target) => ({ target, state: "needsRecheck" })); +} + +function applyGroup(group: SqlExecutionTargetGroup): void { + selectedGroupId.value = group.id; + setTargets(group.targets); + manageGroups.value = false; + void validateSelectedTargets(); +} + +function requestGroupSelection(id: string): void { + if (!id) { + if (hasUnsavedChanges.value) { + pendingGroupId.value = null; + pendingClose.value = false; + unsavedPromptOpen.value = true; + return; + } + selectedGroupId.value = undefined; + setTargets([], { manual: true }); + return; + } + if (hasUnsavedChanges.value) { + pendingGroupId.value = id; + pendingClose.value = false; + unsavedPromptOpen.value = true; + return; + } + const group = compatibleGroups.value.find((candidate) => candidate.id === id); + if (group) applyGroup(group); +} + +function discardAndContinue(): void { + const nextGroupId = pendingGroupId.value; + const shouldClose = pendingClose.value; + pendingGroupId.value = undefined; + pendingClose.value = false; + unsavedPromptOpen.value = false; + if (shouldClose) { + open.value = false; + return; + } + if (nextGroupId === null) { + selectedGroupId.value = undefined; + setTargets([], { manual: false }); + return; + } + if (nextGroupId) { + const group = compatibleGroups.value.find((candidate) => candidate.id === nextGroupId); + if (group) applyGroup(group); + } +} + +function savePendingChanges(): void { + const continuation = () => { + const nextGroupId = pendingGroupId.value; + const shouldClose = pendingClose.value; + pendingGroupId.value = undefined; + pendingClose.value = false; + if (shouldClose) { + open.value = false; + return; + } + if (nextGroupId === null) { + selectedGroupId.value = undefined; + setTargets([], { manual: false }); + return; + } + if (nextGroupId) { + const group = compatibleGroups.value.find((candidate) => candidate.id === nextGroupId); + if (group) applyGroup(group); + } + }; + unsavedPromptOpen.value = false; + pendingAfterSave.value = continuation; + openGroupNameDialog(selectedGroupId.value ? "update" : "create"); +} + +function cancelUnsavedPrompt(): void { + pendingGroupId.value = undefined; + pendingClose.value = false; + unsavedPromptOpen.value = false; +} + +function requestClose(): void { + if (isExecuting.value) { + open.value = false; + return; + } + if (hasUnsavedChanges.value) { + pendingClose.value = true; + pendingGroupId.value = undefined; + unsavedPromptOpen.value = true; + return; + } + open.value = false; +} + +function openGroupNameDialog(mode: "create" | "update" | "clone" | "rename", group?: SqlExecutionTargetGroup): void { + if ((mode === "create" || mode === "update" || mode === "clone") && !canSaveGroup.value) return; + groupNameMode.value = mode; + editingGroupId.value = group?.id; + groupName.value = mode === "rename" ? (group?.name ?? "") : mode === "update" ? (currentGroup.value?.name ?? "") : ""; + groupNameDialogOpen.value = true; +} + +function groupNameError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("不能为空")) return t("multiDbExecute.groupNameRequired"); + if (message.includes("已存在")) return t("multiDbExecute.groupNameExists"); + return t("multiDbExecute.groupSaveFailed", { message }); +} + +function confirmGroupName(): void { + const name = groupName.value.trim(); + if (!name) return; + try { + if (groupNameMode.value === "rename") { + const group = editingGroupId.value ? targetGroupStore.getGroup(editingGroupId.value) : undefined; + if (!group) return; + targetGroupStore.updateGroup(group.id, { name, resolveDatabaseType: resolveTargetDatabaseType }); + toast(t("multiDbExecute.groupSaved")); + } else if (groupNameMode.value === "update") { + const group = currentGroup.value; + if (!group) return; + targetGroupStore.updateGroup(group.id, { targets: selectedTargets.value, resolveDatabaseType: resolveTargetDatabaseType }); + manualSelectionTouched.value = false; + void validateSelectedTargets(); + toast(t("multiDbExecute.groupSaved")); + } else { + const created = targetGroupStore.createGroup({ name, databaseType: props.databaseType!, targets: selectedTargets.value, resolveDatabaseType: resolveTargetDatabaseType }); + selectedGroupId.value = created.id; + manualSelectionTouched.value = false; + toast(t("multiDbExecute.groupSaved")); + } + groupNameDialogOpen.value = false; + const afterSave = pendingAfterSave.value; + pendingAfterSave.value = undefined; + afterSave?.(); + } catch (error) { + toast(groupNameError(error), 5000); + } +} + +function cancelGroupNameDialog(): void { + groupNameDialogOpen.value = false; + pendingAfterSave.value = undefined; +} + +function confirmDeleteGroup(): void { + const id = deleteGroupId.value; + if (!id) return; + targetGroupStore.deleteGroup(id); + if (selectedGroupId.value === id) { + selectedGroupId.value = undefined; + setTargets([], { manual: false }); + } + deleteGroupId.value = undefined; +} + +function groupNameForId(id: string): string { + return targetGroupStore.getGroup(id)?.name ?? id; +} + +function openForNewBatch(): void { + execution.reset(); + executionStarted.value = false; + executionMode.value = "serial"; + trackedBatchId = undefined; + manageGroups.value = false; + selectedGroupId.value = undefined; + searchText.value = ""; + expandedCatalogs.value = new Set(); + expandedDatabases.value = new Set(); + const initial = dedupeMultiDbExecutionTargets(props.initialTargets); + setTargets(initial); + expandedConnections.value = new Set(initial.map((target) => target.connectionId)); + for (const connectionId of expandedConnections.value) void targetSelection.loadConnection(connectionId).catch(() => {}); + void validateSelectedTargets(); +} + +async function startExecution(): Promise { + // Metadata can change while the dialog is open (connection removal, + // permission changes, or a database/schema refresh). Always perform the + // execution-time validation required by the target-group contract instead + // of relying solely on the last background validation result. + const valid = await validateSelectedTargets(); + if (!valid || !canExecute.value) return; + if (selectedGroupId.value) targetGroupStore.markGroupUsed(selectedGroupId.value); + executionStarted.value = true; + void execution.start(props.sql, selectedTargets.value, { sourceOffset: props.sourceOffset }, executionMode.value); +} + +function removeSelected(target: MultiDbExecutionTarget): void { + if (isExecuting.value) return; + selectedTargets.value = selectedTargets.value.filter((candidate) => targetKey(candidate) !== targetKey(target)); + manualSelectionTouched.value = true; + markTargetsNeedsRecheck(); + void validateSelectedTargets(); +} + +function resetSelection(): void { + if (isExecuting.value) return; + selectedGroupId.value = undefined; + setTargets([], { manual: true }); +} + +function statusClass(status: MultiDbExecutionItemStatus): string { + if (status === "success") return "text-emerald-600 dark:text-emerald-300"; + if (status === "failed") return "text-destructive"; + if (status === "running") return "text-primary"; + if (status === "skipped" || status === "cancelled") return "text-amber-600 dark:text-amber-300"; + return "text-muted-foreground"; +} + +watch( + selectedTargets, + () => { + if (ignoreSelectionChange.value) return; + markTargetsNeedsRecheck(); + }, + { deep: true }, +); + +watch( + () => [open.value, props.launchId] as const, + ([value, launchId]) => { + if (value && launchId !== initializedLaunchId) { + initializedLaunchId = launchId; + openForNewBatch(); + } + }, + { immediate: true }, +); + +watch( + () => props.databaseType, + (value) => { + if (!value || !open.value) return; + void validateSelectedTargets(); + }, +); + + + diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue index cea51ee36d..8bae188fc2 100644 --- a/apps/desktop/src/components/export/ExportProgressPopover.vue +++ b/apps/desktop/src/components/export/ExportProgressPopover.vue @@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight, FolderOpen } from "@lucide/vue"; +import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen } from "@lucide/vue"; import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { translateBackendError } from "@/i18n/backend-errors"; import { useToast } from "@/composables/useToast"; @@ -69,6 +69,11 @@ const progressValue = (task: ExportTask) => { if (task.status === "Done") return 100; return Math.min(95, Math.round(((task.tableIndex ?? 0) / task.totalTables) * 100)); } + if (task.kind === "multi-db-execution") { + if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return 100; + if (!task.multiDbTotal) return 0; + return Math.min(95, Math.round(((task.multiDbCompleted ?? 0) / task.multiDbTotal) * 100)); + } return progressPercent(task.totalRows, task.rowsExported); }; @@ -92,6 +97,7 @@ const taskTitle = (task: ExportTask) => { } if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName }); if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName }); + if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName }); return taskFileName(task) || `${task.tableName}.${task.format}`; }; @@ -124,6 +130,14 @@ const rowsText = (task: ExportTask) => { const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) }); return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`; } + if (task.kind === "multi-db-execution") { + return t("exportProgress.multiDbTargets", { + completed: (task.multiDbCompleted ?? 0).toLocaleString(), + total: (task.multiDbTotal ?? 0).toLocaleString(), + success: (task.multiDbSuccessCount ?? 0).toLocaleString(), + failed: (task.multiDbFailureCount ?? 0).toLocaleString(), + }); + } if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`; return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`; }; @@ -151,6 +165,7 @@ const statusIcon = (task: ExportTask) => { if (task.kind === "database-export") return DatabaseBackup; if (task.kind === "sql-file") return FileCode2; if (task.kind === "data-transfer") return ArrowRightLeft; + if (task.kind === "multi-db-execution") return Layers3; } switch (task.status) { case "Running": @@ -215,6 +230,10 @@ function toggleFailureDetails(exportId: string) { function failureDetailCount(task: ExportTask) { return (task.transferFailures?.length ?? 0) + (task.transferFailuresOmitted ?? 0); } + +function openTask(task: ExportTask): void { + task.onOpen?.(); +}