import { ref, watch, type Ref, type ComputedRef } from "vue"; import { useI18n } from "vue-i18n"; import { useQueryStore } from "@/stores/queryStore"; import { useHistoryStore } from "@/stores/historyStore"; 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"; import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache"; import { defaultViewForResult } from "@/lib/query/queryResultDefaultView"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety"; import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget"; import { isElasticsearchRestRequestText, parseElasticsearchRestRequestTarget, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges"; import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters"; import { expandSqlVariables } from "@/lib/sql/sqlVariables"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { assessProductionSql } from "@/lib/database/productionSafety"; import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess"; 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"; import { translateBackendError } from "@/i18n/backend-errors"; const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; interface SqlExecutionOptions { openInNewResultTab?: boolean; editorViewportRequestId?: number; /** Tab that owns this execution request; resolved once, never re-read from the global active tab. */ tabId?: string; } /** * Targeting context captured synchronously when an execution request starts. * Async resume points (danger / parameter dialogs, awaits) must keep using it * instead of re-reading the global active tab or active connection. */ interface SqlExecutionContext { tabId: string; connection: ConnectionConfig | undefined; } 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, " ") .replace(/--.*$/gm, " ") .replace(/#.*$/gm, " "); } const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i]; const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i]; function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD", path: string): boolean { const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/"; if (method === "DELETE") return !ELASTICSEARCH_TRANSIENT_DELETE_PATHS.some((pattern) => pattern.test(pathname)); if (method === "PUT" || method === "PATCH") return true; return method === "POST" && ELASTICSEARCH_DESTRUCTIVE_POST_PATHS.some((pattern) => pattern.test(pathname)); } function isDangerousMeilisearchRequest(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD", path: string): boolean { if (method === "GET" || method === "HEAD") return false; if (method === "DELETE" || method === "PUT" || method === "PATCH") return true; const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/"; return !(pathname === "/multi-search" || /\/search$/i.test(pathname) || /\/facet-search$/i.test(pathname) || /\/similar$/i.test(pathname) || /\/documents\/fetch$/i.test(pathname)); } export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean { if (databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch") { const requests = splitSqlStatementRanges(sql, databaseType) .map((statement) => parseElasticsearchRestRequestTarget(statement.sql)) .filter((request): request is NonNullable => request !== null); if (requests.length > 0) { return requests.some((request) => (databaseType === "meilisearch" ? isDangerousMeilisearchRequest(request.method, request.path) : isDangerousElasticsearchRequest(request.method, request.path))); } } const cleaned = stripSqlComments(sql); return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt)); } function primarySqlOperation(sql: string): string { const cleaned = stripSqlComments(sql); const statement = cleaned .split(";") .map((part) => part.trim()) .find(Boolean); return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL"; } function firstQueryExecutionError(tab: Pick) { const activeResult = tab.result; if (activeResult && isQueryExecutionErrorResult(activeResult)) return activeResult; const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : []; return results.find((result) => isQueryExecutionErrorResult(result)); } export function useSqlExecution(deps: { activeTab: ComputedRef; activeConnection: ComputedRef; executableSql: ComputedRef; resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise; activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">; blockDangerousRedisCommands?: Ref; onMissingDatabase?: (tabId?: string) => void; requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise; onExecutionStarted?: (editorViewportRequestId: number) => void; onExecutionCancelled?: (editorViewportRequestId: number) => void; }) { const { t } = useI18n(); const queryStore = useQueryStore(); const historyStore = useHistoryStore(); const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); const productionSafetyStore = useProductionSafetyStore(); const { toast } = useToast(); const dangerSql = ref(""); const pendingDangerSql = ref(""); const showDangerDialog = ref(false); const suppressDangerConfirm = ref(false); const explainMode = ref<"explain" | "autotrace">("explain"); const showSqlParameterDialog = ref(false); const sqlParameterSourceSql = ref(""); const sqlParameterNames = ref([]); const sqlParameterDatabaseType = ref(); const sqlParameterEnabledSyntaxes = ref([]); const pendingSourceOffset = ref(); const pendingDangerKind = ref<"sql" | "redis">("sql"); const pendingDangerSourceOffset = ref(); const pendingOpenInNewResultTab = ref(false); const pendingSqlParameterEditorViewportRequestId = ref(); const pendingDangerEditorViewportRequestId = ref(); const pendingDangerTabId = ref(); const pendingSqlParameterTabId = ref(); let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise | void) | undefined; function cancelEditorViewportRequest(editorViewportRequestId?: number) { if (editorViewportRequestId !== undefined) deps.onExecutionCancelled?.(editorViewportRequestId); } function resolveExecutionTab(tabId: string | undefined): QueryTab | undefined { if (!tabId) { return deps.activeTab.value; } const fromStore = queryStore.tabs.find((tab) => tab.id === tabId); if (fromStore) { return fromStore; } // The host may hold the acting tab outside the store array (multi-db // workers, hosts under test); identity still comes from the captured id. return deps.activeTab.value?.id === tabId ? deps.activeTab.value : undefined; } function resolveExecutionConnection(tabId: string | undefined): ConnectionConfig | undefined { const tab = resolveExecutionTab(tabId); return (tab ? connectionStore.getConfig(tab.connectionId) : undefined) ?? deps.activeConnection.value; } function captureExecutionContext(tabId?: string): SqlExecutionContext | undefined { const tab = resolveExecutionTab(tabId); if (!tab) { return undefined; } return { tabId: tab.id, connection: resolveExecutionConnection(tabId) }; } async function resolvedExecutableSql(source: SqlExecutionOverride | undefined, context: SqlExecutionContext): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> { const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, context.connection?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet; const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type; const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql); if (typeof source === "string") return { sql: expand(source) }; const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source, resolveExecutionTab(context.tabId)) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value; const declarationSql = isSqlExecutionSnapshot(source) ? source.fullSql.slice(0, source.selectionTo) : resolved; const sql = expand(resolved, declarationSql); const editorViewportRequestId = isSqlExecutionSnapshot(source) ? source.editorViewportRequestId : undefined; if (!isSqlExecutionSnapshot(source) || !source.selectedSql.trim() || sql !== resolved) return { sql, editorViewportRequestId }; const leadingWhitespace = source.selectedSql.length - source.selectedSql.trimStart().length; return { sql, sourceOffset: source.selectionFrom + leadingWhitespace, editorViewportRequestId }; } async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) { const context = captureExecutionContext(options.tabId); if (!context) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context); const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId }; if (!sql.trim()) { cancelEditorViewportRequest(editorViewportRequestId); return; } const tab = resolveExecutionTab(context.tabId); if (!tab) { cancelEditorViewportRequest(editorViewportRequestId); return; } if (requiresDatabaseSelection(tab, context.connection, sql)) { deps.onMissingDatabase?.(context.tabId); cancelEditorViewportRequest(editorViewportRequestId); return; } if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) { return; } await continueExecute(context, sql, sourceOffset, executionOptions); } function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) { return tryExecute(sqlOverride, { ...options, openInNewResultTab: true }); } async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) { const tab = resolveExecutionTab(context.tabId); if (!tab) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } // Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level) if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) { const commands = sql .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); let highestSafety: "allowed" | "write" | "confirm" | "blocked" = "allowed"; for (const cmd of commands) { const safety = classifyRedisCommandSafety(cmd); if (safety === "blocked") { highestSafety = "blocked"; break; } if (safety === "confirm") { highestSafety = "confirm"; } } if (highestSafety === "blocked") { toast(t("redis.blockedCommand", { command: "Redis" }), 5000); cancelEditorViewportRequest(options.editorViewportRequestId); return; } if (highestSafety === "confirm") { dangerSql.value = sql; pendingDangerSql.value = sql; pendingDangerKind.value = "redis"; pendingDangerSourceOffset.value = sourceOffset; pendingOpenInNewResultTab.value = options.openInNewResultTab === true; pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId; pendingDangerTabId.value = context.tabId; suppressDangerConfirm.value = false; showDangerDialog.value = true; return; } } const productionAssessment = assessProductionSql(sql, context.connection, tab.database); if (productionAssessment.active && productionAssessment.isMutation) { // Production writes always need a new explicit decision; editor preferences cannot suppress this gate. const confirmed = await productionSafetyStore.requestConfirmation({ sql, connectionName: context.connection?.name, database: tab.database, productionDatabases: productionAssessment.databases, source: t("production.sourceSqlEditor"), }); if (confirmed) { await doExecute(sql, sourceOffset, options); } else { cancelEditorViewportRequest(options.editorViewportRequestId); } return; } if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) { dangerSql.value = sql; pendingDangerSql.value = sql; pendingDangerKind.value = "sql"; pendingDangerSourceOffset.value = sourceOffset; pendingOpenInNewResultTab.value = options.openInNewResultTab === true; pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId; pendingDangerTabId.value = context.tabId; suppressDangerConfirm.value = false; showDangerDialog.value = true; } else { await doExecute(sql, sourceOffset, options); } } function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise | void): boolean { const connection = resolveExecutionConnection(options.tabId); const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type; const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled); const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles); const parameters = extractSqlParameterDescriptors(sql, { databaseType, enabledSyntaxes }); if (!parameters.length) { return false; } sqlParameterSourceSql.value = sql; sqlParameterNames.value = parameters; sqlParameterDatabaseType.value = databaseType; sqlParameterEnabledSyntaxes.value = enabledSyntaxes; pendingSourceOffset.value = sourceOffset; pendingOpenInNewResultTab.value = options.openInNewResultTab === true; pendingSqlParameterEditorViewportRequestId.value = options.editorViewportRequestId; pendingSqlParameterTabId.value = options.tabId; pendingSqlParameterContinuation = continuation; showSqlParameterDialog.value = true; return true; } async function prepareMultiExecute(onReady: (sql: string, sourceOffset?: number) => Promise | void): Promise { const context = captureExecutionContext(); if (!context) { return false; } const { sql, sourceOffset } = await resolvedExecutableSql(undefined, context); if (!sql.trim()) return false; if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, onReady)) { return true; } await onReady(sql, sourceOffset); return false; } // SQL Server batches that end in PRINT/DBCC-style messages with no rows of their own get // synthesized into a "Message" pseudo-result (server_message: true). The store's generic // "first result with columns" pick can land on that pseudo-result instead of real data, so // whenever it does, redirect focus to the first real data result (falling back to the // message itself only if there is no data result to show). Shared by every SQL execution // entry point so none of them can regress independently (see #6189). function focusSqlServerDataResult(executionTabId: string, executionDatabaseType: DatabaseType | undefined, tab: Pick) { if (executionDatabaseType !== "sqlserver") return; const sqlServerMessageResultIndex = tab.results?.findIndex((result) => result.server_message === true); if (sqlServerMessageResultIndex === undefined || sqlServerMessageResultIndex < 0) return; const activeSqlServerResult = tab.results && tab.activeResultIndex !== undefined ? tab.results[tab.activeResultIndex] : tab.result; if (activeSqlServerResult?.server_message !== true) return; const sqlServerDataResultIndex = tab.results?.findIndex((result) => result.server_message !== true && !isQueryExecutionErrorResult(result) && result.columns.length > 0); queryStore.setActiveResultIndex(executionTabId, sqlServerDataResultIndex !== undefined && sqlServerDataResultIndex >= 0 ? sqlServerDataResultIndex : sqlServerMessageResultIndex); } async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) { if (sql === undefined) { const context = captureExecutionContext(options.tabId); if (!context) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } ({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context)); } const executionTabId = options.tabId ?? deps.activeTab.value?.id; if (!executionTabId || !sql || !sql.trim()) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } const tab = resolveExecutionTab(executionTabId); if (!tab) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value; const executionDatabaseType = executionConnection?.db_type; if (requiresDatabaseSelection(tab, executionConnection, sql)) { deps.onMissingDatabase?.(executionTabId); cancelEditorViewportRequest(options.editorViewportRequestId); return; } const statementCount = splitSqlStatementRanges(sql, executionDatabaseType, sqlStatementParameterOptionsForCompatibility(executionDatabaseType, executionDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(tab.connectionId, tab.database) : undefined)).length; // Output-view switching belongs to the tab the user is looking at — both // when the query starts and when it finishes. if (deps.activeTab.value?.id === executionTabId) { deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result"; } const connName = executionConnection?.name || ""; const start = Date.now(); const isRedis = executionDatabaseType === "redis"; const producedResult = await queryStore.executeCurrentSql(sql, { tabId: executionTabId, ...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}), ...(sourceOffset !== undefined ? { sourceOffset } : {}), ...(options.openInNewResultTab ? { openInNewResultTab: true } : {}), ...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}), }); if (producedResult === false) { cancelEditorViewportRequest(options.editorViewportRequestId); return; } const executionTabStillActive = deps.activeTab.value?.id === executionTabId; const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined; if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) { focusSqlServerDataResult(tab.id, executionDatabaseType, tab); if (executionTabStillActive) { deps.activeOutputView.value = tab.result?.server_message === true ? "messages" : "result"; } } else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) { if (executionTabStillActive) { deps.activeOutputView.value = "messages"; } } else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) { if (executionTabStillActive) { deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary"; } } const elapsed = Date.now() - start; const failure = firstQueryExecutionError(tab); const success = !failure; historyStore.add({ connection_id: tab.connectionId, connection_name: connName, database: tab.database, sql, execution_time_ms: elapsed, success, error: failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? "")) : undefined, activity_kind: classifySqlActivityKind(sql), operation: primarySqlOperation(sql), affected_rows: success ? tab.result?.affected_rows : undefined, }); if (success) { const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema); if (refreshTarget.scope === "connection") { connectionStore.invalidateMetadataCache(tab.connectionId); await invalidateObjectMetadataCache({ connectionId: tab.connectionId }); await connectionStore.loadDatabases(tab.connectionId, { force: true }); } else if (refreshTarget.scope === "database") { await invalidateObjectMetadataCache({ connectionId: tab.connectionId, database: tab.database, schema: refreshTarget.schema }); await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema); } } } async function executeTargetSql(input: TargetSqlExecutionInput): Promise { 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 (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceMultiDbSql") }))) { return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") }); } 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" }); } focusSqlServerDataResult(executionTabId, connection.db_type, latest); const failure = firstQueryExecutionError(latest); const errorMessage = failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : 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") { connectionStore.invalidateMetadataCache(executionTab.connectionId); await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId }); await connectionStore.loadDatabases(executionTab.connectionId, { force: true }); } else if (refreshTarget.scope === "database") { await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema }); 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.activeTab.value?.id === tab.id) { deps.activeOutputView.value = success && latest.result?.server_message === true ? "messages" : 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); } } /** * Opens the danger dialog for an App-level flow (AI auto-execution) that has * already made its own gating decision. The acting tab id is captured here so * the confirmation resumes against the requesting tab, not whichever tab is * active when the user confirms. */ function requestDangerConfirmation(sql: string, tabId?: string) { dangerSql.value = sql; pendingDangerSql.value = sql; pendingDangerKind.value = "sql"; pendingOpenInNewResultTab.value = false; pendingDangerTabId.value = tabId; suppressDangerConfirm.value = false; showDangerDialog.value = true; } function cancelActiveExecution(tabId?: string) { const tab = resolveExecutionTab(tabId); if (!tab) { return; } if (tab.isExecuting) { void queryStore.cancelTabExecution(tab.id); } else if (tab.isExplaining) { void queryStore.cancelTabExplain(tab.id); } } function explainReasonMessage(reason: string): string { if (reason === "unsupported") { return t("explain.unsupported"); } if (reason === "unsafe") { return t("explain.unsafe"); } return t("explain.emptySql"); } async function runExplain(sql: string, context: SqlExecutionContext) { const tab = resolveExecutionTab(context.tabId); if (!tab || !sql.trim()) { toast(t("explain.emptySql")); return; } if (deps.activeTab.value?.id === tab.id) { deps.activeOutputView.value = "explain"; } const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type; const result = await queryStore.explainTabSql(tab.id, sql, databaseType, explainMode.value); if (!result.ok) { toast(explainReasonMessage(result.reason), 5000); return; } const current = resolveExecutionTab(context.tabId); if (current?.explainError) { toast(current.explainError, 5000); } } async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) { const context = captureExecutionContext(options.tabId); if (!context) { return; } const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride, context); if (!sql.trim()) { toast(t("explain.emptySql")); return; } // Resolve SQL template variables exactly like tryExecute so EXPLAIN never runs the raw // placeholders (e.g. `EXPLAIN (FORMAT JSON) SELECT :var;` fails on PostgreSQL). if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, (resolvedSql) => runExplain(resolvedSql, context))) { return; } await runExplain(sql, context); } async function onDangerConfirm() { const sql = pendingDangerSql.value; const sourceOffset = pendingDangerSourceOffset.value; const kind = pendingDangerKind.value; const openInNewResultTab = pendingOpenInNewResultTab.value; const editorViewportRequestId = pendingDangerEditorViewportRequestId.value; const tabId = pendingDangerTabId.value; pendingDangerSql.value = ""; pendingDangerSourceOffset.value = undefined; pendingDangerKind.value = "sql"; pendingOpenInNewResultTab.value = false; pendingDangerEditorViewportRequestId.value = undefined; pendingDangerTabId.value = undefined; if (suppressDangerConfirm.value && kind === "sql") { settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false }); } suppressDangerConfirm.value = false; const tab = resolveExecutionTab(tabId); if (!tab) { return; } const connection = connectionStore.getConfig(tab.connectionId); if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) { cancelEditorViewportRequest(editorViewportRequestId); return; } await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }); } async function onSqlParametersConfirm(sql: string) { const openInNewResultTab = pendingOpenInNewResultTab.value; const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value; const tabId = pendingSqlParameterTabId.value; showSqlParameterDialog.value = false; sqlParameterSourceSql.value = ""; sqlParameterNames.value = []; sqlParameterDatabaseType.value = undefined; sqlParameterEnabledSyntaxes.value = []; const sourceOffset = pendingSourceOffset.value; pendingSourceOffset.value = undefined; pendingOpenInNewResultTab.value = false; pendingSqlParameterEditorViewportRequestId.value = undefined; pendingSqlParameterTabId.value = undefined; const continuation = pendingSqlParameterContinuation; pendingSqlParameterContinuation = undefined; if (continuation) { await continuation(sql, sourceOffset); return; } const context = captureExecutionContext(tabId); if (!context) { return; } await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId }); } watch(showSqlParameterDialog, (open) => { if (open) return; const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value; sqlParameterSourceSql.value = ""; sqlParameterNames.value = []; sqlParameterDatabaseType.value = undefined; sqlParameterEnabledSyntaxes.value = []; pendingSourceOffset.value = undefined; pendingOpenInNewResultTab.value = false; pendingSqlParameterEditorViewportRequestId.value = undefined; pendingSqlParameterTabId.value = undefined; pendingSqlParameterContinuation = undefined; cancelEditorViewportRequest(editorViewportRequestId); }); watch(showDangerDialog, (open) => { if (open) return; const editorViewportRequestId = pendingDangerEditorViewportRequestId.value; pendingDangerSql.value = ""; pendingDangerSourceOffset.value = undefined; pendingDangerKind.value = "sql"; pendingOpenInNewResultTab.value = false; pendingDangerEditorViewportRequestId.value = undefined; pendingDangerTabId.value = undefined; suppressDangerConfirm.value = false; cancelEditorViewportRequest(editorViewportRequestId); }); return { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, tryExecuteInNewResultTab, doExecute, cancelActiveExecution, requestDangerConfirmation, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, sqlParameterDatabaseType, sqlParameterEnabledSyntaxes, onSqlParametersConfirm, prepareMultiExecute, executeTargetSql, explainMode, }; } export function supportsSqlTemplateParameters(connection: Pick | undefined, sql = ""): boolean { if (!connection) return false; if (connection.db_type === "meilisearch") return false; if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql); return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics"; } 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(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 !supportsConnectionScopedQueryExecution(databaseType); }