fix(mysql): support compatible explain formats

This commit is contained in:
t8y2
2026-07-26 16:24:15 +08:00
parent 6a796ae97e
commit 000bb907e3
5 changed files with 163 additions and 21 deletions
@@ -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 }> = [];
@@ -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";');
});
});
@@ -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"),
};
}
@@ -61,7 +61,8 @@ function deferred<T>() {
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],
+52 -17
View File
@@ -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") {