+
{{ t("explain.running") }}
+
+
+
+
+
+
+
+
+ | {{ column }} |
+
+
+
+
+ |
+ {{ tableCellText(row[columnIndex]) }}
+ |
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue
index c6ce0995aa..28d8af1d4a 100644
--- a/apps/desktop/src/components/layout/ContentArea.vue
+++ b/apps/desktop/src/components/layout/ContentArea.vue
@@ -297,7 +297,7 @@ const activeQueryError = computed(() => {
if (!result?.columns.includes("Error")) return "";
return String(result.rows[0]?.[0] ?? "");
});
-const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
+const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const allResultExportSheets = computed(() =>
@@ -1001,7 +1001,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('explain.title')"
:aria-label="t('explain.title')"
- :disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
+ :disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.explainTableResult && !activeTab.explainTableError && !activeTab.isExplaining"
@click="emit('update:activeOutputView', 'explain')"
>
@@ -1234,7 +1234,17 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
-
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 99ffa8b54f..1477263de2 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1097,6 +1097,7 @@ export default {
},
explain: {
title: "Explain Plan",
+ standardTable: "Table",
tree: "Tree",
summary: "Summary",
running: "Reading explain plan...",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 49eb39bf6b..bea7286478 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1041,6 +1041,7 @@ export default withEnglishFallback({
},
explain: {
title: "Plan de ejecución",
+ standardTable: "Tabla",
tree: "Árbol",
summary: "Resumen",
running: "Leyendo plan de ejecución...",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 0736ae1342..a79c12ad67 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1039,6 +1039,7 @@ export default withEnglishFallback({
},
explain: {
title: "Piano di Spiegazione",
+ standardTable: "Tabella",
tree: "Albero",
summary: "Riepilogo",
running: "Lettura del piano di spiegazione...",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 48b902b40f..8d75557f85 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1040,6 +1040,7 @@ export default withEnglishFallback({
},
explain: {
title: "実行計画",
+ standardTable: "標準テーブル",
tree: "ツリー",
summary: "サマリー",
running: "実行計画を読み取り中...",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 8c578ee643..a100d1af3b 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1041,6 +1041,7 @@ export default withEnglishFallback({
},
explain: {
title: "Plano de Execução",
+ standardTable: "Tabela",
tree: "Árvore",
summary: "Resumo",
running: "Lendo plano de execução...",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 0ce032bed3..57fd02343b 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1098,6 +1098,7 @@ export default withEnglishFallback({
},
explain: {
title: "执行计划",
+ standardTable: "标准表格",
tree: "树",
summary: "摘要",
running: "正在读取执行计划...",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index f17faeec13..ce12cd8c10 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1040,6 +1040,7 @@ export default withEnglishFallback({
},
explain: {
title: "執行計畫",
+ standardTable: "標準表格",
tree: "樹狀",
summary: "摘要",
running: "正在讀取執行計畫……",
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 34293bf0f6..d842cdd59d 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -281,6 +281,8 @@ export interface QuerySqlBuildResult {
export interface BuildExplainSqlOptions {
databaseType?: DatabaseType;
sql: string;
+ /** MySQL can return either the existing JSON plan or its native tabular plan. */
+ format?: "json" | "standard";
}
export interface ExplainSqlBuildResult {
diff --git a/apps/desktop/src/lib/diagram/explainPlan.ts b/apps/desktop/src/lib/diagram/explainPlan.ts
index 626d585424..136c48f7b1 100644
--- a/apps/desktop/src/lib/diagram/explainPlan.ts
+++ b/apps/desktop/src/lib/diagram/explainPlan.ts
@@ -28,8 +28,8 @@ export function supportsExplainPlan(databaseType?: DatabaseType): databaseType i
return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
}
-export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string): Promise
{
- return api.buildExplainSql({ databaseType, sql }) as Promise;
+export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string, format: "json" | "standard" = "json"): Promise {
+ return api.buildExplainSql({ databaseType, sql, format }) as Promise;
}
export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng" | "questdb", result: QueryResult): ParsedExplainPlan {
diff --git a/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts
new file mode 100644
index 0000000000..a95069de2c
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts
@@ -0,0 +1,254 @@
+import { createPinia, setActivePinia } from "pinia";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ParsedExplainPlan } from "@/lib/diagram/explainPlan";
+import type { QueryResult } from "@/types/database";
+
+const mocks = vi.hoisted(() => ({
+ buildExplainSql: vi.fn(),
+ parseExplainResult: vi.fn(),
+ parseDamengExplainText: vi.fn(),
+ parseOracleExplainText: vi.fn(),
+ executeQuery: vi.fn(),
+ cancelQuery: vi.fn(),
+ closeClientConnectionSession: vi.fn(),
+ saveOpenTabsState: vi.fn(),
+ getConfig: vi.fn(),
+}));
+
+vi.mock("@/lib/diagram/explainPlan", () => ({
+ buildExplainSql: mocks.buildExplainSql,
+ parseExplainResult: mocks.parseExplainResult,
+ parseDamengExplainText: mocks.parseDamengExplainText,
+ parseOracleExplainText: mocks.parseOracleExplainText,
+}));
+
+vi.mock("@/lib/backend/api", () => ({
+ executeQuery: mocks.executeQuery,
+ cancelQuery: mocks.cancelQuery,
+ closeClientConnectionSession: mocks.closeClientConnectionSession,
+ saveOpenTabsState: mocks.saveOpenTabsState,
+}));
+
+vi.mock("@/stores/connectionStore", () => ({
+ useConnectionStore: () => ({
+ getConfig: mocks.getConfig,
+ recordConnectionLostError: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/settingsStore", () => ({
+ useSettingsStore: () => ({
+ editorSettings: { pageSize: 100, openTabsRestoreMode: "all", confirmUnsavedSqlClose: false },
+ }),
+}));
+
+function installLocalStorage() {
+ const data = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn((key: string) => data.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => data.set(key, value)),
+ removeItem: vi.fn((key: string) => data.delete(key)),
+ });
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
+const standardSql = "EXPLAIN SELECT * FROM users WHERE status = 'active'";
+const jsonSql = "EXPLAIN FORMAT=JSON SELECT * FROM users WHERE status = 'active'";
+const sourceSql = "SELECT * FROM users WHERE status = 'active'";
+
+const tableResult: QueryResult = {
+ columns: ["id", "select_type", "table", "type", "rows", "Extra"],
+ rows: [[1, "SIMPLE", "users", "ref", 12, "Using where"]],
+ affected_rows: 0,
+ execution_time_ms: 4,
+};
+
+const jsonResult: QueryResult = {
+ columns: ["EXPLAIN"],
+ rows: [['{"query_block":{"select_id":1}}']],
+ affected_rows: 0,
+ execution_time_ms: 5,
+};
+
+const visualPlan: ParsedExplainPlan = {
+ databaseType: "mysql",
+ raw: { query_block: { select_id: 1 } },
+ nodes: [],
+};
+
+function configureSuccessfulBuilds() {
+ mocks.buildExplainSql.mockImplementation(async (_databaseType: string, _sql: string, format: "standard" | "json") => ({
+ ok: true,
+ sql: format === "standard" ? standardSql : jsonSql,
+ }));
+}
+
+describe("queryStore MySQL dual explain", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ installLocalStorage();
+ setActivePinia(createPinia());
+
+ mocks.getConfig.mockReturnValue({
+ id: "mysql-1",
+ name: "MySQL",
+ db_type: "mysql",
+ query_timeout_secs: 45,
+ });
+ mocks.cancelQuery.mockResolvedValue(true);
+ mocks.closeClientConnectionSession.mockResolvedValue(undefined);
+ mocks.saveOpenTabsState.mockResolvedValue(undefined);
+ mocks.parseExplainResult.mockReturnValue(visualPlan);
+ configureSuccessfulBuilds();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("executes standard EXPLAIN before JSON EXPLAIN using one execution and client session", async () => {
+ const standardExecution = deferred();
+ mocks.executeQuery.mockImplementationOnce(() => standardExecution.promise).mockResolvedValueOnce(jsonResult);
+
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", "analytics");
+ const explain = store.explainTabSql(tabId, sourceSql, "mysql");
+
+ await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(1));
+ expect(mocks.buildExplainSql).toHaveBeenNthCalledWith(1, "mysql", sourceSql, "standard");
+ expect(mocks.buildExplainSql).toHaveBeenNthCalledWith(2, "mysql", sourceSql, "json");
+ expect(mocks.executeQuery).toHaveBeenNthCalledWith(1, "mysql-1", "app", standardSql, "analytics", expect.any(String), expect.objectContaining({ timeoutSecs: 45 }));
+
+ standardExecution.resolve(tableResult);
+ await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
+ await explain;
+
+ const standardCall = mocks.executeQuery.mock.calls[0]!;
+ const jsonCall = mocks.executeQuery.mock.calls[1]!;
+ const executionId = standardCall[4] as string;
+ const standardOptions = standardCall[5] as { clientSessionId: string; timeoutSecs: number };
+ const jsonOptions = jsonCall[5] as { clientSessionId: string; timeoutSecs: number };
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+
+ expect(jsonCall[0]).toBe("mysql-1");
+ expect(jsonCall[1]).toBe("app");
+ expect(jsonCall[2]).toBe(jsonSql);
+ expect(jsonCall[3]).toBe("analytics");
+ expect(jsonCall[4]).toBe(executionId);
+ expect(jsonOptions.clientSessionId).toBe(standardOptions.clientSessionId);
+ expect(standardOptions.clientSessionId).toBe(`${tabId}:explain:${executionId}`);
+ expect(jsonOptions.timeoutSecs).toBe(45);
+ expect(mocks.parseExplainResult).toHaveBeenCalledWith("mysql", jsonResult);
+ expect(tab.explainTableResult).toEqual(tableResult);
+ expect(tab.explainPlan).toEqual(visualPlan);
+ expect(tab.explainTableSql).toBe(standardSql);
+ expect(tab.explainSql).toBe(jsonSql);
+ expect(tab.isExplaining).toBe(false);
+ expect(tab.explainExecutionId).toBeUndefined();
+ await vi.waitFor(() => expect(mocks.closeClientConnectionSession).toHaveBeenCalledWith("mysql-1", "app", standardOptions.clientSessionId));
+ });
+
+ it("keeps the JSON visual plan when the standard table EXPLAIN fails", async () => {
+ mocks.executeQuery.mockRejectedValueOnce(new Error("standard EXPLAIN failed")).mockResolvedValueOnce(jsonResult);
+
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+
+ await store.explainTabSql(tabId, sourceSql, "mysql");
+
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ expect(mocks.executeQuery).toHaveBeenNthCalledWith(1, "mysql-1", "app", standardSql, undefined, expect.any(String), expect.any(Object));
+ expect(mocks.executeQuery).toHaveBeenNthCalledWith(2, "mysql-1", "app", jsonSql, undefined, expect.any(String), expect.any(Object));
+ expect(tab.explainTableResult).toBeUndefined();
+ expect(tab.explainTableError).toBe("standard EXPLAIN failed");
+ expect(tab.explainPlan).toEqual(visualPlan);
+ expect(tab.explainError).toBeUndefined();
+ expect(tab.isExplaining).toBe(false);
+ });
+
+ it.each([
+ ["is acknowledged", () => mocks.cancelQuery.mockResolvedValue(true), true],
+ ["returns false", () => mocks.cancelQuery.mockResolvedValue(false), false],
+ ["rejects", () => mocks.cancelQuery.mockRejectedValue(new Error("cancel request failed")), false],
+ ])("does not start JSON EXPLAIN when cancellation %s during standard EXPLAIN", async (_label, configureCancel, expectedResult) => {
+ const standardExecution = deferred();
+ mocks.executeQuery.mockImplementationOnce(() => standardExecution.promise);
+
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+ const explain = store.explainTabSql(tabId, sourceSql, "mysql");
+
+ await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(1));
+ const executionId = store.tabs.find((item) => item.id === tabId)?.explainExecutionId;
+ expect(executionId).toEqual(expect.any(String));
+
+ configureCancel();
+ await expect(store.cancelTabExplain(tabId)).resolves.toBe(expectedResult);
+ expect(mocks.cancelQuery).toHaveBeenCalledWith(executionId);
+
+ standardExecution.resolve(tableResult);
+ await explain;
+
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ expect(mocks.executeQuery).toHaveBeenCalledTimes(1);
+ expect(tab.explainTableResult).toBeUndefined();
+ expect(tab.explainPlan).toBeUndefined();
+ expect(tab.isExplaining).toBe(false);
+ expect(tab.explainExecutionId).toBeUndefined();
+ });
+
+ it.each([
+ ["returns false", () => mocks.cancelQuery.mockResolvedValue(false)],
+ ["rejects", () => mocks.cancelQuery.mockRejectedValue(new Error("cancel request failed"))],
+ ])("does not start either EXPLAIN when cancellation %s while SQL formats are building", async (_label, configureCancel) => {
+ const standardBuild = deferred<{ ok: true; sql: string }>();
+ const jsonBuild = deferred<{ ok: true; sql: string }>();
+ mocks.buildExplainSql.mockImplementation((_databaseType: string, _sql: string, format: "standard" | "json") => (format === "standard" ? standardBuild.promise : jsonBuild.promise));
+
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+ const explain = store.explainTabSql(tabId, sourceSql, "mysql");
+
+ await vi.waitFor(() => expect(mocks.buildExplainSql).toHaveBeenCalledTimes(2));
+ configureCancel();
+ await expect(store.cancelTabExplain(tabId)).resolves.toBe(false);
+
+ standardBuild.resolve({ ok: true, sql: standardSql });
+ jsonBuild.resolve({ ok: true, sql: jsonSql });
+ await explain;
+
+ expect(mocks.executeQuery).not.toHaveBeenCalled();
+ });
+
+ it("clears loading state when one of the format builders rejects", async () => {
+ mocks.buildExplainSql.mockRejectedValueOnce(new Error("format builder unavailable"));
+
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query");
+
+ await expect(store.explainTabSql(tabId, sourceSql, "mysql")).resolves.toEqual({ ok: true, sql: "" });
+
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ expect(mocks.executeQuery).not.toHaveBeenCalled();
+ expect(tab).toMatchObject({
+ isExplaining: false,
+ explainExecutionId: undefined,
+ explainError: "format builder unavailable",
+ });
+ });
+});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 423c661cbe..5bfdcbbf06 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n";
import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
-import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText } from "@/lib/diagram/explainPlan";
+import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence";
@@ -382,7 +382,7 @@ export const useQueryStore = defineStore("query", () => {
const catalog = tab.mode === "data" ? tab.tableMeta?.catalog : undefined;
const connection = catalog ? useConnectionStore().getConfig(tab.connectionId) : undefined;
const executionDatabase = dataTabExecutionDatabase(connection, tab.database, catalog);
- const clientSessionIds = [tabClientSessionId(tab), ...BACKGROUND_CLIENT_SESSION_SUFFIXES.map((suffix) => tabClientSessionId(tab, suffix))];
+ const clientSessionIds = [...new Set([tabClientSessionId(tab), ...BACKGROUND_CLIENT_SESSION_SUFFIXES.map((suffix) => tabClientSessionId(tab, suffix)), tab.explainClientSessionId].filter((sessionId): sessionId is string => !!sessionId))];
for (const clientSessionId of clientSessionIds) {
await closeClientSessionId(tab.connectionId, executionDatabase, clientSessionId, { tabId: tab.id });
}
@@ -1962,11 +1962,15 @@ export const useQueryStore = defineStore("query", () => {
function clearExplain(tab: QueryTab) {
tab.explainPlan = undefined;
+ tab.explainTableResult = undefined;
tab.explainError = undefined;
+ tab.explainTableError = undefined;
tab.explainSql = undefined;
+ tab.explainTableSql = undefined;
tab.lastExplainedSql = undefined;
tab.isExplaining = false;
tab.explainExecutionId = undefined;
+ tab.explainClientSessionId = undefined;
}
function toErrorResult(e: any): NonNullable {
@@ -3003,7 +3007,12 @@ export const useQueryStore = defineStore("query", () => {
tab.isExplaining = true;
tab.explainExecutionId = executionId;
+ tab.explainPlan = undefined;
+ tab.explainTableResult = undefined;
tab.explainError = undefined;
+ tab.explainTableError = undefined;
+ tab.explainSql = undefined;
+ tab.explainTableSql = undefined;
tab.lastExplainedSql = sql;
// DM and Oracle agents expose native text plans. DM also supports autotrace.
@@ -3064,10 +3073,99 @@ export const useQueryStore = defineStore("query", () => {
return { ok: true as const, sql: explainSql };
}
+ if (databaseType === "mysql") {
+ let tableBuilt: BuildExplainSqlResult;
+ let jsonBuilt: BuildExplainSqlResult;
+ try {
+ [tableBuilt, jsonBuilt] = await Promise.all([buildExplainSql(databaseType, sql, "standard"), buildExplainSql(databaseType, sql, "json")]);
+ } catch (e: any) {
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.isExplaining = false;
+ current.explainExecutionId = undefined;
+ current.explainError = String(e?.message || e);
+ }
+ return { ok: true as const, sql: "" };
+ }
+ if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
+ return { ok: true as const, sql: jsonBuilt.ok ? jsonBuilt.sql : "" };
+ }
+ if (!tableBuilt.ok || !jsonBuilt.ok) {
+ const failed = !tableBuilt.ok ? tableBuilt : jsonBuilt;
+ const reason = !tableBuilt.ok ? tableBuilt.reason : !jsonBuilt.ok ? jsonBuilt.reason : "unsupported";
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.isExplaining = false;
+ current.explainExecutionId = undefined;
+ current.explainError = reason;
+ }
+ return failed;
+ }
+
+ tab.explainTableSql = tableBuilt.sql;
+ tab.explainSql = jsonBuilt.sql;
+ // Keep the two EXPLAIN statements on the same one-connection MySQL session.
+ const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
+ tab.explainClientSessionId = clientSessionId;
+ try {
+ try {
+ const tableResult = await api.executeQuery(tab.connectionId, tab.database, tableBuilt.sql, tab.schema, executionId, {
+ clientSessionId,
+ timeoutSecs: queryTimeoutSecs,
+ });
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.explainTableResult = markQueryResultRowsRaw(tableResult);
+ current.explainTableError = undefined;
+ }
+ } catch (e: any) {
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.explainTableResult = undefined;
+ current.explainTableError = String(e?.message || e);
+ }
+ }
+
+ // A canceled or superseded standard request must not start the JSON request.
+ if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
+ return { ok: true as const, sql: jsonBuilt.sql };
+ }
+
+ try {
+ const jsonResult = await api.executeQuery(tab.connectionId, tab.database, jsonBuilt.sql, tab.schema, executionId, {
+ clientSessionId,
+ timeoutSecs: queryTimeoutSecs,
+ });
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.explainPlan = parseExplainResult("mysql", jsonResult);
+ current.explainError = undefined;
+ }
+ } catch (e: any) {
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.explainPlan = undefined;
+ current.explainError = String(e?.message || e);
+ }
+ }
+ } finally {
+ const current = tabs.value.find((t) => t.id === id);
+ if (current?.explainExecutionId === executionId) {
+ current.isExplaining = false;
+ current.explainExecutionId = undefined;
+ }
+ if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
+ void closeClientSessionId(tab.connectionId, tab.database, clientSessionId, { tabId: tab.id, explainExecutionId: executionId });
+ }
+ return { ok: true as const, sql: jsonBuilt.sql };
+ }
+
const built = await buildExplainSql(databaseType, sql);
if (!built.ok) {
tab.explainPlan = undefined;
tab.explainError = built.reason;
+ tab.isExplaining = false;
+ tab.explainExecutionId = undefined;
return built;
}
@@ -3142,19 +3240,12 @@ export const useQueryStore = defineStore("query", () => {
if (!tab?.isExplaining || !tab.explainExecutionId) return false;
const executionId = tab.explainExecutionId;
+ // Invalidate locally before the remote cancellation call so no later stage can start.
+ tab.isExplaining = false;
+ tab.explainExecutionId = undefined;
try {
- const canceled = await api.cancelQuery(executionId);
- if (!canceled) {
- const current = tabs.value.find((t) => t.id === id);
- if (current && current.explainExecutionId === executionId) current.isExplaining = false;
- }
- return canceled;
- } catch (e: any) {
- const current = tabs.value.find((t) => t.id === id);
- if (current && current.explainExecutionId === executionId) {
- current.isExplaining = false;
- current.explainError = String(e?.message || e);
- }
+ return await api.cancelQuery(executionId);
+ } catch {
return false;
}
}
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index c57f9d51db..09e3732bf6 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -724,8 +724,12 @@ export interface QueryTab {
activeResultRunId?: string;
resultAutoSave?: boolean;
explainPlan?: import("@/lib/diagram/explainPlan").ParsedExplainPlan;
+ /** MySQL's regular EXPLAIN result, kept alongside its JSON visual plan. */
+ explainTableResult?: QueryResult;
explainError?: string;
+ explainTableError?: string;
explainSql?: string;
+ explainTableSql?: string;
lastExplainedSql?: string;
isExecuting: boolean;
isCancelling?: boolean;
@@ -741,6 +745,8 @@ export interface QueryTab {
executionId?: string;
isExplaining?: boolean;
explainExecutionId?: string;
+ /** Per-run connection session for sequential MySQL explain formats. */
+ explainClientSessionId?: string;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs";
mqTenant?: string;
mqInitialTab?: "topics";
diff --git a/crates/dbx-core/src/agent_tools.rs b/crates/dbx-core/src/agent_tools.rs
index 76a1b3a7fb..ed0e8a4c94 100644
--- a/crates/dbx-core/src/agent_tools.rs
+++ b/crates/dbx-core/src/agent_tools.rs
@@ -615,7 +615,8 @@ async fn execute_explain_query(
}
// Build the database-specific EXPLAIN SQL
- let explain_result = build_explain_sql(ExplainSqlOptions { database_type: Some(*db_type), sql: sql.to_string() });
+ let explain_result =
+ build_explain_sql(ExplainSqlOptions { database_type: Some(*db_type), format: None, sql: sql.to_string() });
let explain_sql = match (explain_result.ok, explain_result.sql) {
(true, Some(sql)) => sql,
diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs
index bcb341bbd0..8ae8a8461b 100644
--- a/crates/dbx-core/src/query_execution_sql.rs
+++ b/crates/dbx-core/src/query_execution_sql.rs
@@ -2,11 +2,22 @@ use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ExplainFormat {
+ Json,
+ Standard,
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExplainSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option,
+ /// MySQL supports both a structured JSON plan and the traditional tabular plan.
+ /// Omitted formats retain the existing JSON behavior.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub format: Option,
pub sql: String,
}
@@ -49,6 +60,10 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
format!("EXPLAIN {source}")
}
Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"),
+ Some(DatabaseType::Mysql) if options.format == Some(ExplainFormat::Standard) => {
+ // MySQL 8.0.32+ may otherwise inherit TREE or JSON from the session-level explain_format.
+ format!("EXPLAIN FORMAT=TRADITIONAL {source}")
+ }
_ => format!("EXPLAIN FORMAT=JSON {source}"),
};
ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None }
@@ -405,6 +420,7 @@ mod tests {
fn builds_postgres_json_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
+ format: None,
sql: " select * from users where id = 1; ".to_string(),
});
@@ -422,6 +438,7 @@ mod tests {
fn builds_dameng_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Dameng),
+ format: None,
sql: "SELECT * FROM t1 WHERE id = 1".to_string(),
});
@@ -439,6 +456,7 @@ mod tests {
fn builds_oracle_explain_plan_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
+ format: None,
sql: "WITH rows AS (SELECT 1 AS id FROM dual) SELECT * FROM rows;".to_string(),
});
@@ -467,6 +485,7 @@ mod tests {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
+ format: None,
sql: "SELECT * FROM users;".to_string(),
}),
ExplainSqlBuildResult {
@@ -479,6 +498,7 @@ mod tests {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
+ format: None,
sql: "delete from users".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
@@ -487,10 +507,24 @@ mod tests {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
+ format: None,
sql: "SELECT * FROM users; DELETE FROM users".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
+
+ assert_eq!(
+ build_explain_sql(ExplainSqlOptions {
+ database_type: Some(DatabaseType::Mysql),
+ format: Some(ExplainFormat::Standard),
+ sql: "SELECT * FROM users;".to_string(),
+ }),
+ ExplainSqlBuildResult {
+ ok: true,
+ sql: Some("EXPLAIN FORMAT=TRADITIONAL SELECT * FROM users".to_string()),
+ reason: None,
+ }
+ );
}
#[test]