828 lines
39 KiB
TypeScript
828 lines
39 KiB
TypeScript
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 } 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<typeof request> => 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<QueryTab, "result" | "results">) {
|
|
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<QueryTab | undefined>;
|
|
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
|
executableSql: ComputedRef<string>;
|
|
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise<string>;
|
|
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">;
|
|
blockDangerousRedisCommands?: Ref<boolean>;
|
|
onMissingDatabase?: (tabId?: string) => void;
|
|
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
|
|
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<SqlParameterDescriptor[]>([]);
|
|
const sqlParameterDatabaseType = ref<DatabaseType | undefined>();
|
|
const sqlParameterEnabledSyntaxes = ref<SqlParameterSyntax[]>([]);
|
|
const pendingSourceOffset = ref<number | undefined>();
|
|
const pendingDangerKind = ref<"sql" | "redis">("sql");
|
|
const pendingDangerSourceOffset = ref<number | undefined>();
|
|
const pendingOpenInNewResultTab = ref(false);
|
|
const pendingSqlParameterEditorViewportRequestId = ref<number | undefined>();
|
|
const pendingDangerEditorViewportRequestId = ref<number | undefined>();
|
|
const pendingDangerTabId = ref<string | undefined>();
|
|
const pendingSqlParameterTabId = ref<string | undefined>();
|
|
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | 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> | 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> | void): Promise<boolean> {
|
|
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<QueryTab, "results" | "result" | "activeResultIndex">) {
|
|
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).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 = "result";
|
|
}
|
|
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
|
|
if (executionTabStillActive) {
|
|
deps.activeOutputView.value = "result";
|
|
}
|
|
} 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<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 (!(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?.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<ConnectionConfig, "db_type"> | 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);
|
|
}
|