mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 14:54:46 +00:00
feat(execution): multi-database SQL execution with scoped targets
This commit is contained in:
@@ -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[] {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "プロセス一覧",
|
||||
|
||||
@@ -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: "데이터베이스 워크스페이스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 文件执行",
|
||||
|
||||
@@ -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);
|
||||
|
||||
+123
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user