From 000bb907e3ebda340e61d4ecc6e5707b3b580b13 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 26 Jul 2026 16:24:15 +0800 Subject: [PATCH] fix(mysql): support compatible explain formats --- .../components/explain/ExplainPlanViewer.vue | 12 +++- .../__tests__/ExplainPlanViewer.spec.ts | 18 +++++ .../lib/diagram/mysqlExplainCompatibility.ts | 40 +++++++++++ .../__tests__/queryStore.mysqlExplain.spec.ts | 45 +++++++++++- apps/desktop/src/stores/queryStore.ts | 69 ++++++++++++++----- 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.spec.ts create mode 100644 apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts diff --git a/apps/desktop/src/components/explain/ExplainPlanViewer.vue b/apps/desktop/src/components/explain/ExplainPlanViewer.vue index fbde5be39c..e12630319c 100644 --- a/apps/desktop/src/components/explain/ExplainPlanViewer.vue +++ b/apps/desktop/src/components/explain/ExplainPlanViewer.vue @@ -22,9 +22,15 @@ const { t } = useI18n(); const activeView = ref<"tree" | "summary" | "raw" | "table">("tree"); const hasTableView = computed(() => !!props.tableResult || !!props.tableError); -watch(hasTableView, (available) => { - if (!available && activeView.value === "table") activeView.value = "tree"; -}); +watch( + [hasTableView, () => !!props.tableResult, () => !!props.plan, () => props.loading], + ([available, hasTableResult, hasPlan, loading]) => { + if (!available && activeView.value === "table") activeView.value = "tree"; + // Wait for JSON EXPLAIN to finish so regular MySQL still defaults to its visual tree. + if (!loading && hasTableResult && !hasPlan) activeView.value = "table"; + }, + { immediate: true }, +); const flatRows = computed(() => { const rows: Array<{ node: ExplainPlanNode; depth: number }> = []; diff --git a/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.spec.ts b/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.spec.ts new file mode 100644 index 0000000000..da41ee61cc --- /dev/null +++ b/apps/desktop/src/components/explain/__tests__/ExplainPlanViewer.spec.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const viewerSource = readFileSync(new URL("../ExplainPlanViewer.vue", import.meta.url), "utf8"); + +describe("ExplainPlanViewer MySQL fallback", () => { + it("selects the table only after loading finishes without a visual plan", () => { + expect(viewerSource).toContain("const hasTableView = computed(() => !!props.tableResult || !!props.tableError);"); + expect(viewerSource).toContain("[hasTableView, () => !!props.tableResult, () => !!props.plan, () => props.loading]"); + expect(viewerSource).toContain('if (!loading && hasTableResult && !hasPlan) activeView.value = "table";'); + expect(viewerSource).toContain("{ immediate: true },"); + }); + + it("keeps the regular tree default when the visual plan arrives", () => { + expect(viewerSource).toContain('if (!loading && hasTableResult && !hasPlan) activeView.value = "table";'); + expect(viewerSource).toContain('if (!available && activeView.value === "table") activeView.value = "tree";'); + }); +}); diff --git a/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts b/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts new file mode 100644 index 0000000000..75f48a07f4 --- /dev/null +++ b/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts @@ -0,0 +1,40 @@ +import { formatError } from "@/lib/backend/errorUtils"; + +export interface MysqlExplainCompatibilityHint { + fallbackSql?: string; + supportsJson: boolean; +} + +const INVALID_EXPLAIN_OPTION_RE = /invalid\s+explain\s+option/i; +const VALID_EXPLAIN_OPTIONS_RE = /valid\s+options\s+are\s*:\s*\[([^\]]+)\]/i; +const EXPLAIN_FORMAT_PREFIX_RE = /^EXPLAIN\s+FORMAT\s*=\s*[A-Z_]+\s+/i; + +export function mysqlExplainCompatibilityHint(error: unknown, explainSql: string): MysqlExplainCompatibilityHint | undefined { + const message = formatError(error); + if (!INVALID_EXPLAIN_OPTION_RE.test(message)) return undefined; + + const optionsMatch = message.match(VALID_EXPLAIN_OPTIONS_RE); + if (!optionsMatch) return undefined; + + const validFormats = new Set( + optionsMatch[1] + .split(",") + .map((format) => + format + .trim() + .replace(/^['"`]|['"`]$/g, "") + .toUpperCase(), + ) + .filter(Boolean), + ); + const formatPrefixMatch = explainSql.match(EXPLAIN_FORMAT_PREFIX_RE); + if (!formatPrefixMatch) return { supportsJson: validFormats.has("JSON") }; + + // MySQL-compatible warehouses often expose TEXT as their tabular EXPLAIN format instead of TRADITIONAL. + const fallbackPrefix = validFormats.has("TEXT") ? "EXPLAIN FORMAT=TEXT " : "EXPLAIN "; + const fallbackSql = explainSql.replace(EXPLAIN_FORMAT_PREFIX_RE, fallbackPrefix); + return { + fallbackSql: fallbackSql === explainSql ? undefined : fallbackSql, + supportsJson: validFormats.has("JSON"), + }; +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts index a95069de2c..ae864c9b61 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts @@ -61,7 +61,8 @@ function deferred() { return { promise, resolve, reject }; } -const standardSql = "EXPLAIN SELECT * FROM users WHERE status = 'active'"; +const standardSql = "EXPLAIN FORMAT=TRADITIONAL SELECT * FROM users WHERE status = 'active'"; +const textSql = "EXPLAIN FORMAT=TEXT 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'"; @@ -178,6 +179,48 @@ describe("queryStore MySQL dual explain", () => { expect(tab.isExplaining).toBe(false); }); + it("retries ADB MySQL with TEXT and skips its advertised unsupported JSON format", async () => { + mocks.executeQuery.mockRejectedValueOnce(new Error("TRADITIONAL is an INVALID EXPLAIN option, valid options are: [TEXT, GRAPHVIZ, DETAIL, SIMPLE]")).mockResolvedValueOnce(tableResult); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query", "query", "analytics"); + + await expect(store.explainTabSql(tabId, sourceSql, "mysql")).resolves.toEqual({ ok: true, sql: textSql }); + + const firstOptions = mocks.executeQuery.mock.calls[0]?.[5] as { clientSessionId: string }; + const secondOptions = mocks.executeQuery.mock.calls[1]?.[5] as { clientSessionId: string }; + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(mocks.executeQuery).toHaveBeenCalledTimes(2); + expect(mocks.executeQuery).toHaveBeenNthCalledWith(1, "mysql-1", "app", standardSql, "analytics", expect.any(String), expect.any(Object)); + expect(mocks.executeQuery).toHaveBeenNthCalledWith(2, "mysql-1", "app", textSql, "analytics", expect.any(String), expect.any(Object)); + expect(secondOptions.clientSessionId).toBe(firstOptions.clientSessionId); + expect(mocks.parseExplainResult).not.toHaveBeenCalled(); + expect(tab.explainTableResult).toEqual(tableResult); + expect(tab.explainTableSql).toBe(textSql); + expect(tab.explainTableError).toBeUndefined(); + expect(tab.explainPlan).toBeUndefined(); + expect(tab.explainSql).toBeUndefined(); + expect(tab.explainError).toBeUndefined(); + }); + + it("keeps the standard table usable when JSON is the rejected ADB format", async () => { + mocks.executeQuery.mockResolvedValueOnce(tableResult).mockRejectedValueOnce(new Error("JSON is an INVALID EXPLAIN option, valid options are: [TEXT, GRAPHVIZ, DETAIL, SIMPLE]")); + + 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).toHaveBeenCalledTimes(2); + expect(tab.explainTableResult).toEqual(tableResult); + expect(tab.explainPlan).toBeUndefined(); + expect(tab.explainSql).toBe(jsonSql); + expect(tab.explainError).toBeUndefined(); + }); + it.each([ ["is acknowledged", () => mocks.cancelQuery.mockResolvedValue(true), true], ["returns false", () => mocks.cancelQuery.mockResolvedValue(false), false], diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 7d104ff2f7..efc815e28a 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6,6 +6,7 @@ import type { ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan"; +import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility"; import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, 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"; @@ -3863,36 +3864,68 @@ export const useQueryStore = defineStore("query", () => { return failed; } - tab.explainTableSql = tableBuilt.sql; - tab.explainSql = jsonBuilt.sql; + let tableSql = tableBuilt.sql; + let jsonSupportedByServer: boolean | undefined; + tab.explainTableSql = tableSql; + tab.explainSql = undefined; // Keep the two EXPLAIN statements on the same one-connection MySQL session. const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`; tab.explainClientSessionId = clientSessionId; try { + let tableResult: QueryResult | undefined; + let tableError: unknown; try { - const tableResult = await api.executeQuery(tab.connectionId, tab.database, tableBuilt.sql, tab.schema, executionId, { + tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, { clientSessionId, timeoutSecs: queryTimeoutSecs, }); - const current = tabs.value.find((t) => t.id === id); - if (current?.explainExecutionId === executionId) { + } catch (error: unknown) { + const compatibility = mysqlExplainCompatibilityHint(error, tableSql); + jsonSupportedByServer = compatibility?.supportsJson; + if (compatibility?.fallbackSql && tabs.value.find((t) => t.id === id)?.explainExecutionId === executionId) { + tableSql = compatibility.fallbackSql; + tab.explainTableSql = tableSql; + try { + tableResult = await api.executeQuery(tab.connectionId, tab.database, tableSql, tab.schema, executionId, { + clientSessionId, + timeoutSecs: queryTimeoutSecs, + }); + } catch (fallbackError: unknown) { + tableError = fallbackError; + } + } else { + tableError = error; + } + } + const current = tabs.value.find((t) => t.id === id); + if (current?.explainExecutionId === executionId) { + if (tableResult) { current.explainTableResult = markQueryResultRowsRaw(tableResult); current.explainTableError = undefined; - } - } catch (e: any) { - const current = tabs.value.find((t) => t.id === id); - if (current?.explainExecutionId === executionId) { + } else if (tableError !== undefined) { current.explainTableResult = undefined; - current.explainTableError = String(e?.message || e); + current.explainTableError = formatError(tableError); } } - // A canceled or superseded standard request must not start the JSON request. + // A canceled or superseded standard request must not start a fallback or JSON request. if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) { - return { ok: true as const, sql: jsonBuilt.sql }; + return { ok: true as const, sql: tableSql }; + } + + // ADB MySQL advertises its accepted formats in the first error; avoid a second known-invalid request. + if (jsonSupportedByServer === false) { + const latest = tabs.value.find((t) => t.id === id); + if (latest?.explainExecutionId === executionId) { + latest.explainPlan = undefined; + latest.explainError = latest.explainTableResult ? undefined : latest.explainTableError; + } + return { ok: true as const, sql: tableSql }; } try { + const latest = tabs.value.find((t) => t.id === id); + if (latest?.explainExecutionId === executionId) latest.explainSql = jsonBuilt.sql; const jsonResult = await api.executeQuery(tab.connectionId, tab.database, jsonBuilt.sql, tab.schema, executionId, { clientSessionId, timeoutSecs: queryTimeoutSecs, @@ -3903,10 +3936,12 @@ export const useQueryStore = defineStore("query", () => { 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); + const latest = tabs.value.find((t) => t.id === id); + if (latest?.explainExecutionId === executionId) { + latest.explainPlan = undefined; + // Keep a usable tabular plan visible when the server explicitly rejects JSON. + const compatibility = mysqlExplainCompatibilityHint(e, jsonBuilt.sql); + latest.explainError = compatibility?.supportsJson === false && latest.explainTableResult ? undefined : formatError(e); } } } finally { @@ -3918,7 +3953,7 @@ export const useQueryStore = defineStore("query", () => { 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 }; + return { ok: true as const, sql: tab.explainSql ?? tableSql }; } if (databaseType === "sqlserver") {