feat(execution): multi-database SQL execution with scoped targets

This commit is contained in:
miracle
2026-08-09 14:24:10 +08:00
committed by GitHub
parent 328a2aa107
commit 71ccb3d97d
46 changed files with 4421 additions and 182 deletions
+92 -1
View File
@@ -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<string | null>(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<DatabaseType>();
const multiExecuteInitialTargets = ref<Array<{ connectionId: string; catalog?: string; database: string; schema?: string }>>([]);
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<number>();
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<void> {
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"
/>
<MultiDbExecuteDialog
v-model:open="showMultiDbExecuteDialog"
:sql="multiExecuteSql"
:source-tab-id="multiExecuteSourceTabId"
:database-type="multiExecuteDatabaseType"
:initial-targets="multiExecuteInitialTargets"
:launch-id="multiExecuteLaunchId"
:execute-target="executeMultiDbTarget"
:cancel-target="cancelMultiDbTarget"
:cancel-pending="cancelPendingMultiDbTarget"
:source-offset="multiExecuteSourceOffset"
/>
<UpdateDialog
v-if="showUpdateDialog"
v-model:open="showUpdateDialog"
File diff suppressed because it is too large Load Diff
@@ -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?.();
}
</script>
<template>
@@ -245,7 +264,7 @@ function failureDetailCount(task: ExportTask) {
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables)"
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables) || (task.kind === 'multi-db-execution' && task.multiDbTotal)"
class="h-full bg-primary rounded-full transition-[width] duration-300"
:style="{ width: `${progressValue(task)}%` }"
/>
@@ -286,6 +305,9 @@ function failureDetailCount(task: ExportTask) {
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
<div class="flex shrink-0 pt-4">
<button v-if="task.kind === 'multi-db-execution' && task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
@@ -20,6 +20,7 @@ const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/s
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
@@ -89,6 +90,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
const request = productionSafetyStore.pending;
@@ -99,6 +101,15 @@ const productionConfirmationDetails = computed(() => {
source: request.source || "-",
});
});
const multiDbDangerDetails = computed(() => {
const request = sqlExecutionDangerStore.pending;
if (!request) return "";
return [request.targetLabel || request.connectionName, request.database].filter(Boolean).join("\n");
});
const multiDbDangerMessage = computed(() => {
const request = sqlExecutionDangerStore.pending;
return request?.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message");
});
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@@ -165,6 +176,19 @@ watch(
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<DangerConfirmDialog
v-if="sqlExecutionDangerStore.pending"
:open="true"
:title="t('multiDbExecute.dangerTitle')"
:message="multiDbDangerMessage"
:details-text="multiDbDangerDetails"
:sql="sqlExecutionDangerStore.pending.sql"
:confirm-label="t('multiDbExecute.dangerConfirm')"
:show-suppress-toggle="false"
:close-on-confirm="false"
@update:open="(open) => !open && sqlExecutionDangerStore.cancel()"
@confirm="sqlExecutionDangerStore.confirm()"
/>
<SqlParameterDialog
v-if="showSqlParameterDialog"
:open="showSqlParameterDialog"
@@ -245,7 +245,12 @@ const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection));
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
const activeResultConnection = computed(() => (activeResultExecutionTarget.value ? connectionStore.getConfig(activeResultExecutionTarget.value.connectionId) : props.activeConnection));
const activeResultConnectionId = computed(() => activeResultExecutionTarget.value?.connectionId ?? props.activeTab.connectionId);
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
@@ -473,7 +478,7 @@ function mongoQueryResultDocumentId(rowIdx: number, fallback: unknown): unknown
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
const tab = props.activeTab;
const target = tab.mongoEditTarget;
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !tab.connectionId || !tab.database || !tab.result) return undefined;
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !activeResultConnectionId.value || !activeResultDatabase.value || !tab.result) return undefined;
if (!tab.result.columns.includes(target.idColumn)) return undefined;
const save: CustomSaveHandler["save"] = async (changes: MongoQueryGridChanges) => {
@@ -488,7 +493,7 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
if (id === null || id === undefined || String(id).trim() === "") continue;
const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns, tab.result?.mongo_documents?.[rowIdx]);
if (Object.keys(updateDoc).length === 0) continue;
await api.mongoUpdateDocument(tab.connectionId, tab.database, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc));
await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc));
}
};
@@ -1070,7 +1075,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
>
{{ t("tabs.runN", { n: run.sequence }) }}
{{ run.title || t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
@@ -1089,7 +1094,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="min-w-0 truncate">{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<span class="min-w-0 truncate">{{ activeResultRunItem ? activeResultRunItem.title || t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
</Button>
</DropdownMenuTrigger>
@@ -1097,7 +1102,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<span class="min-w-0 flex-1 truncate">{{ run.title || t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1494,9 +1499,9 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
context="results"
:auto-transpose-single-row="settingsStore.editorSettings.dataGridAutoTransposeSingleRow"
:database-type="activeEffectiveDatabaseType"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:connection-id="activeResultConnectionId"
:database="activeResultDatabase"
:schema="activeResultSchema"
:table-meta="activeTab.tableMeta"
:table-info-tab="activeTab.tableInfoTab"
:page-offset="activeTab.resultPageOffset"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2 } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -16,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { useConnectionGroupLabel } from "@/composables/useConnectionGroupLabel";
import { isSingleDatabase, supportsClearableQuerySchema, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -46,6 +47,7 @@ const emit = defineEmits<{
openSql: [];
importResultArchive: [];
pasteSqlInCondition: [];
multiExecute: [];
changeConnection: [connectionId: string];
changeCatalog: [catalog: string | undefined, database: string];
changeDatabase: [database: string];
@@ -146,6 +148,12 @@ const executeButtonClass = computed(() => {
});
const isTransactionActive = computed(() => !!props.txnSessionId);
const canMultiExecute = computed(() => {
if (!supportsQueryExecution(props.activeConnection?.db_type)) return false;
if (props.activeTab.isExecuting || props.activeTab.isExplaining || props.activeTab.isCancelling) return false;
if (props.autoCommit === false || isTransactionActive.value) return false;
return !!props.executableSql.trim();
});
const showSchemaSelector = computed(() => {
const connection = props.activeConnection;
@@ -399,6 +407,14 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.exPasteSqlInCondition") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-primary hover:bg-primary/10" :disabled="!canMultiExecute" :aria-label="t('toolbar.multiDbExecute')" @click="emit('multiExecute')">
<CirclePlay class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.multiDbExecute") }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
tabs: [] as QueryTab[],
setTableMeta: vi.fn(),
clearInvalidDataTabSort: vi.fn(),
activeResultExecutionTarget: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
@@ -45,6 +46,7 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
executeTabSql: mocks.executeTabSql,
activeResultExecutionTarget: mocks.activeResultExecutionTarget,
setExecuting: mocks.setExecuting,
updateSql: mocks.updateSql,
tabs: mocks.tabs,
@@ -120,6 +122,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0");
mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" });
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.activeResultExecutionTarget.mockReturnValue(undefined);
mocks.getColumns.mockResolvedValue([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]);
mocks.listIndexes.mockResolvedValue([]);
});
@@ -405,4 +408,38 @@ describe("useDataGridActions", () => {
}),
);
});
it("uses the active multi-database result target for pagination", async () => {
const tab = {
id: "tab-1",
connectionId: "source-1",
database: "source_db",
title: "Query",
sql: "SELECT * FROM users",
resultBaseSql: "SELECT * FROM users",
resultPageLimit: 100,
resultPageOffset: 0,
result: { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
mode: "query",
isDirty: false,
isExecuting: false,
isExplaining: false,
} as QueryTab;
const target = { connectionId: "target-2", database: "reporting", schema: "audit" };
mocks.activeResultExecutionTarget.mockReturnValue(target);
mocks.getConfig.mockImplementation((id: string) => ({ id, db_type: "postgres" }));
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(100, 100);
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
"SELECT * FROM users",
expect.objectContaining({
executionTarget: target,
targetContext: { scope: "database", database: "reporting", schema: "audit" },
pagination: { offset: 100, limit: 100, sessionId: undefined },
}),
);
});
});
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useMultiDbExecution } from "@/composables/useMultiDbExecution";
const targets = [
{ connectionId: "conn-1", database: "app" },
{ connectionId: "conn-2", database: "report" },
{ connectionId: "conn-3", database: "audit" },
] as const;
describe("useMultiDbExecution", () => {
it("executes serially and continues after a target failure", async () => {
const executionOrder: string[] = [];
const executor = useMultiDbExecution(
{
executeTarget: async ({ target }) => {
executionOrder.push(target.connectionId);
return target.connectionId === "conn-1" ? { status: "failed", errorMessage: "boom" } : { status: "success" };
},
},
{ sourceTabId: ref("source") },
);
const batch = await executor.start("ALTER TABLE users ADD COLUMN active BOOLEAN", targets);
expect(executionOrder).toEqual(["conn-1", "conn-2", "conn-3"]);
expect(batch?.items.map((item) => item.status)).toEqual(["failed", "success", "success"]);
expect(batch?.status).toBe("completed");
});
it("executes all targets concurrently in parallel mode", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started: string[] = [];
let running = 0;
let maxRunning = 0;
const executor = useMultiDbExecution(
{
executeTarget: async ({ target }) => {
started.push(target.connectionId);
running += 1;
maxRunning = Math.max(maxRunning, running);
await gate;
running -= 1;
return { status: "success" as const, durationMs: 12 };
},
},
{ sourceTabId: "source" },
);
const run = executor.start("UPDATE users SET active = TRUE", targets, {}, "parallel");
await Promise.resolve();
expect(started).toEqual(["conn-1", "conn-2", "conn-3"]);
expect(maxRunning).toBe(3);
release();
const batch = await run;
expect(batch?.mode).toBe("parallel");
expect(batch?.items.map((item) => item.status)).toEqual(["success", "success", "success"]);
expect(batch?.items.every((item) => item.durationMs === 12)).toBe(true);
expect(batch?.durationMs).toEqual(expect.any(Number));
});
it("takes a target snapshot before execution starts", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const executor = useMultiDbExecution(
{
executeTarget: async () => {
await gate;
return { status: "success" };
},
},
{ sourceTabId: "source" },
);
const mutableTargets = targets.map((target) => ({ ...target }));
const run = executor.start("SELECT 1", mutableTargets);
await Promise.resolve();
mutableTargets[0].database = "changed-after-confirmation";
expect(executor.batch.value?.items[0].target.database).toBe("app");
release();
await run;
});
it("keeps the source offset in the immutable batch context", async () => {
let receivedOffset: number | undefined;
const executor = useMultiDbExecution(
{
executeTarget: async ({ context }) => {
receivedOffset = context.sourceOffset;
return { status: "success" };
},
},
{ sourceTabId: "source" },
);
await executor.start("SELECT 1", [targets[0]], { sourceOffset: 17 });
expect(receivedOffset).toBe(17);
expect(Object.isFrozen(executor.batch.value?.context)).toBe(true);
});
it("cancels the current target and marks remaining targets as not executed", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const cancelTarget = vi.fn(async () => {
release();
});
const executor = useMultiDbExecution(
{
executeTarget: async () => {
await gate;
return { status: "cancelled" };
},
cancelTarget,
},
{ sourceTabId: "source" },
);
const run = executor.start("DROP TABLE users", targets);
await Promise.resolve();
await executor.cancel();
await run;
expect(cancelTarget).toHaveBeenCalledWith("source", expect.any(String));
expect(executor.batch.value?.items.map((item) => item.status)).toEqual(["cancelled", "not_executed", "not_executed"]);
expect(executor.batch.value?.status).toBe("cancelled");
});
it("enters cancelling before waiting for the current target to settle", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const executor = useMultiDbExecution(
{
executeTarget: async () => {
await gate;
return { status: "cancelled" };
},
cancelTarget: async () => release(),
},
{ sourceTabId: "source" },
);
const run = executor.start("DROP TABLE users", targets);
await Promise.resolve();
const cancel = executor.cancel();
expect(executor.batch.value?.status).toBe("cancelling");
await cancel;
await run;
expect(executor.batch.value?.status).toBe("cancelled");
});
it("does not create a target tab when cancellation arrives during validation", async () => {
let releaseValidation!: () => void;
const validationFinished = new Promise<void>((resolve) => {
releaseValidation = resolve;
});
const executor = useMultiDbExecution(
{
validateTarget: async () => {
await validationFinished;
return { valid: true };
},
executeTarget: async () => ({ status: "success" as const }),
},
{ sourceTabId: "source" },
);
const run = executor.start("ALTER TABLE users ADD COLUMN active BOOLEAN", targets);
await Promise.resolve();
await executor.cancel();
releaseValidation();
await run;
expect(executor.batch.value?.items.map((item) => item.status)).toEqual(["cancelled", "not_executed", "not_executed"]);
});
});
@@ -17,6 +17,7 @@ import { uuid } from "@/lib/common/utils";
import { simpleDataGridOrderByReferencesMissingColumn, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { queryResultBaseSql, queryResultExecutionSql } from "@/lib/tabs/tabPresentation";
import { sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities";
const DATA_TAB_METADATA_TTL_MS = TABLE_METADATA_CACHE_TTL_MS;
@@ -40,6 +41,14 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
function activeQueryTargetOptions(tab: QueryTab) {
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
if (!executionTarget) return {};
const connection = connectionStore.getConfig(executionTarget.connectionId);
const capabilities = sqlExecutionTargetCapabilities(connection);
return capabilities ? { executionTarget, targetContext: capabilities.provider.toExecutionContext(executionTarget, connection!) } : { executionTarget };
}
function quoteIdent(tab: QueryTab, name: string): string {
const config = connectionStore.getConfig(tab.connectionId);
return quoteTableDataIdentifier(effectiveDatabaseTypeForConnection(config), name, connectionStore.connectionIdentifierQuote?.(tab.connectionId));
@@ -211,6 +220,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tab.resultSortMode = undefined;
tab.resultSortedSql = undefined;
await queryStore.executeTabSql(tab.id, resultGroupSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: resultGroupSql,
resultSortedSql: undefined,
preserveResultDuringExecution: true,
@@ -222,6 +232,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const sortColumns = visibleQuerySortColumns(tab.result?.columns ?? [], tab.result?.hidden_column_indexes, tab.resultSortColumnIndex ?? -1);
const rebuildHiddenKeySort = !!tab.result?.hidden_column_indexes?.length && tab.resultSortMode === "database" && !!tab.resultSortDirection && !!tab.resultSortColumn && !!sortColumns;
await queryStore.executeTabSql(tab.id, rebuildHiddenKeySort ? (tab.resultBaseSql ?? tab.sql) : tab.resultSortedSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: tab.resultBaseSql ?? tab.sql,
...(rebuildHiddenKeySort
? {
@@ -240,6 +251,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
}
if (sql?.trim()) {
await queryStore.executeTabSql(tab.id, sql, {
...(tab.mode === "query" ? activeQueryTargetOptions(tab) : {}),
resultBaseSql: sql,
resultSortedSql: undefined,
preserveResultDuringExecution: true,
@@ -264,6 +276,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const sessionId = tab.result?.has_more && tab.result?.session_id && continuesResultSession ? tab.result.session_id : undefined;
const resultBaseSql = queryResultBaseSql(tab);
await queryStore.executeTabSql(tab.id, baseSql, {
...activeQueryTargetOptions(tab),
resultBaseSql,
resultSortedSql: tab.resultSortedSql,
...(hasDatabaseSort
@@ -276,7 +289,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
},
}
: {}),
pagination: { offset, limit, sessionId },
pagination: { offset, limit, sessionId, clientSessionId: sessionId ? tab.resultClientSessionId : undefined },
...appendOptions,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
@@ -331,6 +344,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
if (!direction) {
await queryStore.executeTabSql(tab.id, baseSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: undefined,
preserveResultDuringExecution: true,
@@ -340,7 +354,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return;
}
const config = connectionStore.getConfig(tab.connectionId);
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
const config = connectionStore.getConfig(executionTarget?.connectionId ?? tab.connectionId);
if (effectiveDatabaseTypeForConnection(config) === "mongodb") {
const sortedSql = applyMongoFindSort(baseSql, column, direction);
if (!sortedSql) {
@@ -349,6 +364,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
}
queryStore.updateSql(tab.id, sortedSql);
await queryStore.executeTabSql(tab.id, sortedSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: sortedSql,
preserveResultDuringExecution: true,
@@ -377,6 +393,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return;
}
await queryStore.executeTabSql(tab.id, built.sql, {
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: built.sql,
preserveResultDuringExecution: true,
@@ -386,6 +403,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return;
}
await queryStore.executeTabSql(tab.id, baseSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
querySort: {
resultColumns: sortColumns.resultColumns,
@@ -1,7 +1,7 @@
import { ref } from "vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { filterDatabaseNamesForConnection, filterSchemaNamesForConnection } from "@/lib/database/visibleDatabases";
import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
import { isDorisFamilyCatalogCapable, supportsQueryTargetDatabaseListing } from "@/lib/database/databaseFeatureSupport";
import { usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport";
import type { CatalogInfo, ConnectionConfig } from "@/types/database";
@@ -78,43 +78,54 @@ export function useDatabaseOptions() {
const loadingCatalogOptions = ref<Record<string, boolean>>({});
const catalogDatabaseOptions = ref<Record<string, string[]>>({});
const loadingCatalogDatabaseOptions = ref<Record<string, boolean>>({});
const databaseRequests = new Map<string, Promise<void>>();
const catalogRequests = new Map<string, Promise<CatalogInfo[]>>();
const catalogDatabaseRequests = new Map<string, Promise<string[]>>();
async function loadDatabaseOptions(connectionId: string, catalog?: string) {
async function loadDatabaseOptions(connectionId: string, catalog?: string): Promise<void> {
const connection = connectionStore.getConfig(connectionId);
if (!connection || loadingDatabaseOptions.value[connectionId]) return;
if (!connection) return;
loadingDatabaseOptions.value[connectionId] = true;
try {
await connectionStore.ensureConnected(connectionId);
if (connection.db_type === "redis") {
const dbs = await api.redisListDatabases(connectionId);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => String(db.db)),
connection,
);
} else if (connection.db_type === "mongodb") {
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(await api.mongoListDatabases(connectionId), connection);
} else if (connection.db_type === "dameng") {
// 达梦的"数据库"概念对应 schema,使用 listSchemas 获取 schema 列表
databaseOptions.value[connectionId] = await fetchNamespaceOptionsForConnection(connectionId, connection);
} else if (catalog && isDorisFamilyCatalogCapable(connection?.db_type, connection?.driver_profile)) {
const dbs = await api.listDorisCatalogDatabases(connectionId, catalog);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
} else {
const dbs = await api.listDatabases(connectionId);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
const requestKey = `${connectionId}:${catalog ?? ""}`;
const pending = databaseRequests.get(requestKey);
if (pending) return pending;
const request = (async () => {
loadingDatabaseOptions.value[connectionId] = true;
try {
await connectionStore.ensureConnected(connectionId);
if (connection.db_type === "redis") {
const dbs = await api.redisListDatabases(connectionId);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => String(db.db)),
connection,
);
} else if (connection.db_type === "mongodb") {
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(await api.mongoListDatabases(connectionId), connection);
} else if (connection.db_type === "dameng") {
databaseOptions.value[connectionId] = await fetchNamespaceOptionsForConnection(connectionId, connection);
} else if (supportsQueryTargetDatabaseListing(connection.db_type)) {
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(await api.documentListDatabases(connectionId), connection);
} else if (catalog && isDorisFamilyCatalogCapable(connection?.db_type, connection?.driver_profile)) {
const dbs = await api.listDorisCatalogDatabases(connectionId, catalog);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
} else {
const dbs = await api.listDatabases(connectionId);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
}
} finally {
loadingDatabaseOptions.value[connectionId] = false;
databaseRequests.delete(requestKey);
}
} finally {
loadingDatabaseOptions.value[connectionId] = false;
}
})();
databaseRequests.set(requestKey, request);
return request;
}
async function loadCatalogOptions(connectionId: string): Promise<CatalogInfo[]> {
@@ -2,7 +2,7 @@ import { reactive, computed } from "vue";
import * as api from "@/lib/backend/api";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution";
export type BackgroundTaskStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
export type DatabaseExportSource = "manual" | "scheduled";
@@ -46,6 +46,31 @@ export interface ExportTask {
targetTables?: string[];
transferFailures?: DataTransferFailure[];
transferFailuresOmitted?: number;
multiDbSourceTabId?: string;
multiDbTotal?: number;
multiDbCompleted?: number;
multiDbSuccessCount?: number;
multiDbFailureCount?: number;
multiDbSkippedCount?: number;
multiDbNotExecutedCount?: number;
currentTarget?: { connectionId: string; catalog?: string; database: string; schema?: string };
onOpen?: () => void;
}
export interface MultiDbExecutionTaskProgress {
sourceTabId: string;
total: number;
completed: number;
successCount: number;
failureCount: number;
skippedCount: number;
notExecutedCount: number;
status: "running" | "completed" | "cancelled";
startedAt: number;
finishedAt?: number;
elapsedMs?: number;
currentTarget?: { connectionId: string; catalog?: string; database: string; schema?: string };
errorMessage?: string;
}
export const MAX_TRANSFER_FAILURE_DETAILS = 100;
@@ -325,6 +350,48 @@ export function useExportTracker() {
return task;
}
function addMultiDbExecutionTask(batchId: string, label: string, sourceTabId: string, onOpen?: () => void): ExportTask {
const task = reactive<ExportTask>({
exportId: batchId,
kind: "multi-db-execution",
tableName: label,
format: "sql",
filePath: "",
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
multiDbSourceTabId: sourceTabId,
onOpen,
startedAt: Date.now(),
});
taskMap.set(batchId, task);
return task;
}
function updateMultiDbExecutionTask(batchId: string, progress: MultiDbExecutionTaskProgress): void {
const task = taskMap.get(batchId);
if (!task) return;
task.multiDbSourceTabId = progress.sourceTabId;
task.multiDbTotal = progress.total;
task.multiDbCompleted = progress.completed;
task.multiDbSuccessCount = progress.successCount;
task.multiDbFailureCount = progress.failureCount;
task.multiDbSkippedCount = progress.skippedCount;
task.multiDbNotExecutedCount = progress.notExecutedCount;
task.rowsExported = progress.completed;
task.totalRows = progress.total;
task.currentTarget = progress.currentTarget;
task.errorMessage = progress.errorMessage ?? null;
task.startedAt = progress.startedAt;
task.finishedAt = progress.finishedAt;
task.elapsedMs = progress.elapsedMs;
if (progress.status === "running") task.status = "Running";
else if (progress.status === "cancelled") task.status = "Cancelled";
else task.status = progress.failureCount > 0 ? "Error" : "Done";
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
}
function startDataTransferTask(
request: api.TransferRequest,
label: string,
@@ -505,6 +572,8 @@ export function useExportTracker() {
addDatabaseExportTask,
addSqlFileTask,
addDataTransferTask,
addMultiDbExecutionTask,
updateMultiDbExecutionTask,
startDataTransferTask,
updateTableExportTask,
updateDatabaseExportTask,
@@ -0,0 +1,215 @@
import { computed, reactive, ref, type ComputedRef, type Ref } from "vue";
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
export interface MultiDbExecutionItem {
id: string;
target: Readonly<MultiDbExecutionTarget>;
status: MultiDbExecutionItemStatus;
errorMessage?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
}
export interface MultiDbExecutionBatch {
id: string;
sourceTabId: string;
sql: string;
items: MultiDbExecutionItem[];
status: "running" | "cancelling" | "completed" | "cancelled";
cancelRequested: boolean;
context: MultiDbExecutionContext;
mode: "serial" | "parallel";
startedAt: number;
completedAt?: number;
durationMs?: number;
}
export interface MultiDbExecutionAdapter {
executeTarget: (input: { target: MultiDbExecutionTarget; sourceTabId: string; sql: string; scopeId: string; context: Readonly<MultiDbExecutionContext>; isCancellationRequested: () => boolean }) => Promise<MultiDbTargetExecutionResult>;
validateTarget?: (target: MultiDbExecutionTarget) => Promise<{ valid: boolean; errorMessage?: string }>;
cancelTarget?: (sourceTabId: string, scopeId: string) => Promise<void>;
cancelPending?: (scopeId: string) => void | Promise<void>;
}
export interface MultiDbExecutionContext {
readonly batchId: string;
readonly sourceTabId: string;
readonly sql: string;
readonly targets: readonly MultiDbExecutionTarget[];
/** Offset of the submitted SQL in the source editor, captured at confirmation time. */
readonly sourceOffset?: number;
}
export type MultiDbExecutionContextOverrides = Pick<MultiDbExecutionContext, "sourceOffset">;
export interface MultiDbExecutionOptions {
sourceTabId: Ref<string> | ComputedRef<string> | string;
}
export type MultiDbExecutionMode = "serial" | "parallel";
function executionId(): string {
return `multi-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeError(error: unknown): string {
if (error instanceof Error) return error.message;
return typeof error === "string" ? error : String(error);
}
export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: MultiDbExecutionOptions) {
const batch = ref<MultiDbExecutionBatch>();
const isRunning = computed(() => batch.value?.status === "running" || batch.value?.status === "cancelling");
function sourceTabId(): string {
return typeof options.sourceTabId === "string" ? options.sourceTabId : options.sourceTabId.value;
}
function markPendingNotExecuted(): void {
const current = batch.value;
if (!current) return;
for (const item of current.items) {
if (item.status === "pending") {
item.status = "not_executed";
item.completedAt ??= Date.now();
item.durationMs ??= 0;
}
}
}
async function executeItem(current: MultiDbExecutionBatch, item: MultiDbExecutionItem): Promise<void> {
if (current.cancelRequested) {
markPendingNotExecuted();
return;
}
item.status = "running";
item.startedAt = Date.now();
try {
if (adapter.validateTarget) {
const validation = await adapter.validateTarget(item.target);
if (!validation.valid) {
item.status = current.cancelRequested ? "cancelled" : "failed";
item.errorMessage = validation.errorMessage;
item.completedAt = Date.now();
item.durationMs = item.completedAt - (item.startedAt ?? item.completedAt);
return;
}
}
if (current.cancelRequested) {
item.status = "cancelled";
markPendingNotExecuted();
item.completedAt = Date.now();
item.durationMs = item.completedAt - (item.startedAt ?? item.completedAt);
return;
}
const result = await adapter.executeTarget({
target: item.target,
sourceTabId: current.sourceTabId,
sql: current.sql,
scopeId: current.id,
context: current.context,
isCancellationRequested: () => current.cancelRequested,
});
item.status = current.cancelRequested && result.status === "failed" ? "cancelled" : result.status;
item.errorMessage = result.errorMessage;
item.durationMs = result.durationMs;
} catch (error) {
// One target is intentionally isolated from the queue. Adapter errors
// become target failures so later targets keep running.
item.status = current.cancelRequested ? "cancelled" : "failed";
item.errorMessage = normalizeError(error);
} finally {
item.completedAt = Date.now();
item.durationMs ??= item.completedAt - (item.startedAt ?? item.completedAt);
}
}
async function executeBatch(current: MultiDbExecutionBatch): Promise<void> {
if (current.mode === "parallel") {
await Promise.all(current.items.map((item) => executeItem(current, item)));
} else {
for (const item of current.items) {
await executeItem(current, item);
if (current.cancelRequested) break;
}
}
if (current.cancelRequested) markPendingNotExecuted();
current.status = current.cancelRequested ? "cancelled" : "completed";
current.completedAt = Date.now();
current.durationMs = current.completedAt - current.startedAt;
}
async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise<MultiDbExecutionBatch | undefined> {
if (isRunning.value || !sql.trim() || targets.length === 0) return undefined;
const sourceId = sourceTabId();
const id = executionId();
const targetSnapshot = targets.map((target) => Object.freeze({ ...target }));
const current = reactive<MultiDbExecutionBatch>({
id,
sourceTabId: sourceId,
sql,
items: targetSnapshot.map((target, index) => ({
id: `${index}-${executionId()}`,
target: Object.freeze({ ...target }),
status: "pending",
})),
status: "running",
cancelRequested: false,
mode,
context: Object.freeze({
batchId: id,
sourceTabId: sourceId,
sql,
targets: Object.freeze(targetSnapshot),
...context,
}),
startedAt: Date.now(),
});
batch.value = current;
await executeBatch(current);
return current;
}
async function cancel(): Promise<void> {
const current = batch.value;
if (!current || current.status !== "running") return;
current.cancelRequested = true;
current.status = "cancelling";
try {
await adapter.cancelPending?.(current.id);
} catch {
// Cancellation is best-effort. Continue to the active target even when
// a confirmation store has already settled concurrently.
}
const running = current.items.find((item) => item.status === "running");
if (running && adapter.cancelTarget) {
try {
await adapter.cancelTarget(current.sourceTabId, current.id);
} catch {
// The target execution will settle the batch when its request returns.
}
}
if (!running) {
markPendingNotExecuted();
current.status = "cancelled";
current.completedAt = Date.now();
current.durationMs = current.completedAt - current.startedAt;
}
}
function reset(): void {
if (isRunning.value) return;
batch.value = undefined;
}
return {
batch,
isRunning,
start,
cancel,
reset,
};
}
@@ -0,0 +1,336 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useDatabaseOptions, catalogDatabaseOptionsKey } from "@/composables/useDatabaseOptions";
import { schemaOptionsCacheKey, useSchemaOptions } from "@/composables/useSchemaOptions";
import { isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { sqlExecutionTargetCapabilities, targetCanUseDefaultWhenDatabaseListEmpty, targetDefaultDatabase, targetIsSingleDatabase, targetSupportsCatalog, targetSupportsSchema, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities";
import type { CatalogInfo, ConnectionConfig, DatabaseType } from "@/types/database";
import type { MultiDbExecutionTarget, SqlExecutionTargetValidation } from "@/types/sqlExecution";
export type SqlExecutionTargetValidationReason = "targetMissingConnection" | "targetTypeMismatch" | "targetCatalogMissing" | "targetDatabaseMissing" | "targetSchemaMissing" | "targetPermissionDenied" | "targetValidationFailed";
export interface MultiDbTargetCatalogOption {
name: string;
targetCatalog?: string;
isInternal: boolean;
}
export interface MultiDbTargetResourceState {
status: "idle" | "loading" | "loaded" | "error";
loading: boolean;
loaded: boolean;
error?: string;
}
type DatabaseTypeSource = Ref<DatabaseType | undefined> | ComputedRef<DatabaseType | undefined> | DatabaseType | undefined;
function sourceValue(source: DatabaseTypeSource): DatabaseType | undefined {
return typeof source === "string" || source === undefined ? source : source.value;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function catalogTargetValue(catalog: CatalogInfo): MultiDbTargetCatalogOption {
const isInternal = isInternalDorisCatalog(catalog.catalog_type, catalog.name);
return {
name: catalog.name,
targetCatalog: isInternal ? undefined : catalog.name,
isInternal,
};
}
export function useMultiDbTargetSelection(databaseTypeSource: DatabaseTypeSource) {
const connectionStore = useConnectionStore();
const databaseOptionsApi = useDatabaseOptions();
const schemaOptionsApi = useSchemaOptions();
const resourceStates = ref<Record<string, MultiDbTargetResourceState>>({});
const pendingLoads = new Map<string, Promise<void>>();
const databaseType = computed(() => sourceValue(databaseTypeSource));
const compatibleConnections = computed(() =>
connectionStore.connections
.filter((connection) => {
const effectiveType = effectiveDatabaseTypeForConnection(connection);
return effectiveType === databaseType.value && supportsQueryExecution(connection.db_type);
})
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, undefined, { numeric: true, sensitivity: "base" })),
);
function stateFor(key: string): MultiDbTargetResourceState {
return resourceStates.value[key] ?? { status: "idle", loading: false, loaded: false };
}
function setState(key: string, state: MultiDbTargetResourceState): void {
resourceStates.value[key] = state;
}
async function loadOnce(key: string, loader: () => Promise<void>): Promise<void> {
const existing = pendingLoads.get(key);
if (existing) return existing;
const request = (async () => {
setState(key, { status: "loading", loading: true, loaded: false });
try {
await loader();
setState(key, { status: "loaded", loading: false, loaded: true });
} catch (error) {
setState(key, { status: "error", loading: false, loaded: false, error: errorMessage(error) });
throw error;
} finally {
pendingLoads.delete(key);
}
})();
pendingLoads.set(key, request);
return request;
}
function connection(connectionId: string): ConnectionConfig | undefined {
return connectionStore.getConfig(connectionId);
}
function isCatalogCapable(connectionId: string): boolean {
return targetSupportsCatalog(connection(connectionId));
}
function catalogsForConnection(connectionId: string): MultiDbTargetCatalogOption[] {
return (databaseOptionsApi.catalogOptions.value[connectionId] ?? []).map(catalogTargetValue);
}
function catalogOption(connectionId: string, targetCatalog?: string): MultiDbTargetCatalogOption | undefined {
return catalogsForConnection(connectionId).find((catalog) => catalog.targetCatalog === targetCatalog);
}
function databaseCacheKey(connectionId: string, targetCatalog?: string): string {
return targetCatalog ? catalogDatabaseOptionsKey(connectionId, targetCatalog) : connectionId;
}
function databaseNamesForConnection(connectionId: string, targetCatalog?: string): string[] {
const config = connection(connectionId);
if (config && targetUsesConnectionOnlyScope(config)) return [];
const cached = targetCatalog ? databaseOptionsApi.catalogDatabaseOptions.value[databaseCacheKey(connectionId, targetCatalog)] : databaseOptionsApi.databaseOptions.value[connectionId];
const cache = targetCatalog ? databaseOptionsApi.catalogDatabaseOptions.value : databaseOptionsApi.databaseOptions.value;
const cacheKey = databaseCacheKey(connectionId, targetCatalog);
if (stateFor(`databases:${cacheKey}`).status === "error") return [];
const hasLoadedMetadata = Object.prototype.hasOwnProperty.call(cache, cacheKey);
const loadedNames = cached ?? [];
if (loadedNames.length > 0) return loadedNames;
if (config && targetCanUseDefaultWhenDatabaseListEmpty(config)) {
const defaultDatabase = targetDefaultDatabase(config);
if (defaultDatabase) return [defaultDatabase];
}
// An empty loaded response is authoritative. Returning no synthetic
// database here lets the selector expose an explicit connection fallback
// only for capabilities that registered one, without turning an empty
// metadata response into an arbitrary database list.
if (hasLoadedMetadata) return [];
return [];
}
function schemaDatabaseName(target: MultiDbExecutionTarget, config: ConnectionConfig): string {
if (target.database) return target.database;
if (targetIsSingleDatabase(config)) return "_";
return "";
}
function schemaNamesForTarget(target: MultiDbExecutionTarget): string[] {
const config = connection(target.connectionId);
if (!config || !targetSupportsSchema(config)) return [];
const database = schemaDatabaseName(target, config);
if (stateFor(`schemas:${schemaOptionsCacheKey(target.connectionId, database, config.show_system_schemas === true)}`).status === "error") return [];
return schemaOptionsApi.getSchemaOptionsForDb(target.connectionId, database);
}
async function loadConnection(connectionId: string): Promise<void> {
const config = connection(connectionId);
if (!config) return;
if (targetUsesConnectionOnlyScope(config)) return;
if (isCatalogCapable(connectionId)) {
await loadCatalogs(connectionId);
const catalogs = catalogsForConnection(connectionId);
const internalCatalog = catalogs.find((catalog) => catalog.isInternal);
await loadDatabase(connectionId, internalCatalog?.targetCatalog);
return;
}
await loadDatabase(connectionId);
}
async function loadCatalogs(connectionId: string): Promise<void> {
if (!isCatalogCapable(connectionId)) return;
const config = connection(connectionId);
const provider = sqlExecutionTargetCapabilities(config)?.provider;
if (!config || !provider) return;
await loadOnce(`catalogs:${connectionId}`, async () => {
await provider.listTargets(
config,
{ level: "catalog" },
{
listCatalogs: async () => {
await databaseOptionsApi.loadCatalogOptions(connectionId);
return catalogsForConnection(connectionId).map((catalog) => catalog.name);
},
},
);
});
}
async function loadDatabase(connectionId: string, targetCatalog?: string): Promise<void> {
const config = connection(connectionId);
if (!config) return;
if (targetUsesConnectionOnlyScope(config)) return;
const provider = sqlExecutionTargetCapabilities(config)?.provider;
if (!provider) return;
const key = databaseCacheKey(connectionId, targetCatalog);
await loadOnce(`databases:${key}`, async () => {
await provider.listTargets(
config,
{ level: "database", catalog: targetCatalog },
{
listDatabases: async (catalog) => {
if (catalog) await databaseOptionsApi.loadCatalogDatabaseOptions(connectionId, catalog);
else await databaseOptionsApi.loadDatabaseOptions(connectionId);
return databaseNamesForConnection(connectionId, catalog);
},
},
);
});
}
async function loadSchemas(target: MultiDbExecutionTarget): Promise<void> {
const config = connection(target.connectionId);
if (!config || !targetSupportsSchema(config)) return;
const provider = sqlExecutionTargetCapabilities(config)?.provider;
if (!provider) return;
const database = schemaDatabaseName(target, config);
const key = schemaOptionsCacheKey(target.connectionId, database, config.show_system_schemas === true);
await loadOnce(`schemas:${key}`, async () => {
await provider.listTargets(
config,
{ level: "schema", database },
{
listSchemas: async (requestedDatabase) => {
await schemaOptionsApi.loadSchemaOptions(target.connectionId, requestedDatabase);
return schemaOptionsApi.getSchemaOptionsForDb(target.connectionId, requestedDatabase);
},
},
);
});
}
function resourceState(key: string): MultiDbTargetResourceState {
return stateFor(key);
}
function databaseState(connectionId: string, targetCatalog?: string): MultiDbTargetResourceState {
return resourceState(`databases:${databaseCacheKey(connectionId, targetCatalog)}`);
}
function catalogState(connectionId: string): MultiDbTargetResourceState {
return resourceState(`catalogs:${connectionId}`);
}
function schemaState(target: MultiDbExecutionTarget): MultiDbTargetResourceState {
const config = connection(target.connectionId);
if (!config) return { status: "idle", loading: false, loaded: false };
const database = schemaDatabaseName(target, config);
return resourceState(`schemas:${schemaOptionsCacheKey(target.connectionId, database, config.show_system_schemas === true)}`);
}
async function validateTarget(target: MultiDbExecutionTarget): Promise<SqlExecutionTargetValidation> {
const config = connection(target.connectionId);
if (!config) return { target, state: "invalid", reason: "targetMissingConnection" };
if (!databaseType.value) return { target, state: "invalid", reason: "targetValidationFailed" };
if (effectiveDatabaseTypeForConnection(config) !== databaseType.value) return { target, state: "invalid", reason: "targetTypeMismatch" };
const capabilities = sqlExecutionTargetCapabilities(config);
if (!capabilities) return { target, state: "invalid", reason: "targetValidationFailed" };
try {
await capabilities.provider.validateTarget(target, config);
} catch {
return { target, state: "invalid", reason: "targetValidationFailed" };
}
if (capabilities.scope === "connection") {
if (target.database.trim() || target.catalog !== undefined || target.schema !== undefined) return { target, state: "invalid", reason: "targetDatabaseMissing" };
return { target: { ...target, database: "" }, state: "valid" };
}
try {
if (isCatalogCapable(target.connectionId)) {
await loadCatalogs(target.connectionId);
// An omitted catalog is the normalized representation of the
// driver's internal catalog. It must still be present in the loaded
// catalog metadata; otherwise an empty/partial catalog response must
// not silently validate a target against a fabricated default.
if (!catalogOption(target.connectionId, target.catalog)) {
return { target, state: "invalid", reason: "targetCatalogMissing" };
}
} else if (target.catalog !== undefined) {
return { target, state: "invalid", reason: "targetCatalogMissing" };
}
await loadDatabase(target.connectionId, target.catalog);
if (databaseState(target.connectionId, target.catalog).status !== "loaded") {
return { target, state: "invalid", reason: "targetValidationFailed" };
}
const databases = databaseNamesForConnection(target.connectionId, target.catalog);
if (target.database) {
if (databases.length === 0) {
if (!capabilities.allowDefaultWhenDatabaseListEmpty || target.database !== capabilities.defaultDatabase) {
return { target, state: "invalid", reason: "targetDatabaseMissing" };
}
} else if (!databases.includes(target.database)) {
return { target, state: "invalid", reason: "targetDatabaseMissing" };
}
} else if (capabilities.databaseRequired || !capabilities.allowsEmptyDatabaseTarget) {
return { target, state: "invalid", reason: "targetDatabaseMissing" };
}
if (target.schema !== undefined) {
if (!capabilities.supportsSchema) return { target, state: "invalid", reason: "targetSchemaMissing" };
await loadSchemas(target);
if (schemaState(target).status !== "loaded") {
return { target, state: "invalid", reason: "targetValidationFailed" };
}
const schemas = schemaNamesForTarget(target);
if (schemas.length === 0 || !schemas.includes(target.schema)) return { target, state: "invalid", reason: "targetSchemaMissing" };
}
// Keep the provider as the final authority for the context that will be
// consumed by execution. This also prevents a future namespace provider
// from being considered valid merely because a list item was present.
capabilities.provider.toExecutionContext(target, config);
return { target, state: "valid" };
} catch (error) {
return { target, state: "invalid", reason: error instanceof Error && /permission|denied|forbidden|unauthorized/i.test(error.message) ? "targetPermissionDenied" : "targetValidationFailed" };
}
}
async function validateTargets(targets: readonly MultiDbExecutionTarget[]): Promise<SqlExecutionTargetValidation[]> {
// Target validation is independent. Keep the shared loadOnce/pendingLoads
// deduplication semantics, but allow different connections/databases to
// load metadata concurrently instead of making the dialog wait for a
// full serial pass.
return Promise.all(targets.map((target) => validateTarget(target)));
}
return {
databaseType,
compatibleConnections,
resourceStates,
connection,
isCatalogCapable,
catalogsForConnection,
databaseNamesForConnection,
schemaNamesForTarget,
loadConnection,
loadCatalogs,
loadDatabase,
loadSchemas,
resourceState,
catalogState,
databaseState,
schemaState,
validateTarget,
validateTargets,
};
}
@@ -24,6 +24,7 @@ export function useSchemaOptions() {
const schemaOptions = ref<Record<string, string[]>>({});
const loadingSchemaOptions = ref<Record<string, boolean>>({});
const schemaRequests = new Map<string, Promise<void>>();
function cacheKey(connectionId: string, database: string) {
return schemaOptionsCacheKey(connectionId, database, connectionStore.getConfig(connectionId)?.show_system_schemas === true);
@@ -40,18 +41,24 @@ export function useSchemaOptions() {
if (!database && !isSingleDatabase(dbType) && !usesTreeSchemaMode(dbType)) return;
const key = cacheKey(connectionId, database);
if (hasSchemaOptionsCacheEntry(schemaOptions.value, key)) return;
if (loadingSchemaOptions.value[key]) return;
const pending = schemaRequests.get(key);
if (pending) return pending;
loadingSchemaOptions.value[key] = true;
try {
await connectionStore.ensureConnected(connectionId);
schemaOptions.value[key] = schemaOptionsForConnection(await api.listSchemas(connectionId, database), connection, database);
} catch (e) {
schemaOptions.value[key] = [];
throw e;
} finally {
loadingSchemaOptions.value[key] = false;
}
const request = (async () => {
loadingSchemaOptions.value[key] = true;
try {
await connectionStore.ensureConnected(connectionId);
schemaOptions.value[key] = schemaOptionsForConnection(await api.listSchemas(connectionId, database), connection, database);
} catch (e) {
schemaOptions.value[key] = [];
throw e;
} finally {
loadingSchemaOptions.value[key] = false;
schemaRequests.delete(key);
}
})();
schemaRequests.set(key, request);
return request;
}
function getSchemaOptionsForDb(connectionId: string, database: string): string[] {
+202 -5
View File
@@ -6,6 +6,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { supportsConnectionScopedQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
@@ -19,7 +20,11 @@ import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@@ -27,6 +32,24 @@ interface SqlExecutionOptions {
openInNewResultTab?: boolean;
}
interface TargetSqlExecutionInput {
tab: QueryTab;
connection: ConnectionConfig;
sql: string;
executionTarget?: MultiDbExecutionTarget;
resultRun?: {
batchId: string;
title: string;
target: MultiDbExecutionTarget;
};
sourceOffset?: number;
blockDangerousRedisCommands?: boolean;
targetLabel?: string;
scopeId?: string;
isCancellationRequested?: () => boolean;
targetContext?: SqlExecutionTargetContext;
}
export function stripSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
@@ -80,6 +103,7 @@ export function useSqlExecution(deps: {
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
blockDangerousRedisCommands?: Ref<boolean>;
onMissingDatabase?: () => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
}) {
const { t } = useI18n();
const queryStore = useQueryStore();
@@ -103,6 +127,7 @@ export function useSqlExecution(deps: {
const pendingDangerKind = ref<"sql" | "redis">("sql");
const pendingDangerSourceOffset = ref<number | undefined>();
const pendingOpenInNewResultTab = ref(false);
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | void) | undefined;
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type).atSet;
@@ -192,7 +217,7 @@ export function useSqlExecution(deps: {
}
}
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}): boolean {
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise<void> | void): boolean {
const databaseType = deps.activeConnection.value?.db_type;
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType);
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
@@ -204,10 +229,20 @@ export function useSqlExecution(deps: {
sqlParameterEnabledSyntaxes.value = enabledSyntaxes;
pendingSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingSqlParameterContinuation = continuation;
showSqlParameterDialog.value = true;
return true;
}
async function prepareMultiExecute(onReady: (sql: string, sourceOffset?: number) => Promise<void> | void): Promise<boolean> {
const tab = deps.activeTab.value;
const { sql, sourceOffset } = await resolvedExecutableSql();
if (!tab || !sql.trim()) return false;
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, onReady)) return true;
await onReady(sql, sourceOffset);
return false;
}
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (sql === undefined) ({ sql, sourceOffset } = await resolvedExecutableSql());
const tab = deps.activeTab.value;
@@ -263,6 +298,161 @@ export function useSqlExecution(deps: {
}
}
async function executeTargetSql(input: TargetSqlExecutionInput): Promise<MultiDbTargetExecutionResult> {
const { tab, connection, sql, sourceOffset, targetLabel } = input;
const startedAt = Date.now();
const executionTab = input.executionTarget
? {
...tab,
connectionId: input.executionTarget.connectionId,
catalog: input.executionTarget.catalog,
database: input.executionTarget.database,
schema: input.executionTarget.schema,
}
: tab;
const finish = (result: MultiDbTargetExecutionResult): MultiDbTargetExecutionResult => ({
...result,
durationMs: Date.now() - startedAt,
});
const cancelRequested = () => input.isCancellationRequested?.() === true;
const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count;
if (cancelRequested()) return finish({ status: "cancelled" });
if (!sql.trim()) return finish({ status: "failed", errorMessage: t("explain.emptySql") });
if (requiresDatabaseSelection(executionTab, connection, sql)) {
return finish({ status: "failed", errorMessage: t("editor.selectDatabaseRequired") });
}
const blockRedisCommands = input.blockDangerousRedisCommands !== false;
if (connection.db_type === "redis" && blockRedisCommands) {
const commands = sql
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let highestSafety: "allowed" | "confirm" | "blocked" = "allowed";
for (const command of commands) {
const safety = classifyRedisCommandSafety(command);
if (safety === "blocked") {
highestSafety = "blocked";
break;
}
if (safety === "confirm") highestSafety = "confirm";
}
if (highestSafety === "blocked") {
return finish({ status: "skipped", errorMessage: t("redis.blockedCommand", { command: "Redis" }) });
}
if (highestSafety === "confirm") {
const confirmed = await deps.requestDangerConfirmation?.({
sql,
kind: "redis",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
databaseType: connection.db_type,
scopeId: input.scopeId,
});
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
}
const productionAssessment = assessProductionSql(sql, connection, executionTab.database);
if (productionAssessment.active && productionAssessment.isMutation) {
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: connection.name,
database: executionTab.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceMultiDbSql"),
scopeId: input.scopeId,
});
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
if (isDangerousSql(sql, connection.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
const confirmed = await deps.requestDangerConfirmation?.({
sql,
kind: "sql",
connectionName: connection.name,
database: executionTab.database,
targetLabel,
databaseType: connection.db_type,
scopeId: input.scopeId,
});
if (cancelRequested()) return finish({ status: "cancelled" });
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
if (cancelRequested()) return finish({ status: "cancelled" });
const cancelRequestCount = tab.cancelRequestCount ?? 0;
const workerId = input.executionTarget ? queryStore.createMultiDbExecutionWorker(tab.id, input.executionTarget, input.scopeId ?? "") : undefined;
if (input.executionTarget && !workerId) return finish({ status: "failed", errorMessage: t("multiDbExecute.targetMissingConnection") });
const executionTabId = workerId ?? tab.id;
const captureWorkerResult = (status: MultiDbResultRunExecution["status"], errorMessage?: string): string | undefined => {
if (!workerId || !input.resultRun) return undefined;
return queryStore.captureMultiDbExecutionWorkerResult(tab.id, workerId, sql, {
kind: "multi-db",
batchId: input.resultRun.batchId,
target: input.resultRun.target,
title: input.resultRun.title,
status,
durationMs: Date.now() - startedAt,
errorMessage,
});
};
try {
await queryStore.executeTabSql(executionTabId, sql, {
resultBaseSql: sql,
...(input.targetContext ? { targetContext: input.targetContext } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
});
const latest = queryStore.getExecutionTab(executionTabId) ?? tab;
if (cancelRequested() || tabCancelRequested(cancelRequestCount)) {
return finish({ status: "cancelled" });
}
const failure = firstQueryExecutionError(latest);
const errorMessage = failure ? String(failure.rows?.[0]?.[0] ?? t("common.failed")) : undefined;
const success = !failure;
const resultStatus = success ? "success" : "failed";
captureWorkerResult(resultStatus, errorMessage);
historyStore.add({
connection_id: executionTab.connectionId,
connection_name: connection.name || "",
database: executionTab.database,
sql,
execution_time_ms: Date.now() - startedAt,
success,
error: errorMessage,
activity_kind: classifySqlActivityKind(sql),
operation: primarySqlOperation(sql),
affected_rows: success ? latest.result?.affected_rows : undefined,
});
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
}
}
// 多库 worker 路径(workerId 存在)下不切主编辑器输出视图:并行 Promise.all
// 会让多个 worker 几乎同时改这个共享 ref,导致主视图在 result/summary 间反复
// 跳动(闪烁/竞态)。worker 结果已由 captureMultiDbExecutionWorkerResult 记录
// 到 source tab 的 result run 并通过 projectResultRun 投影显示,无需再切主视图。
if (!workerId) {
deps.activeOutputView.value = success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary";
}
return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
captureWorkerResult("failed", errorMessage);
return finish({ status: "failed", errorMessage });
} finally {
if (workerId) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId);
}
}
function cancelActiveExecution() {
const tab = deps.activeTab.value;
if (!tab) return;
@@ -321,7 +511,10 @@ export function useSqlExecution(deps: {
const sourceOffset = pendingSourceOffset.value;
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
await continueExecute(sql, sourceOffset, { openInNewResultTab });
const continuation = pendingSqlParameterContinuation;
pendingSqlParameterContinuation = undefined;
if (continuation) await continuation(sql, sourceOffset);
else await continueExecute(sql, sourceOffset, { openInNewResultTab });
}
watch(showSqlParameterDialog, (open) => {
@@ -332,6 +525,7 @@ export function useSqlExecution(deps: {
sqlParameterEnabledSyntaxes.value = [];
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterContinuation = undefined;
});
watch(showDangerDialog, (open) => {
@@ -360,6 +554,8 @@ export function useSqlExecution(deps: {
sqlParameterDatabaseType,
sqlParameterEnabledSyntaxes,
onSqlParametersConfirm,
prepareMultiExecute,
executeTargetSql,
explainMode,
};
}
@@ -373,11 +569,12 @@ export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig,
export function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionConfig | undefined, _sql = ""): boolean {
if (tab.mode !== "query") return false;
if (!connection) return false;
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection.db_type;
if (tab.database) return false;
if (tab.database === "" && usesTreeSchemaMode(connection.db_type)) return false;
if (isSingleDatabase(connection.db_type)) return false;
if (tab.database === "" && usesTreeSchemaMode(databaseType)) return false;
if (isSingleDatabase(databaseType)) return false;
// MySQL-compatible servers decide per statement whether a default database is required.
// Keep interactive execution connection-scoped instead of rejecting valid qualified or constant queries.
if (supportsConnectionLevelSqlExecution(connection)) return false;
return !["elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "zookeeper"].includes(connection.db_type);
return !supportsConnectionScopedQueryExecution(databaseType);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import en from "../locales/en";
import es from "../locales/es";
import it_ from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
type Messages = Record<string, unknown>;
const locales: Array<[string, Messages]> = [
["es", es],
["it", it_],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
const namespaces = ["multiDbExecute", "exportProgress"] as const;
function leafEntries(value: unknown, prefix = ""): Array<[string, string]> {
if (typeof value === "string") return [[prefix, value]];
if (value === null || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value as Messages).flatMap(([key, child]) => leafEntries(child, prefix ? `${prefix}.${key}` : key));
}
function placeholders(message: string): string[] {
return [...new Set([...message.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]))].sort();
}
describe.each(namespaces)("%s i18n namespace parity", (namespace) => {
const englishEntries = new Map(leafEntries((en as Messages)[namespace]));
it("english declares the namespace", () => {
expect(englishEntries.size).toBeGreaterThan(0);
});
it.each(locales)("%s declares the same keys and placeholders", (_name, locale) => {
const localeEntries = new Map(leafEntries(locale[namespace]));
expect([...localeEntries.keys()].sort()).toEqual([...englishEntries.keys()].sort());
for (const [key, englishMessage] of englishEntries) {
expect(placeholders(localeEntries.get(key) ?? ""), `${namespace}.${key}`).toEqual(placeholders(englishMessage));
}
});
});
+79
View File
@@ -67,6 +67,80 @@ export default {
updatableDriverCount: "Updatable driver count",
mcpUpdateAvailable: "MCP server update available",
blockDangerousRedisCommands: "Block dangerous commands",
multiDbExecute: "Multi-db execute",
},
multiDbExecute: {
title: "Multi-database execution",
progress: "Execution progress ({completed}/{total})",
sqlPreview: "SQL preview",
databaseType: "Database type",
searchTargets: "Search connections, databases, schemas...",
selectedCount: "{count} selected",
selectedTargets: "Selected targets",
selectGroup: "Execution target group",
noGroupSelected: "Select a target group",
manageGroups: "Manage groups",
manageGroupsDescription: "Target groups are reusable SQL execution targets and do not change connection groups.",
noGroups: "No target groups",
loadGroup: "Load group",
available: "Available",
typeMismatch: "Type mismatch",
targets: "targets",
saveAsGroup: "Save as group",
saveChanges: "Save changes",
saveAsNewGroup: "Save as new group",
renameGroup: "Rename group",
deleteGroup: "Delete group",
deleteGroupConfirm: "Delete target group “{name}”? Connections and databases will not be affected.",
groupName: "Group name",
groupNameRequired: "Enter a group name.",
groupNameExists: "A group with this name already exists.",
groupSaved: "Target group saved",
groupSaveFailed: "Failed to save target group: {message}",
groupLoadFailed: "Failed to load target group: {message}",
groupModified: "Target selection modified",
unsavedChangesConfirm: "The current target selection has unsaved changes. Save them before continuing?",
cancelOperation: "Cancel operation",
discardChanges: "Discard changes",
internalCatalog: "internal",
defaultDatabase: "default database",
noDatabase: "connection scope",
clearTargets: "Clear",
noTargets: "No executable targets are available.",
removeTarget: "Remove target",
validTarget: "Target is valid",
needsRecheck: "Waiting for target validation",
needsRecheckCount: "{count} target(s) need validation",
invalidTargetCount: "{count} invalid target(s)",
invalidTarget: "Invalid target",
connectionOnlyTarget: "Connection-level target",
targetMissingConnection: "Connection no longer exists",
targetTypeMismatch: "Connection database type no longer matches",
targetCatalogMissing: "Catalog no longer exists",
targetDatabaseMissing: "Database no longer exists or is not accessible",
targetSchemaMissing: "Schema no longer exists or is not accessible",
targetPermissionDenied: "Permission denied while validating target",
targetValidationFailed: "Target validation failed",
retryValidation: "Retry validation",
serialContinueOnError: "Serial execution; continue after failures",
parallelContinueOnError: "Parallel execution; continue after failures",
serial: "Serial",
parallel: "Parallel",
switchToParallel: "Switch to parallel",
switchToSerial: "Switch to serial",
elapsed: "Elapsed: {duration}",
pending: "Pending",
running: "Running",
success: "Success",
failed: "Failed",
skipped: "Skipped",
cancelled: "Cancelled",
notExecuted: "Not executed",
execute: "Execute",
cancelBatch: "Cancel batch",
dangerTitle: "Confirm target execution",
dangerConfirm: "Execute on this target",
resultTabPrefix: "Multi-db",
},
externalSqlFile: {
modifiedTitle: "File changed outside DBX",
@@ -884,6 +958,7 @@ export default {
showResultsPane: "Show results",
hideResultsPane: "Hide results",
noDatabase: "No database selected",
connectionOnlyTarget: "Connection-level target",
unsavedChangesTitle: "Unsaved changes",
unsavedChangesMessage: '"{title}" has unsaved changes that will be lost if closed. Save before closing?',
unsavedChangesBatchCloseMessage: "Some tabs you are closing have unsaved changes that will be lost. Save before continuing?",
@@ -1689,6 +1764,9 @@ export default {
databaseBackupTitle: "Database backup: {name}",
sqlFileTitle: "SQL file: {name}",
dataTransferTitle: "Data transfer: {name}",
multiDbExecutionTitle: "Multi-database execution: {name}",
multiDbTargets: "Targets {completed}/{total}, succeeded {success}, failed {failed}",
openTask: "Open task",
objectsCount: "{current} / {total} objects",
tablesCount: "{current} / {total} tables",
statementsCount: "{done} succeeded, {failed} failed",
@@ -4123,6 +4201,7 @@ export default {
confirmDetails: "Connection: {connection}\nDatabase: {database}\nSource: {source}",
confirmAction: "Execute in production",
sourceSqlEditor: "SQL editor",
sourceMultiDbSql: "Multi-database SQL execution",
sourceDataGrid: "Data editor",
sourceStructure: "Structure editor",
sourceSqlFile: "SQL file execution",
+79
View File
@@ -68,6 +68,80 @@ export default withEnglishFallback({
updatableDriverCount: "Cantidad de drivers actualizables",
mcpUpdateAvailable: "Actualización del servidor MCP disponible",
blockDangerousRedisCommands: "Bloquear comandos peligrosos",
multiDbExecute: "Ejecución multi-base de datos",
},
multiDbExecute: {
title: "Ejecución en varias bases de datos",
progress: "Progreso de ejecución ({completed}/{total})",
sqlPreview: "Vista previa de SQL",
databaseType: "Tipo de base de datos",
searchTargets: "Buscar conexiones, bases de datos, esquemas...",
selectedCount: "{count} seleccionados",
selectedTargets: "Destinos seleccionados",
selectGroup: "Grupo de destinos de ejecución",
noGroupSelected: "Selecciona un grupo de destinos",
manageGroups: "Gestionar grupos",
manageGroupsDescription: "Los grupos de destinos son reutilizables y no modifican los grupos de conexiones.",
noGroups: "No hay grupos de destinos",
loadGroup: "Cargar grupo",
available: "Disponible",
typeMismatch: "El tipo no coincide",
targets: "destinos",
saveAsGroup: "Guardar como grupo",
saveChanges: "Guardar cambios",
saveAsNewGroup: "Guardar como grupo nuevo",
renameGroup: "Cambiar nombre del grupo",
deleteGroup: "Eliminar grupo",
deleteGroupConfirm: "¿Eliminar el grupo de destinos «{name}»? No se modificarán las conexiones ni las bases de datos.",
groupName: "Nombre del grupo",
groupNameRequired: "Introduce un nombre para el grupo.",
groupNameExists: "Ya existe un grupo con este nombre.",
groupSaved: "Grupo de destinos guardado",
groupSaveFailed: "No se pudo guardar el grupo: {message}",
groupLoadFailed: "No se pudo cargar el grupo: {message}",
groupModified: "Selección de destinos modificada",
unsavedChangesConfirm: "La selección actual tiene cambios sin guardar. ¿Quieres guardarlos antes de continuar?",
cancelOperation: "Cancelar operación",
discardChanges: "Descartar cambios",
internalCatalog: "interno",
defaultDatabase: "base de datos predeterminada",
noDatabase: "ámbito de conexión",
connectionOnlyTarget: "Destino a nivel de conexión",
clearTargets: "Limpiar",
noTargets: "No hay destinos ejecutables disponibles.",
removeTarget: "Quitar destino",
validTarget: "El destino es válido",
needsRecheck: "Esperando la validación del destino",
needsRecheckCount: "{count} destino(s) necesitan validación",
invalidTargetCount: "{count} destino(s) no válidos",
invalidTarget: "Destino no válido",
targetMissingConnection: "La conexión ya no existe",
targetTypeMismatch: "El tipo de base de datos de la conexión ya no coincide",
targetCatalogMissing: "El catálogo ya no existe",
targetDatabaseMissing: "La base de datos ya no existe o no es accesible",
targetSchemaMissing: "El esquema ya no existe o no es accesible",
targetPermissionDenied: "No hay permisos para validar el destino",
targetValidationFailed: "No se pudo validar el destino",
retryValidation: "Reintentar validación",
serialContinueOnError: "Ejecución secuencial; continuar después de los fallos",
parallelContinueOnError: "Ejecución paralela; continuar después de los fallos",
serial: "Secuencial",
parallel: "Paralela",
switchToParallel: "Cambiar a paralela",
switchToSerial: "Cambiar a secuencial",
elapsed: "Tiempo transcurrido: {duration}",
pending: "Pendiente",
running: "En ejecución",
success: "Correcto",
failed: "Fallido",
skipped: "Omitido",
cancelled: "Cancelado",
notExecuted: "No ejecutado",
execute: "Ejecutar",
cancelBatch: "Cancelar lote",
dangerTitle: "Confirmar ejecución de destinos",
dangerConfirm: "Ejecutar en este destino",
resultTabPrefix: "Varias bases de datos",
},
externalSqlFile: {
modifiedTitle: "El archivo cambió fuera de DBX",
@@ -1032,6 +1106,7 @@ export default withEnglishFallback({
confirm: "Confirmar",
selectFirst: "Seleccione el texto de la lista",
},
connectionOnlyTarget: "Objetivo a nivel de conexión",
},
tabs: {
sql: "SQL",
@@ -1629,6 +1704,9 @@ export default withEnglishFallback({
databaseBackupTitle: "Copia de seguridad: {name}",
sqlFileTitle: "Archivo SQL: {name}",
dataTransferTitle: "Transferencia de datos: {name}",
multiDbExecutionTitle: "Ejecución en varias bases de datos: {name}",
multiDbTargets: "Destinos {completed}/{total}, correctos {success}, fallidos {failed}",
openTask: "Abrir tarea",
objectsCount: "{current} / {total} objetos",
tablesCount: "{current} / {total} tablas",
statementsCount: "{done} exitosas, {failed} fallidas",
@@ -6792,6 +6870,7 @@ export default withEnglishFallback({
sourceQueryHistory: "Reversión del historial de consultas",
sourceAdmin: "Administración de bases de datos",
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
sourceMultiDbSql: "Ejecución SQL multi-base de datos",
},
processList: {
title: "Lista de procesos",
+79
View File
@@ -67,6 +67,80 @@ export default withEnglishFallback({
updatableDriverCount: "Driver aggiornabili",
mcpUpdateAvailable: "Aggiornamento server MCP disponibile",
blockDangerousRedisCommands: "Blocca comandi pericolosi",
multiDbExecute: "Esecuzione multi-DB",
},
multiDbExecute: {
title: "Esecuzione su più database",
progress: "Avanzamento esecuzione ({completed}/{total})",
sqlPreview: "Anteprima SQL",
databaseType: "Tipo di database",
searchTargets: "Cerca connessioni, database, schemi...",
selectedCount: "{count} selezionati",
selectedTargets: "Destinazioni selezionate",
selectGroup: "Gruppo di destinazioni di esecuzione",
noGroupSelected: "Seleziona un gruppo di destinazioni",
manageGroups: "Gestisci gruppi",
manageGroupsDescription: "I gruppi di destinazioni sono riutilizzabili e non modificano i gruppi di connessioni.",
noGroups: "Nessun gruppo di destinazioni",
loadGroup: "Carica gruppo",
available: "Disponibile",
typeMismatch: "Tipo non corrispondente",
targets: "destinazioni",
saveAsGroup: "Salva come gruppo",
saveChanges: "Salva modifiche",
saveAsNewGroup: "Salva come nuovo gruppo",
renameGroup: "Rinomina gruppo",
deleteGroup: "Elimina gruppo",
deleteGroupConfirm: "Eliminare il gruppo di destinazioni «{name}»? Connessioni e database non verranno modificati.",
groupName: "Nome del gruppo",
groupNameRequired: "Inserisci un nome per il gruppo.",
groupNameExists: "Esiste già un gruppo con questo nome.",
groupSaved: "Gruppo di destinazioni salvato",
groupSaveFailed: "Impossibile salvare il gruppo: {message}",
groupLoadFailed: "Impossibile caricare il gruppo: {message}",
groupModified: "Selezione delle destinazioni modificata",
unsavedChangesConfirm: "La selezione corrente contiene modifiche non salvate. Salvarle prima di continuare?",
cancelOperation: "Annulla operazione",
discardChanges: "Ignora modifiche",
internalCatalog: "interno",
defaultDatabase: "database predefinito",
noDatabase: "ambito della connessione",
connectionOnlyTarget: "Destinazione a livello di connessione",
clearTargets: "Pulisci",
noTargets: "Nessuna destinazione eseguibile disponibile.",
removeTarget: "Rimuovi destinazione",
validTarget: "Destinazione valida",
needsRecheck: "In attesa della convalida della destinazione",
needsRecheckCount: "{count} destinazione/i richiedono la convalida",
invalidTargetCount: "{count} destinazione/i non valide",
invalidTarget: "Destinazione non valida",
targetMissingConnection: "La connessione non esiste più",
targetTypeMismatch: "Il tipo di database della connessione non corrisponde più",
targetCatalogMissing: "Il catalogo non esiste più",
targetDatabaseMissing: "Il database non esiste più o non è accessibile",
targetSchemaMissing: "Lo schema non esiste più o non è accessibile",
targetPermissionDenied: "Permessi insufficienti per convalidare la destinazione",
targetValidationFailed: "Convalida della destinazione non riuscita",
retryValidation: "Riprova convalida",
serialContinueOnError: "Esecuzione seriale; continua dopo gli errori",
parallelContinueOnError: "Esecuzione parallela; continua dopo gli errori",
serial: "Seriale",
parallel: "Parallela",
switchToParallel: "Passa a parallela",
switchToSerial: "Passa a seriale",
elapsed: "Tempo trascorso: {duration}",
pending: "In attesa",
running: "In esecuzione",
success: "Riuscita",
failed: "Non riuscita",
skipped: "Ignorata",
cancelled: "Annullata",
notExecuted: "Non eseguita",
execute: "Esegui",
cancelBatch: "Annulla batch",
dangerTitle: "Conferma esecuzione destinazioni",
dangerConfirm: "Esegui su questa destinazione",
resultTabPrefix: "Multi-database",
},
externalSqlFile: {
modifiedTitle: "File modificato esternamente a DBX",
@@ -1030,6 +1104,7 @@ export default withEnglishFallback({
confirm: "Conferma",
selectFirst: "Seleziona il testo della lista",
},
connectionOnlyTarget: "Target a livello di connessione",
},
tabs: {
sql: "SQL",
@@ -1628,6 +1703,9 @@ export default withEnglishFallback({
databaseBackupTitle: "Backup database: {name}",
sqlFileTitle: "File SQL: {name}",
dataTransferTitle: "Trasferimento dati: {name}",
multiDbExecutionTitle: "Esecuzione su più database: {name}",
multiDbTargets: "Destinazioni {completed}/{total}, riuscite {success}, non riuscite {failed}",
openTask: "Apri attività",
objectsCount: "{current} / {total} oggetti",
tablesCount: "{current} / {total} tabelle",
statementsCount: "{done} riuscite, {failed} non riuscite",
@@ -6792,6 +6870,7 @@ export default withEnglishFallback({
sourceQueryHistory: "Rollback cronologia query",
sourceAdmin: "Amministrazione database",
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
sourceMultiDbSql: "Esecuzione SQL multi-DB",
},
processList: {
title: "Elenco processi",
+79
View File
@@ -68,6 +68,80 @@ export default withEnglishFallback({
updatableDriverCount: "更新可能なドライバー数",
mcpUpdateAvailable: "MCPサーバーの更新があります",
blockDangerousRedisCommands: "危険なコマンドをブロック",
multiDbExecute: "複数DB実行",
},
multiDbExecute: {
title: "複数データベースで実行",
progress: "実行の進行状況({completed}/{total})",
sqlPreview: "SQLプレビュー",
databaseType: "データベースの種類",
searchTargets: "接続、データベース、スキーマを検索...",
selectedCount: "{count}件選択済み",
selectedTargets: "選択したターゲット",
selectGroup: "実行ターゲットグループ",
noGroupSelected: "ターゲットグループを選択",
manageGroups: "グループを管理",
manageGroupsDescription: "ターゲットグループは再利用可能なSQL実行先であり、接続グループは変更されません。",
noGroups: "ターゲットグループはありません",
loadGroup: "グループを読み込む",
available: "利用可能",
typeMismatch: "種類が一致しません",
targets: "ターゲット",
saveAsGroup: "グループとして保存",
saveChanges: "変更を保存",
saveAsNewGroup: "新しいグループとして保存",
renameGroup: "グループ名を変更",
deleteGroup: "グループを削除",
deleteGroupConfirm: "ターゲットグループ「{name}」を削除しますか?接続とデータベースには影響しません。",
groupName: "グループ名",
groupNameRequired: "グループ名を入力してください。",
groupNameExists: "同じ名前のグループが既に存在します。",
groupSaved: "ターゲットグループを保存しました",
groupSaveFailed: "ターゲットグループを保存できませんでした:{message}",
groupLoadFailed: "ターゲットグループを読み込めませんでした:{message}",
groupModified: "ターゲットの選択が変更されました",
unsavedChangesConfirm: "現在のターゲット選択に未保存の変更があります。続行する前に保存しますか?",
cancelOperation: "操作をキャンセル",
discardChanges: "変更を破棄",
internalCatalog: "内部",
defaultDatabase: "デフォルトデータベース",
noDatabase: "接続スコープ",
connectionOnlyTarget: "接続レベルのターゲット",
clearTargets: "クリア",
noTargets: "実行可能なターゲットがありません。",
removeTarget: "ターゲットを削除",
validTarget: "有効なターゲット",
needsRecheck: "ターゲットの検証を待機中",
needsRecheckCount: "{count}件のターゲットに検証が必要です",
invalidTargetCount: "{count}件の無効なターゲット",
invalidTarget: "無効なターゲット",
targetMissingConnection: "接続が存在しません",
targetTypeMismatch: "接続のデータベース種類が一致しません",
targetCatalogMissing: "カタログが存在しません",
targetDatabaseMissing: "データベースが存在しないか、アクセスできません",
targetSchemaMissing: "スキーマが存在しないか、アクセスできません",
targetPermissionDenied: "ターゲットを検証する権限がありません",
targetValidationFailed: "ターゲットの検証に失敗しました",
retryValidation: "検証を再試行",
serialContinueOnError: "直列実行。失敗後も続行します",
parallelContinueOnError: "並列実行。失敗後も続行します",
serial: "直列",
parallel: "並列",
switchToParallel: "並列に切り替え",
switchToSerial: "直列に切り替え",
elapsed: "経過時間:{duration}",
pending: "待機中",
running: "実行中",
success: "成功",
failed: "失敗",
skipped: "スキップ",
cancelled: "キャンセル済み",
notExecuted: "未実行",
execute: "実行",
cancelBatch: "一括実行をキャンセル",
dangerTitle: "ターゲットでの実行を確認",
dangerConfirm: "このターゲットで実行",
resultTabPrefix: "複数DB",
},
externalSqlFile: {
modifiedTitle: "ファイルがDBXの外部で変更されました",
@@ -1051,6 +1125,7 @@ export default withEnglishFallback({
confirm: "確定",
selectFirst: "リストテキストを選択してください",
},
connectionOnlyTarget: "接続レベル目標",
},
tabs: {
sql: "SQL",
@@ -1663,6 +1738,9 @@ export default withEnglishFallback({
hideFailureDetails: "失敗の詳細を閉じる",
failureDetailTruncated: "エラー詳細は省略されました",
failureDetailsOmitted: "ほか {count} 件の失敗詳細は表示されていません",
multiDbExecutionTitle: "複数データベースで実行:{name}",
multiDbTargets: "ターゲット {completed}/{total}、成功 {success}、失敗 {failed}",
openTask: "タスクを開く",
},
welcome: {
title: "データベースワークスペース",
@@ -6847,6 +6925,7 @@ export default withEnglishFallback({
sourceQueryHistory: "クエリ履歴のロールバック",
sourceAdmin: "データベース管理",
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
sourceMultiDbSql: "複数DB SQL実行",
},
processList: {
title: "プロセス一覧",
+76
View File
@@ -69,6 +69,79 @@ export default withEnglishFallback({
mcpUpdateAvailable: "MCP 서버 업데이트 가능",
blockDangerousRedisCommands: "위험한 명령 차단",
},
multiDbExecute: {
title: "다중 데이터베이스 실행",
progress: "실행 진행률 ({completed}/{total})",
sqlPreview: "SQL 미리보기",
databaseType: "데이터베이스 유형",
searchTargets: "연결, 데이터베이스, 스키마 검색...",
selectedCount: "{count}개 선택됨",
selectedTargets: "선택된 대상",
selectGroup: "실행 대상 그룹",
noGroupSelected: "대상 그룹 선택",
manageGroups: "그룹 관리",
manageGroupsDescription: "대상 그룹은 재사용 가능한 SQL 실행 대상이며 연결 그룹을 변경하지 않습니다.",
noGroups: "대상 그룹 없음",
loadGroup: "그룹 불러오기",
available: "사용 가능",
typeMismatch: "유형 불일치",
targets: "대상",
saveAsGroup: "그룹으로 저장",
saveChanges: "변경 사항 저장",
saveAsNewGroup: "새 그룹으로 저장",
renameGroup: "그룹 이름 변경",
deleteGroup: "그룹 삭제",
deleteGroupConfirm: "대상 그룹 “{name}”을(를) 삭제하시겠습니까? 연결과 데이터베이스에는 영향을 주지 않습니다.",
groupName: "그룹 이름",
groupNameRequired: "그룹 이름을 입력하세요.",
groupNameExists: "같은 이름의 그룹이 이미 있습니다.",
groupSaved: "대상 그룹 저장됨",
groupSaveFailed: "대상 그룹을 저장하지 못했습니다: {message}",
groupLoadFailed: "대상 그룹을 불러오지 못했습니다: {message}",
groupModified: "대상 선택이 변경됨",
unsavedChangesConfirm: "현재 대상 선택에 저장되지 않은 변경 사항이 있습니다. 계속하기 전에 저장하시겠습니까?",
cancelOperation: "작업 취소",
discardChanges: "변경 사항 버리기",
internalCatalog: "내부",
defaultDatabase: "기본 데이터베이스",
noDatabase: "연결 범위",
connectionOnlyTarget: "연결 수준 대상",
clearTargets: "지우기",
noTargets: "실행 가능한 대상이 없습니다.",
removeTarget: "대상 제거",
validTarget: "유효한 대상",
needsRecheck: "대상 검증 대기 중",
needsRecheckCount: "{count}개 대상에 검증이 필요합니다",
invalidTargetCount: "유효하지 않은 대상 {count}개",
invalidTarget: "유효하지 않은 대상",
targetMissingConnection: "연결이 더 이상 존재하지 않습니다",
targetTypeMismatch: "연결의 데이터베이스 유형이 더 이상 일치하지 않습니다",
targetCatalogMissing: "카탈로그가 더 이상 존재하지 않습니다",
targetDatabaseMissing: "데이터베이스가 없거나 접근할 수 없습니다",
targetSchemaMissing: "스키마가 없거나 접근할 수 없습니다",
targetPermissionDenied: "대상을 검증할 권한이 없습니다",
targetValidationFailed: "대상 검증 실패",
retryValidation: "검증 다시 시도",
serialContinueOnError: "직렬 실행, 실패 후에도 계속",
parallelContinueOnError: "병렬 실행, 실패 후에도 계속",
serial: "직렬",
parallel: "병렬",
switchToParallel: "병렬로 전환",
switchToSerial: "직렬로 전환",
elapsed: "경과 시간: {duration}",
pending: "대기 중",
running: "실행 중",
success: "성공",
failed: "실패",
skipped: "건너뜀",
cancelled: "취소됨",
notExecuted: "실행되지 않음",
execute: "실행",
cancelBatch: "일괄 실행 취소",
dangerTitle: "대상 실행 확인",
dangerConfirm: "이 대상에서 실행",
resultTabPrefix: "다중 DB",
},
externalSqlFile: {
modifiedTitle: "DBX 외부에서 파일이 변경됨",
modifiedDescription: "{path} 파일이 다른 프로그램에서 변경되었습니다. 유지할 버전을 선택하세요.",
@@ -1568,6 +1641,9 @@ export default withEnglishFallback({
hideFailureDetails: "실패 상세 숨기기",
failureDetailTruncated: "오류 상세가 잘렸습니다",
failureDetailsOmitted: "추가 실패 상세 {count}개가 표시되지 않았습니다",
multiDbExecutionTitle: "다중 데이터베이스 실행: {name}",
multiDbTargets: "대상 {completed}/{total}, 성공 {success}, 실패 {failed}",
openTask: "작업 열기",
},
welcome: {
title: "데이터베이스 워크스페이스",
+79
View File
@@ -68,6 +68,80 @@ export default withEnglishFallback({
updatableDriverCount: "Quantidade de drivers atualizáveis",
mcpUpdateAvailable: "Atualização do servidor MCP disponível",
blockDangerousRedisCommands: "Bloquear comandos perigosos",
multiDbExecute: "Execução em múltiplos bancos",
},
multiDbExecute: {
title: "Execução em vários bancos de dados",
progress: "Progresso da execução ({completed}/{total})",
sqlPreview: "Prévia do SQL",
databaseType: "Tipo de banco de dados",
searchTargets: "Pesquisar conexões, bancos de dados, schemas...",
selectedCount: "{count} selecionados",
selectedTargets: "Destinos selecionados",
selectGroup: "Grupo de destinos de execução",
noGroupSelected: "Selecione um grupo de destinos",
manageGroups: "Gerenciar grupos",
manageGroupsDescription: "Os grupos de destinos são reutilizáveis e não alteram os grupos de conexões.",
noGroups: "Nenhum grupo de destinos",
loadGroup: "Carregar grupo",
available: "Disponível",
typeMismatch: "Tipo incompatível",
targets: "destinos",
saveAsGroup: "Salvar como grupo",
saveChanges: "Salvar alterações",
saveAsNewGroup: "Salvar como novo grupo",
renameGroup: "Renomear grupo",
deleteGroup: "Excluir grupo",
deleteGroupConfirm: "Excluir o grupo de destinos “{name}”? As conexões e os bancos de dados não serão afetados.",
groupName: "Nome do grupo",
groupNameRequired: "Informe um nome para o grupo.",
groupNameExists: "Já existe um grupo com este nome.",
groupSaved: "Grupo de destinos salvo",
groupSaveFailed: "Falha ao salvar o grupo: {message}",
groupLoadFailed: "Falha ao carregar o grupo: {message}",
groupModified: "Seleção de destinos modificada",
unsavedChangesConfirm: "A seleção atual possui alterações não salvas. Deseja salvá-las antes de continuar?",
cancelOperation: "Cancelar operação",
discardChanges: "Descartar alterações",
internalCatalog: "interno",
defaultDatabase: "banco de dados padrão",
noDatabase: "escopo da conexão",
connectionOnlyTarget: "Destino no nível da conexão",
clearTargets: "Limpar",
noTargets: "Nenhum destino executável disponível.",
removeTarget: "Remover destino",
validTarget: "Destino válido",
needsRecheck: "Aguardando validação do destino",
needsRecheckCount: "{count} destino(s) precisam de validação",
invalidTargetCount: "{count} destino(s) inválido(s)",
invalidTarget: "Destino inválido",
targetMissingConnection: "A conexão não existe mais",
targetTypeMismatch: "O tipo de banco de dados da conexão não corresponde mais",
targetCatalogMissing: "O catálogo não existe mais",
targetDatabaseMissing: "O banco de dados não existe ou não está acessível",
targetSchemaMissing: "O schema não existe ou não está acessível",
targetPermissionDenied: "Permissão negada ao validar o destino",
targetValidationFailed: "Falha ao validar o destino",
retryValidation: "Tentar validar novamente",
serialContinueOnError: "Execução serial; continuar após falhas",
parallelContinueOnError: "Execução paralela; continuar após falhas",
serial: "Serial",
parallel: "Paralela",
switchToParallel: "Mudar para paralela",
switchToSerial: "Mudar para serial",
elapsed: "Tempo decorrido: {duration}",
pending: "Pendente",
running: "Em execução",
success: "Sucesso",
failed: "Falha",
skipped: "Ignorado",
cancelled: "Cancelado",
notExecuted: "Não executado",
execute: "Executar",
cancelBatch: "Cancelar lote",
dangerTitle: "Confirmar execução dos destinos",
dangerConfirm: "Executar neste destino",
resultTabPrefix: "Vários bancos",
},
externalSqlFile: {
modifiedTitle: "Arquivo alterado fora do DBX",
@@ -1031,6 +1105,7 @@ export default withEnglishFallback({
confirm: "Confirmar",
selectFirst: "Selecione o texto da lista primeiro",
},
connectionOnlyTarget: "Alvo em nível de conexão",
},
tabs: {
sql: "SQL",
@@ -1638,6 +1713,9 @@ export default withEnglishFallback({
hideFailureDetails: "Ocultar detalhes das falhas",
failureDetailTruncated: "Os detalhes do erro foram truncados",
failureDetailsOmitted: "{count} detalhes adicionais de falha não exibidos",
multiDbExecutionTitle: "Execução em vários bancos de dados: {name}",
multiDbTargets: "Destinos {completed}/{total}, sucesso {success}, falha {failed}",
openTask: "Abrir tarefa",
},
welcome: {
title: "Área de Trabalho do Banco de Dados",
@@ -6794,6 +6872,7 @@ export default withEnglishFallback({
sourceQueryHistory: "Reversão do histórico de consultas",
sourceAdmin: "Administração de banco de dados",
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
sourceMultiDbSql: "Execução de SQL em múltiplos bancos de dados",
},
processList: {
title: "Lista de Processos",
+79
View File
@@ -68,6 +68,80 @@ export default withEnglishFallback({
updatableDriverCount: "可更新驱动数量",
mcpUpdateAvailable: "MCP 服务有可用更新",
blockDangerousRedisCommands: "拦截危险命令",
multiDbExecute: "多库执行",
},
multiDbExecute: {
title: "多库执行",
progress: "执行进度({completed}/{total})",
sqlPreview: "SQL 预览",
databaseType: "数据库类型",
searchTargets: "搜索连接、数据库、Schema...",
selectedCount: "已选择 {count} 个目标",
selectedTargets: "已选执行目标",
selectGroup: "SQL 执行目标组",
noGroupSelected: "选择目标组",
manageGroups: "管理目标组",
manageGroupsDescription: "目标组用于复用 SQL 执行目标,不会改变连接分组。",
noGroups: "暂无目标组",
loadGroup: "加载目标组",
available: "可用",
typeMismatch: "类型不匹配",
targets: "个目标",
saveAsGroup: "保存为目标组",
saveChanges: "保存修改",
saveAsNewGroup: "另存为新目标组",
renameGroup: "重命名目标组",
deleteGroup: "删除目标组",
deleteGroupConfirm: "确定删除目标组“{name}”吗?不会影响连接和数据库配置。",
groupName: "目标组名称",
groupNameRequired: "请输入目标组名称。",
groupNameExists: "目标组名称已存在。",
groupSaved: "目标组已保存",
groupSaveFailed: "保存目标组失败:{message}",
groupLoadFailed: "加载目标组失败:{message}",
groupModified: "目标选择已修改",
unsavedChangesConfirm: "当前目标选择有未保存修改,是否先保存后继续?",
cancelOperation: "取消操作",
discardChanges: "放弃修改",
internalCatalog: "内部",
defaultDatabase: "默认数据库",
noDatabase: "连接级执行",
clearTargets: "清空",
noTargets: "没有可执行的目标。",
removeTarget: "移除目标",
validTarget: "目标有效",
needsRecheck: "等待目标校验",
needsRecheckCount: "有 {count} 个目标待校验",
invalidTargetCount: "有 {count} 个目标失效",
invalidTarget: "目标失效",
connectionOnlyTarget: "连接级目标",
targetMissingConnection: "连接已不存在",
targetTypeMismatch: "连接数据库类型已不匹配",
targetCatalogMissing: "Catalog 已不存在",
targetDatabaseMissing: "数据库不存在或无权访问",
targetSchemaMissing: "Schema 不存在或无权访问",
targetPermissionDenied: "校验目标时权限不足",
targetValidationFailed: "目标校验失败",
retryValidation: "重试校验",
serialContinueOnError: "串行执行,失败后继续",
parallelContinueOnError: "并行执行,失败后继续",
serial: "串行",
parallel: "并行",
switchToParallel: "切换为并行",
switchToSerial: "切换为串行",
elapsed: "耗时:{duration}",
pending: "等待中",
running: "执行中",
success: "成功",
failed: "失败",
skipped: "已跳过",
cancelled: "已取消",
notExecuted: "未执行",
execute: "执行",
cancelBatch: "取消批次",
dangerTitle: "确认目标执行",
dangerConfirm: "在此目标上执行",
resultTabPrefix: "多库",
},
externalSqlFile: {
modifiedTitle: "文件已在外部发生修改",
@@ -884,6 +958,7 @@ export default withEnglishFallback({
showResultsPane: "显示结果",
hideResultsPane: "收起",
noDatabase: "未选择数据库",
connectionOnlyTarget: "连接级目标",
unsavedChangesTitle: "未保存的更改",
unsavedChangesMessage: "「{title}」有未保存的更改,关闭后将丢失。是否保存后再关闭?",
unsavedChangesBatchCloseMessage: "待关闭的标签页中有未保存的更改,关闭后将丢失。是否保存后再继续关闭?",
@@ -1697,6 +1772,9 @@ export default withEnglishFallback({
hideFailureDetails: "收起失败详情",
failureDetailTruncated: "错误详情已截断",
failureDetailsOmitted: "另有 {count} 条失败详情未显示",
multiDbExecutionTitle: "多库执行:{name}",
multiDbTargets: "目标 {completed}/{total},成功 {success},失败 {failed}",
openTask: "打开任务",
},
welcome: {
title: "数据库工作台",
@@ -4121,6 +4199,7 @@ export default withEnglishFallback({
confirmDetails: "连接:{connection}\n数据库:{database}\n入口:{source}",
confirmAction: "在生产环境执行",
sourceSqlEditor: "SQL 编辑器",
sourceMultiDbSql: "多库 SQL 执行",
sourceDataGrid: "数据编辑器",
sourceStructure: "结构编辑器",
sourceSqlFile: "SQL 文件执行",
+79
View File
@@ -68,6 +68,80 @@ export default withEnglishFallback({
updatableDriverCount: "可更新驅動程式數量",
mcpUpdateAvailable: "MCP 服務有可用更新",
blockDangerousRedisCommands: "攔截危險命令",
multiDbExecute: "多庫執行",
},
multiDbExecute: {
title: "多資料庫執行",
progress: "執行進度({completed}/{total})",
sqlPreview: "SQL 預覽",
databaseType: "資料庫類型",
searchTargets: "搜尋連線、資料庫、結構描述...",
selectedCount: "已選取 {count} 個",
selectedTargets: "已選取的目標",
selectGroup: "執行目標群組",
noGroupSelected: "選取目標群組",
manageGroups: "管理群組",
manageGroupsDescription: "目標群組可重複用於 SQL 執行,不會變更連線群組。",
noGroups: "沒有目標群組",
loadGroup: "載入群組",
available: "可用",
typeMismatch: "類型不符",
targets: "個目標",
saveAsGroup: "另存為群組",
saveChanges: "儲存變更",
saveAsNewGroup: "另存為新群組",
renameGroup: "重新命名群組",
deleteGroup: "刪除群組",
deleteGroupConfirm: "確定要刪除目標群組「{name}」嗎?不會影響連線和資料庫。",
groupName: "群組名稱",
groupNameRequired: "請輸入群組名稱。",
groupNameExists: "已有同名群組。",
groupSaved: "目標群組已儲存",
groupSaveFailed: "儲存目標群組失敗:{message}",
groupLoadFailed: "載入目標群組失敗:{message}",
groupModified: "目標選取已變更",
unsavedChangesConfirm: "目前的目標選取有未儲存的變更。要先儲存再繼續嗎?",
cancelOperation: "取消操作",
discardChanges: "捨棄變更",
internalCatalog: "內部",
defaultDatabase: "預設資料庫",
noDatabase: "連線範圍",
connectionOnlyTarget: "連線層級目標",
clearTargets: "清除",
noTargets: "沒有可執行的目標。",
removeTarget: "移除目標",
validTarget: "目標有效",
needsRecheck: "等待目標驗證",
needsRecheckCount: "有 {count} 個目標需要驗證",
invalidTargetCount: "有 {count} 個無效目標",
invalidTarget: "無效目標",
targetMissingConnection: "連線已不存在",
targetTypeMismatch: "連線的資料庫類型已不符",
targetCatalogMissing: "Catalog 已不存在",
targetDatabaseMissing: "資料庫已不存在或無法存取",
targetSchemaMissing: "結構描述已不存在或無法存取",
targetPermissionDenied: "驗證目標時權限不足",
targetValidationFailed: "目標驗證失敗",
retryValidation: "重試驗證",
serialContinueOnError: "串行執行;失敗後繼續",
parallelContinueOnError: "並行執行;失敗後繼續",
serial: "串行",
parallel: "並行",
switchToParallel: "切換為並行",
switchToSerial: "切換為串行",
elapsed: "耗時:{duration}",
pending: "等待中",
running: "執行中",
success: "成功",
failed: "失敗",
skipped: "已略過",
cancelled: "已取消",
notExecuted: "未執行",
execute: "執行",
cancelBatch: "取消批次",
dangerTitle: "確認執行目標",
dangerConfirm: "在此目標上執行",
resultTabPrefix: "多資料庫",
},
externalSqlFile: {
modifiedTitle: "檔案已在 DBX 外部變更",
@@ -1030,6 +1104,7 @@ export default withEnglishFallback({
confirm: "確定",
selectFirst: "請選擇清單文字",
},
connectionOnlyTarget: "連線級目標",
},
tabs: {
sql: "SQL",
@@ -1637,6 +1712,9 @@ export default withEnglishFallback({
hideFailureDetails: "收合失敗詳情",
failureDetailTruncated: "錯誤詳情已截斷",
failureDetailsOmitted: "另有 {count} 條失敗詳情未顯示",
multiDbExecutionTitle: "多資料庫執行:{name}",
multiDbTargets: "目標 {completed}/{total},成功 {success},失敗 {failed}",
openTask: "開啟工作",
},
welcome: {
title: "資料庫工作檯",
@@ -6787,6 +6865,7 @@ export default withEnglishFallback({
sourceQueryHistory: "查詢歷史回復",
sourceAdmin: "資料庫管理",
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
sourceMultiDbSql: "多庫 SQL 執行",
},
processList: {
title: "處理程序清單",
@@ -2,7 +2,17 @@ import { describe, expect, it } from "vitest";
import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation";
import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing";
import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
import { isSchemaAware, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste, supportsTableImport, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import {
isSchemaAware,
supportsConnectionScopedQueryExecution,
supportsDatabaseNameCompletion,
supportsDatabaseSchemaQualifier,
supportsQueryTargetDatabaseListing,
supportsSqlInListPaste,
supportsTableImport,
supportsTransaction,
usesConnectionOnlyQueryTarget,
} from "@/lib/database/databaseFeatureSupport";
describe("schema awareness", () => {
it("keeps SQLite database aliases separate from schema-capable databases", () => {
@@ -10,6 +20,21 @@ describe("schema awareness", () => {
});
});
describe("connection-scoped query targets", () => {
it("keeps connection-only target types separate from unregistered namespace targets", () => {
expect(usesConnectionOnlyQueryTarget("etcd")).toBe(true);
expect(usesConnectionOnlyQueryTarget("zookeeper")).toBe(true);
expect(usesConnectionOnlyQueryTarget("elasticsearch")).toBe(true);
expect(supportsConnectionScopedQueryExecution("elasticsearch")).toBe(true);
expect(supportsQueryTargetDatabaseListing("elasticsearch")).toBe(false);
expect(usesConnectionOnlyQueryTarget("qdrant")).toBe(true);
expect(usesConnectionOnlyQueryTarget("milvus")).toBe(true);
expect(usesConnectionOnlyQueryTarget("weaviate")).toBe(true);
expect(usesConnectionOnlyQueryTarget("chromadb")).toBe(true);
expect(supportsQueryTargetDatabaseListing("etcd")).toBe(false);
});
});
describe("database and schema qualifiers", () => {
it.each(["sqlserver", "trino", "prestosql"] as const)("supports three-part object names for %s", (databaseType) => {
expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(true);
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { manifestDatabaseTypes, supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import {
normalizeSqlExecutionTarget,
sqlExecutionTargetCapabilities,
targetAllowsEmptyDatabase,
targetCanUseDefaultWhenDatabaseListEmpty,
targetDefaultDatabase,
targetIsSingleDatabase,
targetSupportsSchema,
targetUsesConnectionOnlyScope,
targetUsesNamespaceScope,
} from "@/lib/database/sqlExecutionTargetCapabilities";
import { normalizeSqlExecutionTargetContext, resolveSqlExecutionTargetProvider } from "@/lib/database/sqlExecutionTargetRegistry";
function connection(overrides: Partial<ConnectionConfig>): ConnectionConfig {
return {
id: "connection-1",
name: "Test connection",
db_type: "mysql",
host: "localhost",
port: 3306,
username: "user",
password: "",
...overrides,
};
}
describe("sql execution target capabilities", () => {
it("exposes a target capability for every manifest database type with query execution", () => {
for (const dbType of manifestDatabaseTypes()) {
if (!supportsDatabaseFeature(dbType, "queryExecution")) continue;
expect(sqlExecutionTargetCapabilities(connection({ db_type: dbType })), dbType).toBeDefined();
}
});
it("models connection-only query targets without database or schema", () => {
const config = connection({ db_type: "etcd" });
const capabilities = sqlExecutionTargetCapabilities(config);
expect(capabilities?.scope).toBe("connection");
expect(capabilities?.supportsDatabase).toBe(false);
expect(capabilities?.supportsSchema).toBe(false);
expect(targetUsesConnectionOnlyScope(config)).toBe(true);
expect(targetAllowsEmptyDatabase(config)).toBe(true);
expect(normalizeSqlExecutionTarget(config, { connectionId: config.id, database: "stale", catalog: "old", schema: "old" })).toEqual({
connectionId: config.id,
database: "",
});
});
it("models document and vector query targets as connection-only until a namespace provider exists", () => {
const config = connection({ db_type: "qdrant" });
const capabilities = sqlExecutionTargetCapabilities(config);
expect(capabilities?.scope).toBe("connection");
expect(capabilities?.supportsDatabase).toBe(false);
expect(capabilities?.supportsSchema).toBe(false);
expect(targetUsesNamespaceScope(config)).toBe(false);
expect(targetUsesConnectionOnlyScope(config)).toBe(true);
expect(normalizeSqlExecutionTarget(config, { connectionId: config.id, database: "collection", schema: "stale" })).toEqual({
connectionId: config.id,
database: "",
});
});
it("uses the registered VictoriaMetrics default without treating arbitrary names as valid", () => {
const config = connection({ db_type: "victoriametrics", database: undefined });
const capabilities = sqlExecutionTargetCapabilities(config);
expect(capabilities?.defaultDatabase).toBe("metrics");
expect(targetDefaultDatabase(config)).toBe("metrics");
expect(targetCanUseDefaultWhenDatabaseListEmpty(config)).toBe(true);
expect(targetIsSingleDatabase(config)).toBe(true);
expect(targetAllowsEmptyDatabase(config)).toBe(true);
});
it("uses the effective JDBC dialect for target semantics", () => {
const oracle = connection({ db_type: "jdbc", connection_string: "jdbc:oracle:thin:@localhost:1521/XEPDB1" });
const postgres = connection({ db_type: "jdbc", connection_string: "jdbc:postgresql://localhost:5432/app" });
expect(sqlExecutionTargetCapabilities(oracle)?.databaseType).toBe("oracle");
expect(targetIsSingleDatabase(oracle)).toBe(true);
expect(targetSupportsSchema(oracle)).toBe(true);
expect(sqlExecutionTargetCapabilities(postgres)?.databaseType).toBe("postgres");
expect(targetIsSingleDatabase(postgres)).toBe(false);
expect(targetSupportsSchema(postgres)).toBe(true);
});
it("prefers a configured PostgreSQL database for the stable default", () => {
const config = connection({ db_type: "postgres", database: "app" });
expect(targetDefaultDatabase(config)).toBe("app");
expect(targetCanUseDefaultWhenDatabaseListEmpty(config)).toBe(true);
expect(sqlExecutionTargetCapabilities(config)?.provider.id).toBe("builtin:database");
});
it("maps every built-in target scope through the shared provider contract", () => {
const databaseProvider = resolveSqlExecutionTargetProvider("database");
const connectionProvider = resolveSqlExecutionTargetProvider("connection");
const config = connection({ id: "conn-1", db_type: "postgres" });
expect(databaseProvider.toExecutionContext({ connectionId: "conn-1", database: "app", schema: "public" }, config)).toEqual({
scope: "database",
database: "app",
schema: "public",
});
expect(connectionProvider.toExecutionContext({ connectionId: "conn-1", database: "" }, config)).toEqual({ scope: "connection" });
expect(normalizeSqlExecutionTargetContext({ scope: "connection", database: "stale", schema: "stale", catalog: "stale" })).toEqual({ scope: "connection" });
});
it("rejects stale namespace fields on a connection-scoped target", () => {
const config = connection({ id: "conn-1", db_type: "etcd" });
const provider = sqlExecutionTargetCapabilities(config)!.provider;
expect(() => provider.validateTarget({ connectionId: "conn-1", database: "old", schema: "old" }, config)).toThrow();
});
it("does not expose targets for connections without query execution capability", () => {
expect(sqlExecutionTargetCapabilities(connection({ db_type: "mq" }))).toBeUndefined();
});
});
+1
View File
@@ -3725,6 +3725,7 @@ export interface QueryResultExportRequest {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
sql: string;
queryBaseSql: string;
setupSql?: string[];
@@ -2,6 +2,7 @@ import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/datab
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import { canEditTableStructure } from "@/lib/table/tableStructureCapabilities";
import { CLEARABLE_QUERY_SCHEMA_TYPES, DATABASE_OBJECT_TREE_TYPES, DATABASE_SCHEMA_QUALIFIED_TYPES, FETCH_FIRST_TYPES, PG_LIKE_STRUCTURE_TYPES, SCHEMA_AWARE_TYPES, SINGLE_DATABASE_TYPES, TREE_SCHEMA_TYPES } from "@/lib/database/databaseCapabilitySets";
import { supportsRegisteredConnectionScopedQueryExecution, supportsRegisteredQueryTargetDatabaseListing, usesRegisteredConnectionOnlyQueryTarget } from "@/lib/database/sqlExecutionTargetRegistry";
export function isSchemaAware(dbType?: DatabaseType): boolean {
return !!dbType && SCHEMA_AWARE_TYPES.has(dbType);
@@ -81,6 +82,40 @@ export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos" && dbType !== "hbase";
}
/**
* Whether the current product surface exposes a query execution path for the
* database type. This is intentionally distinct from sqlFileExecution:
* document/vector/key-value editors can execute commands from a query tab
* even when importing a SQL file is not supported.
*/
export function supportsQueryExecution(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "queryExecution");
}
export function supportsConnectionScopedQueryExecution(dbType?: DatabaseType): boolean {
return supportsRegisteredConnectionScopedQueryExecution(dbType);
}
/**
* Query surfaces whose target is the connection itself and which do not expose
* a database namespace to select. Keep this separate from
* supportsConnectionScopedQueryExecution: document/vector stores may execute
* without a selected database while still exposing database-like namespaces
* (for example indexes or collections) for browsing and target selection.
*/
export function usesConnectionOnlyQueryTarget(dbType?: DatabaseType): boolean {
return usesRegisteredConnectionOnlyQueryTarget(dbType);
}
/**
* Database-like namespaces exposed by connection-scoped document/vector
* query surfaces. This is the extension point for drivers whose query target
* is still selected from a database/index list rather than from SQL metadata.
*/
export function supportsQueryTargetDatabaseListing(dbType?: DatabaseType): boolean {
return supportsRegisteredQueryTargetDatabaseListing(dbType);
}
export function usesFetchFirst(dbType?: DatabaseType): boolean {
return !!dbType && FETCH_FIRST_TYPES.has(dbType);
}
@@ -0,0 +1,152 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
import { isSchemaAware, isSingleDatabase, supportsConnectionScopedQueryExecution, supportsQueryTargetDatabaseListing, usesConnectionOnlyQueryTarget } from "@/lib/database/databaseFeatureSupport";
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { registeredSqlExecutionTargetAllowsEmptyMetadataDefault, registeredSqlExecutionTargetDefaultDatabase, resolveSqlExecutionTargetProvider, supportsRegisteredCatalogTarget, type SqlExecutionTargetProvider, type SqlExecutionTargetScope } from "@/lib/database/sqlExecutionTargetRegistry";
/**
* Defaults that are part of the driver contract rather than values inferred
* from a possibly incomplete metadata response. A configured connection
* database is handled separately below and is also considered a stable
* default for that connection.
*/
export interface SqlExecutionTargetCapabilities {
databaseType: DatabaseType;
scope: SqlExecutionTargetScope;
provider: SqlExecutionTargetProvider;
supportsCatalog: boolean;
supportsDatabase: boolean;
supportsSchema: boolean;
/** Whether a database-like namespace is required to construct a target. */
databaseRequired: boolean;
/** Whether the namespace is enumerated by the existing metadata path. */
databaseListable: boolean;
/** Whether the connection itself is a valid fallback target. */
connectionFallback: boolean;
/** Whether an empty database is a valid target value for this scope. */
allowsEmptyDatabaseTarget: boolean;
/** Stable default used only before metadata has been loaded or by registered fallback rules. */
defaultDatabase?: string;
/** A loaded empty metadata list must not be widened to this fallback implicitly. */
allowDefaultWhenDatabaseListEmpty: boolean;
}
/**
* Resolves the target shape for every query-capable connection surface.
*
* This is the single extension point for multi-database target semantics. UI,
* validation and persistence consumers should use this result instead of
* maintaining database-type allow-lists of their own.
*/
export function sqlExecutionTargetCapabilities(connection: ConnectionConfig | undefined): SqlExecutionTargetCapabilities | undefined {
const databaseType = effectiveDatabaseTypeForConnection(connection);
if (!connection || !databaseType || !supportsQueryExecution(connection.db_type)) return undefined;
const connectionOnly = usesConnectionOnlyQueryTarget(databaseType);
const connectionScoped = supportsConnectionScopedQueryExecution(databaseType);
const supportsCatalog = supportsRegisteredCatalogTarget(connection, databaseType);
const supportsSchema = !connectionOnly && isSchemaAware(databaseType);
const supportsDatabase = !connectionOnly;
const databaseListable = !connectionOnly;
const namespaceTarget = connectionScoped && !connectionOnly;
const defaultDatabase = defaultExecutionTargetDatabase(connection, databaseType);
const connectionLevelExecution = supportsConnectionLevelSqlExecution(connection);
const hasStableDefault = defaultDatabase.length > 0 && (registeredSqlExecutionTargetAllowsEmptyMetadataDefault(databaseType) || !!connection.database?.trim());
const scope: SqlExecutionTargetScope = connectionOnly ? "connection" : supportsCatalog ? "catalog" : namespaceTarget ? "namespace" : "database";
return {
databaseType,
scope,
provider: resolveSqlExecutionTargetProvider(scope, databaseType),
supportsCatalog,
supportsDatabase,
supportsSchema,
databaseRequired: connectionOnly ? false : namespaceTarget ? true : !isSingleDatabase(databaseType) && !connectionLevelExecution,
databaseListable,
connectionFallback: connectionOnly,
allowsEmptyDatabaseTarget: connectionOnly || isSingleDatabase(databaseType),
defaultDatabase,
allowDefaultWhenDatabaseListEmpty: hasStableDefault,
};
}
function defaultExecutionTargetDatabase(connection: ConnectionConfig, databaseType: DatabaseType): string {
return registeredSqlExecutionTargetDefaultDatabase(connection, databaseType);
}
export function targetUsesConnectionOnlyScope(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.scope === "connection";
}
export function targetUsesNamespaceScope(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.scope === "namespace";
}
export function targetUsesConnectionFallback(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.connectionFallback === true;
}
export function targetAllowsEmptyDatabase(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.allowsEmptyDatabaseTarget === true;
}
/**
* Normalizes a target at the boundary where it is created from a live
* connection. This prevents an old tab namespace from leaking into a
* connection-scoped target and makes all producers use the same shape.
* Persisted targets are intentionally not silently normalized on load; they
* must be validated and shown as invalid when their stored shape is stale.
*/
export function normalizeSqlExecutionTarget(connection: ConnectionConfig | undefined, target: MultiDbExecutionTarget): MultiDbExecutionTarget {
const capabilities = sqlExecutionTargetCapabilities(connection);
if (!capabilities) return { ...target };
if (capabilities.scope === "connection") return { connectionId: target.connectionId, database: "" };
return {
connectionId: target.connectionId,
database: capabilities.supportsDatabase ? target.database : "",
...(capabilities.supportsCatalog && target.catalog ? { catalog: target.catalog } : {}),
...(capabilities.supportsSchema && target.schema ? { schema: target.schema } : {}),
};
}
export function targetIsSingleDatabase(connection: ConnectionConfig | undefined): boolean {
const capabilities = sqlExecutionTargetCapabilities(connection);
return capabilities ? isSingleDatabase(capabilities.databaseType) : false;
}
export function targetDefaultDatabase(connection: ConnectionConfig | undefined): string {
return sqlExecutionTargetCapabilities(connection)?.defaultDatabase ?? "";
}
export function targetCanUseDefaultWhenDatabaseListEmpty(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.allowDefaultWhenDatabaseListEmpty === true;
}
export function targetDatabaseListIsRequired(connection: ConnectionConfig | undefined): boolean {
const capabilities = sqlExecutionTargetCapabilities(connection);
return !!capabilities?.databaseRequired;
}
export function targetDatabaseMetadataIsListable(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.databaseListable === true;
}
export function targetSupportsSchema(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.supportsSchema === true;
}
export function targetSupportsCatalog(connection: ConnectionConfig | undefined): boolean {
return sqlExecutionTargetCapabilities(connection)?.supportsCatalog === true;
}
/**
* Namespace lists are already backed by the existing database-options loader
* for document/vector query surfaces. Keep this helper explicit so a future
* target provider can register a different loader without changing consumers.
*/
export function targetNamespaceListIsRegistered(connection: ConnectionConfig | undefined): boolean {
const databaseType = sqlExecutionTargetCapabilities(connection)?.databaseType;
return !!databaseType && supportsQueryTargetDatabaseListing(databaseType);
}
@@ -0,0 +1,181 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
export type SqlExecutionTargetScope = "catalog" | "database" | "namespace" | "connection";
export type SqlExecutionTargetListLevel = "catalog" | "database" | "schema";
export type SqlExecutionTargetContext = { scope: "catalog"; catalog?: string; database: string; schema?: string } | { scope: "database"; database: string; schema?: string } | { scope: "namespace"; namespaceKind: string; namespace: string } | { scope: "connection" };
export interface SqlExecutionTargetMetadataLoader {
listCatalogs?: () => Promise<readonly string[]>;
listDatabases?: (catalog?: string) => Promise<readonly string[]>;
listSchemas?: (database: string) => Promise<readonly string[]>;
}
/**
* The target provider is the common contract for target discovery, validation
* and execution-context mapping. Metadata itself remains owned by the
* existing composables; providers receive a narrow loader adapter so a new
* target kind does not need to know about Vue stores or backend caches.
*/
export interface SqlExecutionTargetProvider {
readonly id: string;
readonly scope: SqlExecutionTargetScope;
canListTargets(connection: ConnectionConfig): boolean;
listTargets(connection: ConnectionConfig, request: { level: SqlExecutionTargetListLevel; catalog?: string; database?: string }, loader: SqlExecutionTargetMetadataLoader): Promise<readonly string[]>;
validateTarget(target: MultiDbExecutionTarget, connection: ConnectionConfig): void | Promise<void>;
toExecutionContext(target: MultiDbExecutionTarget, connection: ConnectionConfig): SqlExecutionTargetContext;
}
/**
* Target-scope registration for query surfaces whose execution context is not
* the ordinary database/schema hierarchy.
*
* Keep this registry independent from UI components, metadata loaders and the
* executor. Adding a connection-scoped query driver should require one
* registration here; consumers derive their behavior from the registered
* scope instead of maintaining separate database-type lists.
*/
export type RegisteredSqlExecutionTargetScope = "namespace" | "connection";
interface SqlExecutionTargetRegistration {
scope?: RegisteredSqlExecutionTargetScope;
provider?: SqlExecutionTargetProvider;
supportsCatalog?: boolean;
/**
* Returns a driver-defined stable namespace default, if one exists. This
* is deliberately separate from connection.database, which is handled as
* a per-connection default by the capability resolver.
*/
defaultDatabase?: (connection: Pick<ConnectionConfig, "database" | "driver_profile">) => string;
/** Whether the default remains valid when metadata enumeration is empty. */
allowDefaultWhenDatabaseListEmpty?: boolean;
}
function optional(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function providerError(message: string): never {
throw new Error(message);
}
function createProvider(scope: SqlExecutionTargetScope): SqlExecutionTargetProvider {
return {
id: `builtin:${scope}`,
scope,
canListTargets: (connection) => scope !== "connection" && Boolean(connection.id),
async listTargets(_connection, request, loader) {
if (request.level === "catalog") return (await loader.listCatalogs?.()) ?? [];
if (request.level === "database") return (await loader.listDatabases?.(request.catalog)) ?? [];
return (await loader.listSchemas?.(request.database ?? "")) ?? [];
},
validateTarget(target, connection) {
if (target.connectionId !== connection.id) providerError("Target connection does not match the selected connection.");
if (scope === "connection") {
if (target.database.trim() || target.catalog !== undefined || target.schema !== undefined) providerError("Connection-scoped targets cannot contain a database, catalog, or schema.");
return;
}
if (scope !== "catalog" && target.catalog !== undefined) providerError("This target scope does not support catalogs.");
if (scope !== "namespace" && target.schema !== undefined && typeof target.schema !== "string") providerError("Invalid target schema.");
if (scope === "namespace" && !target.database.trim()) providerError("Namespace targets require a namespace.");
},
toExecutionContext(target, _connection) {
if (scope === "connection") return { scope };
if (scope === "namespace") return { scope, namespaceKind: "namespace", namespace: target.database };
return {
scope,
...(scope === "catalog" && target.catalog !== undefined ? { catalog: target.catalog } : {}),
database: target.database,
...(target.schema !== undefined ? { schema: target.schema } : {}),
};
},
};
}
const SQL_EXECUTION_TARGET_PROVIDERS: Record<SqlExecutionTargetScope, SqlExecutionTargetProvider> = {
catalog: createProvider("catalog"),
database: createProvider("database"),
namespace: createProvider("namespace"),
connection: createProvider("connection"),
};
const SQL_EXECUTION_TARGET_REGISTRY: Partial<Record<DatabaseType, SqlExecutionTargetRegistration>> = {
// These query surfaces currently accept the target namespace in the SQL/REST
// text itself, while the shared executeQuery boundary does not carry a
// namespace context. Keep them connection-scoped until a driver provider can
// safely apply a selected index/collection/database to the actual request.
elasticsearch: { scope: "connection" },
easysearch: { scope: "connection" },
qdrant: { scope: "connection" },
milvus: { scope: "connection" },
weaviate: { scope: "connection" },
chromadb: { scope: "connection" },
etcd: { scope: "connection" },
zookeeper: { scope: "connection" },
doris: { supportsCatalog: true },
starrocks: { supportsCatalog: true },
"cloudflare-d1": { defaultDatabase: () => "main", allowDefaultWhenDatabaseListEmpty: true },
sqlite: { defaultDatabase: (connection) => connection.database?.trim() || "main", allowDefaultWhenDatabaseListEmpty: true },
postgres: {
defaultDatabase: (connection) => connection.database?.trim() || (connection.driver_profile === "cockroachdb" ? "defaultdb" : "postgres"),
allowDefaultWhenDatabaseListEmpty: true,
},
victoriametrics: { defaultDatabase: (connection) => connection.database?.trim() || "metrics", allowDefaultWhenDatabaseListEmpty: true },
};
export function registeredSqlExecutionTargetScope(dbType?: DatabaseType): RegisteredSqlExecutionTargetScope | undefined {
return dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.scope : undefined;
}
export function supportsRegisteredConnectionScopedQueryExecution(dbType?: DatabaseType): boolean {
return registeredSqlExecutionTargetScope(dbType) !== undefined;
}
export function usesRegisteredConnectionOnlyQueryTarget(dbType?: DatabaseType): boolean {
return registeredSqlExecutionTargetScope(dbType) === "connection";
}
export function supportsRegisteredQueryTargetDatabaseListing(dbType?: DatabaseType): boolean {
return registeredSqlExecutionTargetScope(dbType) === "namespace";
}
export function supportsRegisteredCatalogTarget(connection: Pick<ConnectionConfig, "db_type" | "driver_profile"> | undefined, dbType?: DatabaseType): boolean {
if (!connection || !dbType) return false;
return SQL_EXECUTION_TARGET_REGISTRY[dbType]?.supportsCatalog === true;
}
export function registeredSqlExecutionTargetDefaultDatabase(connection: Pick<ConnectionConfig, "database" | "driver_profile">, dbType: DatabaseType): string {
return SQL_EXECUTION_TARGET_REGISTRY[dbType]?.defaultDatabase?.(connection) ?? connection.database?.trim() ?? "";
}
export function registeredSqlExecutionTargetAllowsEmptyMetadataDefault(dbType?: DatabaseType): boolean {
return dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.allowDefaultWhenDatabaseListEmpty === true : false;
}
export function resolveSqlExecutionTargetProvider(scope: SqlExecutionTargetScope, dbType?: DatabaseType): SqlExecutionTargetProvider {
return (dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.provider : undefined) ?? SQL_EXECUTION_TARGET_PROVIDERS[scope];
}
export function registeredSqlExecutionTargetNamespaceKind(dbType?: DatabaseType): string | undefined {
return dbType && SQL_EXECUTION_TARGET_REGISTRY[dbType]?.scope === "namespace" ? "namespace" : undefined;
}
export function normalizeSqlExecutionTargetContext(context: SqlExecutionTargetContext): SqlExecutionTargetContext {
if (context.scope === "connection") return { scope: "connection" };
if (context.scope === "namespace") {
return {
scope: "namespace",
namespaceKind: optional(context.namespaceKind) ?? "namespace",
namespace: context.namespace?.trim() ?? "",
};
}
return {
scope: context.scope,
...(context.scope === "catalog" && context.catalog !== undefined ? { catalog: optional(context.catalog) } : {}),
database: context.database.trim(),
...(context.schema !== undefined ? { schema: optional(context.schema) } : {}),
};
}
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
describe("productionSafetyStore scope cancellation", () => {
beforeEach(() => setActivePinia(createPinia()));
it("cancels queued requests in the scope before advancing the confirmation queue", async () => {
const store = useProductionSafetyStore();
const first = store.requestConfirmation({ sql: "UPDATE a SET value = 1", scopeId: "batch-1" });
const second = store.requestConfirmation({ sql: "UPDATE b SET value = 1", scopeId: "batch-1" });
const unrelated = store.requestConfirmation({ sql: "UPDATE c SET value = 1", scopeId: "batch-2" });
store.cancelScope("batch-1");
expect(store.pending?.scopeId).toBe("batch-2");
expect(await first).toBe(false);
expect(await second).toBe(false);
store.confirm();
expect(await unrelated).toBe(true);
expect(store.pending).toBeUndefined();
});
});
@@ -622,6 +622,31 @@ describe("queryStore multi-statement errors", () => {
expect(tab.activeResultRunId).toBeUndefined();
});
it("uses the immutable target context instead of the result tab namespace", async () => {
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "source-db", "Target", "query", "source-schema", "SELECT 1", "source-catalog");
await store.executeTabSql(tabId, "SELECT 1", {
targetContext: { scope: "database", database: "target-db", schema: "target-schema" },
});
expect(mocks.executeMulti).toHaveBeenCalledWith("mysql-1", "target-db", "SELECT 1", "target-schema", expect.any(String), expect.objectContaining({ catalog: undefined }));
});
it("clears the database and schema for a connection-scoped target", async () => {
mocks.getConnectionConfig.mockReturnValue({ id: "etcd-1", name: "etcd", db_type: "etcd", database: "stale" });
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("etcd-1", "stale", "Target", "query", "stale-schema", "GET /", "stale-catalog");
await store.executeTabSql(tabId, "GET /", { targetContext: { scope: "connection" } });
expect(mocks.executeMulti).toHaveBeenCalledWith("etcd-1", "", "GET /", undefined, expect.any(String), expect.objectContaining({ catalog: undefined }));
});
it("restores the retained result when a new-result execution fails before dispatch", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
describe("sqlExecutionDangerStore", () => {
beforeEach(() => setActivePinia(createPinia()));
it("cancels all requests in a batch without promoting another request from that batch", async () => {
const store = useSqlExecutionDangerStore();
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-1" });
const second = store.requestConfirmation({ sql: "DROP TABLE b", kind: "sql", scopeId: "batch-1" });
const unrelated = store.requestConfirmation({ sql: "DROP TABLE c", kind: "sql", scopeId: "batch-2" });
store.cancelScope("batch-1");
expect(store.pending?.scopeId).toBe("batch-2");
expect(await first).toBe(false);
expect(await second).toBe(false);
store.confirm();
expect(await unrelated).toBe(true);
expect(store.pending).toBeUndefined();
});
});
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useSqlExecutionTargetGroupStore } from "@/stores/sqlExecutionTargetGroupStore";
import { SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY } from "@/stores/sqlExecutionTargetGroupStore";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
});
return data;
}
const targets = [
{ connectionId: "conn-1", database: "app", schema: "public" },
{ connectionId: "conn-2", database: "app", schema: "public" },
] as const;
const resolveDatabaseType = () => "postgres" as const;
describe("sqlExecutionTargetGroupStore", () => {
beforeEach(() => {
installLocalStorage();
setActivePinia(createPinia());
});
it("deduplicates targets and persists reusable target references", () => {
const store = useSqlExecutionTargetGroupStore();
const group = store.createGroup({
name: "Production",
databaseType: "postgres",
targets: [...targets, targets[0]],
resolveDatabaseType,
});
expect(group.targets).toHaveLength(2);
expect(group.targets[0]).toEqual(targets[0]);
expect(JSON.parse(globalThis.localStorage.getItem(SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY) || "{}")).not.toHaveProperty("password");
setActivePinia(createPinia());
const reloaded = useSqlExecutionTargetGroupStore();
expect(reloaded.getGroup(group.id)?.targets).toEqual([...targets]);
});
it("rejects duplicate names case-insensitively and supports update/clone/delete", () => {
const store = useSqlExecutionTargetGroupStore();
const group = store.createGroup({ name: "Shared", databaseType: "mysql", targets: [targets[0]], resolveDatabaseType: () => "mysql" });
expect(() => store.createGroup({ name: " shared ", databaseType: "mysql", targets: [targets[1]], resolveDatabaseType: () => "mysql" })).toThrow("目标组名称已存在");
const updated = store.updateGroup(group.id, { name: "Shared App", targets: [targets[1]], resolveDatabaseType: () => "mysql" });
expect(updated?.name).toBe("Shared App");
expect(updated?.targets).toEqual([targets[1]]);
const clone = store.cloneGroup(group.id, "Shared Copy", undefined, () => "mysql");
expect(clone?.id).not.toBe(group.id);
expect(clone?.targets).toEqual([targets[1]]);
store.deleteGroup(group.id);
expect(store.getGroup(group.id)).toBeUndefined();
expect(store.getGroup(clone!.id)).toBeDefined();
});
it("rejects a mixed effective database type at the store boundary", () => {
const store = useSqlExecutionTargetGroupStore();
expect(() =>
store.createGroup({
name: "Mixed",
databaseType: "postgres",
targets,
resolveDatabaseType: (target) => (target.connectionId === "conn-1" ? "postgres" : "mysql"),
}),
).toThrow("目标组只能包含同一种数据库类型");
});
});
@@ -7,6 +7,8 @@ export interface ProductionConfirmationRequest {
database?: string;
productionDatabases?: string[];
source?: string;
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
scopeId?: string;
}
interface QueuedConfirmationRequest {
@@ -59,5 +61,19 @@ export const useProductionSafetyStore = defineStore("productionSafety", () => {
settle(false);
}
return { pending, requestConfirmation, confirm, cancel };
function cancelScope(scopeId: string) {
// Remove queued requests before settling the active one. `settle` promotes
// the next queue entry, so removing afterwards could promote a request from
// this same scope and leave its promise unresolved.
const retained: QueuedConfirmationRequest[] = [];
const cancelled: QueuedConfirmationRequest[] = [];
for (const entry of queue) {
(entry.request.scopeId === scopeId ? cancelled : retained).push(entry);
}
queue.splice(0, queue.length, ...retained);
cancelled.forEach((entry) => entry.resolve(false));
if (pending.value?.scopeId === scopeId) settle(false);
}
return { pending, requestConfirmation, confirm, cancel, cancelScope };
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { DatabaseType } from "@/types/database";
export interface SqlExecutionDangerRequest {
sql: string;
kind: "sql" | "redis";
connectionName?: string;
database?: string;
targetLabel?: string;
databaseType?: DatabaseType;
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
scopeId?: string;
}
interface QueuedDangerRequest {
request: SqlExecutionDangerRequest;
resolve: (confirmed: boolean) => void;
}
export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () => {
const pending = ref<SqlExecutionDangerRequest>();
const queue: QueuedDangerRequest[] = [];
let resolvePending: ((confirmed: boolean) => void) | undefined;
function requestConfirmation(request: SqlExecutionDangerRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (pending.value) {
queue.push({ request, resolve });
return;
}
begin(request, resolve);
});
}
function begin(request: SqlExecutionDangerRequest, resolve: (confirmed: boolean) => void): void {
pending.value = request;
resolvePending = resolve;
}
function settle(confirmed: boolean): void {
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
resolve?.(confirmed);
const next = queue.shift();
if (next) begin(next.request, next.resolve);
}
function confirm(): void {
settle(true);
}
function cancel(): void {
settle(false);
}
function cancelAll(): void {
settle(false);
while (queue.length > 0) queue.shift()?.resolve(false);
}
function cancelScope(scopeId: string): void {
// Remove queued requests before settling the active one. `settle` promotes
// the next queue entry, so removing afterwards could promote a request from
// this same scope and leave its promise unresolved.
const retained: QueuedDangerRequest[] = [];
const cancelled: QueuedDangerRequest[] = [];
for (const entry of queue) {
(entry.request.scopeId === scopeId ? cancelled : retained).push(entry);
}
queue.splice(0, queue.length, ...retained);
cancelled.forEach((entry) => entry.resolve(false));
if (pending.value?.scopeId === scopeId) settle(false);
}
return { pending, requestConfirmation, confirm, cancel, cancelAll, cancelScope };
});
@@ -0,0 +1,194 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { uuid } from "@/lib/common/utils";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { dedupeMultiDbExecutionTargets, executionTargetGroupNameKey, normalizeMultiDbExecutionTarget, type MultiDbExecutionTarget, type SqlExecutionTargetDatabaseTypeResolver, type SqlExecutionTargetGroup } from "@/types/sqlExecution";
import type { DatabaseType } from "@/types/database";
const STORAGE_KEY = "dbx-sql-execution-target-groups";
const STORAGE_VERSION = 1;
interface PersistedTargetGroupState {
version: number;
groups: SqlExecutionTargetGroup[];
}
function nowIso(): string {
return new Date().toISOString();
}
function normalizeGroup(value: Partial<SqlExecutionTargetGroup>): SqlExecutionTargetGroup | undefined {
const name = typeof value.name === "string" ? value.name.trim() : "";
const databaseType = value.databaseType;
if (!value.id || !name || !databaseType || !Array.isArray(value.targets)) return undefined;
const targets = dedupeMultiDbExecutionTargets(value.targets);
if (targets.length === 0) return undefined;
const createdAt = typeof value.createdAt === "string" && value.createdAt ? value.createdAt : nowIso();
const updatedAt = typeof value.updatedAt === "string" && value.updatedAt ? value.updatedAt : createdAt;
return {
id: value.id,
name,
databaseType,
targets,
createdAt,
updatedAt,
lastUsedAt: typeof value.lastUsedAt === "string" && value.lastUsedAt ? value.lastUsedAt : undefined,
};
}
function sortGroups(groups: readonly SqlExecutionTargetGroup[]): SqlExecutionTargetGroup[] {
return [...groups].sort((a, b) => {
const aTime = a.lastUsedAt ?? a.updatedAt;
const bTime = b.lastUsedAt ?? b.updatedAt;
const timeDiff = bTime.localeCompare(aTime);
if (timeDiff !== 0) return timeDiff;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
});
}
function readGroups(): SqlExecutionTargetGroup[] {
const raw = safeLocalStorageGet(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<PersistedTargetGroupState> | SqlExecutionTargetGroup[];
const values = Array.isArray(parsed) ? parsed : Array.isArray(parsed.groups) ? parsed.groups : [];
const groups: SqlExecutionTargetGroup[] = [];
const names = new Set<string>();
for (const value of values) {
const group = normalizeGroup(value);
if (!group) continue;
const nameKey = executionTargetGroupNameKey(group.name);
if (names.has(nameKey)) continue;
names.add(nameKey);
groups.push(group);
}
return sortGroups(groups);
} catch (error) {
console.warn("[DBX][sql-execution-target-groups:load] invalid persisted state", error);
return [];
}
}
function writeGroups(groups: readonly SqlExecutionTargetGroup[]): void {
const state: PersistedTargetGroupState = {
version: STORAGE_VERSION,
groups: sortGroups(groups),
};
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(state));
}
export const useSqlExecutionTargetGroupStore = defineStore("sqlExecutionTargetGroup", () => {
const groups = ref<SqlExecutionTargetGroup[]>(readGroups());
function hasName(name: string, exceptId?: string): boolean {
const key = executionTargetGroupNameKey(name);
return groups.value.some((group) => group.id !== exceptId && executionTargetGroupNameKey(group.name) === key);
}
function assertGroupName(name: string, exceptId?: string): string {
const normalized = name.trim();
if (!normalized) throw new Error("目标组名称不能为空");
if (hasName(normalized, exceptId)) throw new Error("目标组名称已存在");
return normalized;
}
function assertTargets(targets: readonly MultiDbExecutionTarget[], expectedDatabaseType?: DatabaseType, resolveDatabaseType?: SqlExecutionTargetDatabaseTypeResolver): MultiDbExecutionTarget[] {
const normalized = dedupeMultiDbExecutionTargets(targets);
if (normalized.length === 0) throw new Error("目标组至少需要一个执行目标");
if (expectedDatabaseType && resolveDatabaseType) {
const mismatched = normalized.find((target) => resolveDatabaseType(target) !== expectedDatabaseType);
if (mismatched) throw new Error("目标组只能包含同一种数据库类型的执行目标");
}
return normalized;
}
function persist(): void {
groups.value = sortGroups(groups.value);
writeGroups(groups.value);
}
function createGroup(input: { name: string; databaseType: DatabaseType; targets: readonly MultiDbExecutionTarget[]; resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver }): SqlExecutionTargetGroup {
const timestamp = nowIso();
const group: SqlExecutionTargetGroup = {
id: uuid(),
name: assertGroupName(input.name),
databaseType: input.databaseType,
targets: assertTargets(input.targets, input.databaseType, input.resolveDatabaseType),
createdAt: timestamp,
updatedAt: timestamp,
};
groups.value = [...groups.value, group];
persist();
return group;
}
function updateGroup(id: string, input: { name?: string; targets?: readonly MultiDbExecutionTarget[]; resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver }): SqlExecutionTargetGroup | undefined {
const existing = groups.value.find((group) => group.id === id);
if (!existing) return undefined;
const updated: SqlExecutionTargetGroup = {
...existing,
...(input.name !== undefined ? { name: assertGroupName(input.name, id) } : {}),
...(input.targets !== undefined ? { targets: assertTargets(input.targets, existing.databaseType, input.resolveDatabaseType) } : {}),
updatedAt: nowIso(),
};
groups.value = groups.value.map((group) => (group.id === id ? updated : group));
persist();
return updated;
}
function deleteGroup(id: string): void {
groups.value = groups.value.filter((group) => group.id !== id);
persist();
}
function markGroupUsed(id: string): SqlExecutionTargetGroup | undefined {
const existing = groups.value.find((group) => group.id === id);
if (!existing) return undefined;
const updated = { ...existing, lastUsedAt: nowIso() };
groups.value = groups.value.map((group) => (group.id === id ? updated : group));
persist();
return updated;
}
function getGroup(id: string): SqlExecutionTargetGroup | undefined {
return groups.value.find((group) => group.id === id);
}
function getGroupsByDatabaseType(databaseType?: DatabaseType): SqlExecutionTargetGroup[] {
return sortGroups(databaseType ? groups.value.filter((group) => group.databaseType === databaseType) : groups.value);
}
function replaceTargets(id: string, targets: readonly MultiDbExecutionTarget[], resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver): SqlExecutionTargetGroup | undefined {
return updateGroup(id, { targets, resolveDatabaseType });
}
function cloneGroup(id: string, name: string, targets: readonly MultiDbExecutionTarget[] | undefined, resolveDatabaseType: SqlExecutionTargetDatabaseTypeResolver): SqlExecutionTargetGroup | undefined {
const existing = getGroup(id);
if (!existing) return undefined;
return createGroup({
name,
databaseType: existing.databaseType,
targets: targets ?? existing.targets,
resolveDatabaseType,
});
}
function normalizeTarget(target: MultiDbExecutionTarget): MultiDbExecutionTarget | undefined {
return normalizeMultiDbExecutionTarget(target);
}
return {
groups,
createGroup,
updateGroup,
replaceTargets,
cloneGroup,
deleteGroup,
markGroupUsed,
getGroup,
getGroupsByDatabaseType,
normalizeTarget,
};
});
export { STORAGE_KEY as SQL_EXECUTION_TARGET_GROUPS_STORAGE_KEY };
+4
View File
@@ -1,4 +1,5 @@
import type { BackendError } from "@/lib/backend/errorUtils";
import type { MultiDbResultRunExecution } from "@/types/sqlExecution";
export type DatabaseType =
| "mysql"
@@ -709,6 +710,7 @@ export interface QueryResultRun {
resultTotalRowCount?: number;
resultTotalRowCountLoading?: boolean;
resultSessionId?: string;
resultClientSessionId?: string;
resultAccessedAt?: number;
resultEstimatedBytes?: number;
resultCacheKey?: string;
@@ -719,6 +721,7 @@ export interface QueryResultRun {
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
tableMeta?: QueryTab["tableMeta"];
multiDbExecution?: MultiDbResultRunExecution;
}
export interface ParticipantInfo {
@@ -987,6 +990,7 @@ export interface QueryTab {
resultTotalRowCount?: number;
resultTotalRowCountLoading?: boolean;
resultSessionId?: string;
resultClientSessionId?: string;
resultAccessedAt?: number;
resultEstimatedBytes?: number;
resultCacheKey?: string;
+88
View File
@@ -0,0 +1,88 @@
import type { DatabaseType } from "@/types/database";
export interface MultiDbExecutionTarget {
connectionId: string;
catalog?: string;
database: string;
schema?: string;
}
export interface SqlExecutionTargetGroup {
id: string;
name: string;
databaseType: DatabaseType;
targets: MultiDbExecutionTarget[];
createdAt: string;
updatedAt: string;
lastUsedAt?: string;
}
export type SqlExecutionTargetValidationState = "valid" | "invalid" | "needsRecheck";
export type MultiDbExecutionItemStatus = "pending" | "running" | "success" | "failed" | "skipped" | "cancelled" | "not_executed";
export interface MultiDbResultRunExecution {
kind: "multi-db";
batchId: string;
target: MultiDbExecutionTarget;
title?: string;
status: MultiDbExecutionItemStatus;
durationMs?: number;
errorMessage?: string;
}
export interface MultiDbTargetExecutionResult {
status: Exclude<MultiDbExecutionItemStatus, "pending" | "running" | "not_executed">;
errorMessage?: string;
durationMs?: number;
}
export interface SqlExecutionTargetValidation {
target: MultiDbExecutionTarget;
state: SqlExecutionTargetValidationState;
reason?: string;
}
/** Resolves the effective database type for a persisted execution target. */
export type SqlExecutionTargetDatabaseTypeResolver = (target: MultiDbExecutionTarget) => DatabaseType | undefined;
export function normalizeOptionalExecutionTargetPart(value?: string): string | undefined {
const normalized = typeof value === "string" ? value.trim() : undefined;
return normalized ? normalized : undefined;
}
export function multiDbExecutionTargetKey(target: MultiDbExecutionTarget): string {
return JSON.stringify([target.connectionId, normalizeOptionalExecutionTargetPart(target.catalog), target.database, normalizeOptionalExecutionTargetPart(target.schema)]);
}
export function normalizeMultiDbExecutionTarget(target: MultiDbExecutionTarget): MultiDbExecutionTarget | undefined {
if (!target || typeof target !== "object") return undefined;
const raw = target as Partial<MultiDbExecutionTarget>;
const connectionId = typeof raw.connectionId === "string" ? raw.connectionId.trim() : "";
const database = typeof raw.database === "string" ? raw.database.trim() : undefined;
if (!connectionId || database === undefined) return undefined;
return {
connectionId,
database,
catalog: normalizeOptionalExecutionTargetPart(raw.catalog),
schema: normalizeOptionalExecutionTargetPart(raw.schema),
};
}
export function dedupeMultiDbExecutionTargets(targets: readonly MultiDbExecutionTarget[]): MultiDbExecutionTarget[] {
const result: MultiDbExecutionTarget[] = [];
const seen = new Set<string>();
for (const candidate of targets) {
const target = normalizeMultiDbExecutionTarget(candidate);
if (!target) continue;
const key = multiDbExecutionTargetKey(target);
if (seen.has(key)) continue;
seen.add(key);
result.push(target);
}
return result;
}
export function executionTargetGroupNameKey(name: string): string {
return name.trim().toLocaleLowerCase();
}
@@ -63,6 +63,8 @@ pub struct QueryResultExportRequest {
pub database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
pub sql: String,
pub query_base_sql: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -712,6 +714,7 @@ async fn export_query_result_core_inner(
timeout_secs: request.timeout_secs,
client_session_id: request.client_session_id.clone(),
execution_id: request.execution_id.clone(),
catalog: request.catalog.clone(),
..Default::default()
}
} else {
@@ -721,6 +724,7 @@ async fn export_query_result_core_inner(
timeout_secs: request.timeout_secs,
client_session_id: request.client_session_id.clone(),
execution_id: request.execution_id.clone(),
catalog: request.catalog.clone(),
..Default::default()
}
};
@@ -1788,6 +1792,7 @@ mod tests {
connection_id: "conn-1".to_string(),
database: "db".to_string(),
schema: None,
catalog: None,
sql: "SELECT * FROM users".to_string(),
query_base_sql: "SELECT * FROM users".to_string(),
setup_sql: Vec::new(),
@@ -84,6 +84,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc
connection_id: connection_id.clone(),
database: database.clone(),
schema: None,
catalog: None,
sql: sql.clone(),
query_base_sql: sql,
setup_sql: Vec::new(),
+2
View File
@@ -185,6 +185,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic
connection_id: connection_id.clone(),
database: database.clone(),
schema: None,
catalog: None,
sql: sql.clone(),
query_base_sql: sql,
setup_sql: Vec::new(),
@@ -274,6 +275,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi
connection_id: connection_id.clone(),
database: database.clone(),
schema: None,
catalog: None,
sql: sql.clone(),
query_base_sql: sql,
setup_sql: Vec::new(),
@@ -122,6 +122,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
connection_id: connection_id.to_string(),
database: database.clone(),
schema: Some(schema.clone()),
catalog: None,
sql: sql.clone(),
query_base_sql: sql,
setup_sql: Vec::new(),
@@ -201,6 +202,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() {
connection_id: connection_id.to_string(),
database: database.clone(),
schema: Some(schema.clone()),
catalog: None,
sql: format!("SELECT day, created_at, label FROM \"{schema}\".events"),
query_base_sql: format!("SELECT day, created_at, label FROM \"{schema}\".events"),
setup_sql: Vec::new(),
@@ -273,6 +275,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() {
connection_id: connection_id.clone(),
database: database.clone(),
schema: Some("public".to_string()),
catalog: None,
sql: sql.clone(),
query_base_sql: sql.clone(),
setup_sql: setup_sql.clone(),
@@ -348,6 +351,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr
connection_id,
database,
schema: Some("public".to_string()),
catalog: None,
sql: sql.to_string(),
query_base_sql: sql.to_string(),
setup_sql: Vec::new(),
@@ -414,6 +418,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() {
connection_id: connection_id.clone(),
database: database.clone(),
schema: Some("public".to_string()),
catalog: None,
sql: sql.to_string(),
query_base_sql: sql.to_string(),
setup_sql: Vec::new(),
@@ -1178,6 +1178,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() {
connection_id: connection_id.to_string(),
database: database.clone(),
schema: Some("dbo".to_string()),
catalog: None,
sql: sql.clone(),
query_base_sql: sql,
setup_sql: Vec::new(),
@@ -97,6 +97,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar
connection_id: connection_id.to_string(),
database: database.clone(),
schema: Some("dbo".to_string()),
catalog: None,
sql: sql.to_string(),
query_base_sql: sql.to_string(),
setup_sql: Vec::new(),