fix(tidb): handle unsupported JSON explain format

Closes #9168
This commit is contained in:
t8y2
2026-09-23 09:22:34 +08:00
parent a1c641cbef
commit 98ed32066c
3 changed files with 239 additions and 0 deletions
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { BackendErrorException } from "@/lib/backend/errorUtils";
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
const sourceSql = "SELECT * FROM users";
const standardSql = `EXPLAIN FORMAT=TRADITIONAL ${sourceSql}`;
const jsonSql = `EXPLAIN FORMAT=JSON ${sourceSql}`;
const reportedError = "Server error: `ERROR 1105 (HY000): explain format 'JSON' is not supported now` SQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement";
const backendError = new BackendErrorException(reportedError).backendError;
describe("mysqlExplainCompatibilityHint", () => {
it.each([
["string", reportedError],
["Error", new Error(reportedError)],
["message", { message: reportedError }],
["reason", { reason: reportedError }],
["detail", { detail: reportedError }],
["backend envelope", backendError],
["backend exception", new BackendErrorException(backendError)],
["nested backendError", { backendError }],
["nested error", { error: backendError }],
["serialized envelope", JSON.stringify(backendError)],
["serialized Error message", new Error(JSON.stringify(backendError))],
])("recognizes the reported TiDB JSON rejection as %s", (_label, error) => {
expect(mysqlExplainCompatibilityHint(error, jsonSql)).toEqual({ supportsJson: false });
});
it.each(["explain format 'JSON' is not supported now", 'EXPLAIN FORMAT "json" IS NOT SUPPORTED NOW', "Explain Format `JsOn` Is Not Supported Now", "explain format JSON is not supported now", "explain\tformat\t'json'\tis\tnot\tsupported\tnow"])(
"recognizes quote, case and whitespace variants: %s",
(error) => {
expect(mysqlExplainCompatibilityHint(error, jsonSql)).toEqual({ supportsJson: false });
},
);
it.each([
"explain format 'TEXT' is not supported now",
"explain format 'TRADITIONAL' is not supported now",
"explain format 'TIDB_JSON' is not supported now",
"explain format 'JSONB' is not supported now",
"explain format 'JSON\" is not supported now",
"explain format JSON' is not supported now",
"explain format 'JSON' is not supported nowhere",
"ERROR 1105 (HY000): Invalid JSON text",
"ERROR 1064 (42000): syntax error near 'JSON'",
"ERROR 1045 (28000): Access denied for JSON EXPLAIN",
"ERROR 1142 (42000): SELECT command denied during JSON EXPLAIN",
"Lost connection during EXPLAIN FORMAT=JSON",
"Query timeout during EXPLAIN FORMAT=JSON",
"JSON is not supported now",
"JSON is an invalid explain option",
"valid options are: [TEXT]",
"Invalid explain option, valid options are: []",
"",
null,
undefined,
1105,
false,
{},
{ code: 1105 },
])("does not classify an unrelated or missing error: %j", (error) => {
expect(mysqlExplainCompatibilityHint(error, jsonSql)).toBeUndefined();
});
it.each([
["[TEXT, GRAPHVIZ, DETAIL, SIMPLE]", `EXPLAIN FORMAT=TEXT ${sourceSql}`, false],
["['text', \"JSON\", `detail`]", `EXPLAIN FORMAT=TEXT ${sourceSql}`, true],
["[ROW, BRIEF]", `EXPLAIN ${sourceSql}`, false],
["[JSON]", `EXPLAIN ${sourceSql}`, true],
])("preserves the advertised valid-options fallback: %s", (options, fallbackSql, supportsJson) => {
const error = `TRADITIONAL is an INVALID EXPLAIN option, valid options are: ${options}`;
expect(mysqlExplainCompatibilityHint(error, standardSql)).toEqual({ fallbackSql, supportsJson });
});
it("does not rewrite a default EXPLAIN when valid options include JSON", () => {
const error = "Invalid explain option, valid options are: [TEXT, JSON]";
expect(mysqlExplainCompatibilityHint(error, `EXPLAIN ${sourceSql}`)).toEqual({ supportsJson: true });
});
it("does not retry an already selected TEXT format", () => {
const error = "Invalid explain option, valid options are: [TEXT]";
expect(mysqlExplainCompatibilityHint(error, `EXPLAIN FORMAT=TEXT ${sourceSql}`)).toEqual({ fallbackSql: undefined, supportsJson: false });
});
});
@@ -8,9 +8,11 @@ export interface MysqlExplainCompatibilityHint {
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;
const UNSUPPORTED_JSON_EXPLAIN_FORMAT_RE = /\bexplain\s+format\s+(['"`]?)json\1\s+is\s+not\s+supported\s+now\b/i;
export function mysqlExplainCompatibilityHint(error: unknown, explainSql: string): MysqlExplainCompatibilityHint | undefined {
const message = formatError(error);
if (UNSUPPORTED_JSON_EXPLAIN_FORMAT_RE.test(message)) return { supportsJson: false };
if (!INVALID_EXPLAIN_OPTION_RE.test(message)) return undefined;
const optionsMatch = message.match(VALID_EXPLAIN_OPTIONS_RE);
@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BackendErrorException, formatError } from "@/lib/backend/errorUtils";
import type { ParsedExplainPlan } from "@/lib/diagram/explainPlan";
import type { QueryResult } from "@/types/database";
@@ -65,6 +66,25 @@ const standardSql = "EXPLAIN FORMAT=TRADITIONAL SELECT * FROM users WHERE status
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'";
const tidbSourceSql = `select ti.id invoiceId,
ti.invoice_no invoiceNo,
ti.invoice_amount invoiceAmount
from ti_oms.ts_invoice ti
where ti.invoice_status in (3, 5, 6, 8)
and ti.id in(1,2)`;
const tidbJsonError = "Server error: `ERROR 1105 (HY000): explain format 'JSON' is not supported now` SQL text omitted from user-facing error; enable debug SQL diagnostics to inspect the original statement";
const tidbBackendError = new BackendErrorException(tidbJsonError).backendError;
const tidbTableResult: QueryResult = {
columns: ["id", "estRows", "task", "access object", "operator info"],
rows: [
["Projection_4", "0.01", "root", "", "ti_oms.ts_invoice.id, ti_oms.ts_invoice.invoice_no, ti_oms.ts_invoice.invoice_amount"],
["└─Selection_6", "0.01", "root", "", "in(ti_oms.ts_invoice.invoice_status, 3, 5, 6, 8)"],
[" └─Batch_Point_Get_5", "2.00", "root", "table:ts_invoice", "handle:[1 2], keep order:false, desc:false"],
],
affected_rows: 0,
execution_time_ms: 4,
};
const tableResult: QueryResult = {
columns: ["id", "select_type", "table", "type", "rows", "Extra"],
@@ -221,6 +241,140 @@ describe("queryStore MySQL dual explain", () => {
expect(tab.explainError).toBeUndefined();
});
it.each([
["string", tidbJsonError],
["Error", new Error(tidbJsonError)],
["message", { message: tidbJsonError }],
["backend envelope", tidbBackendError],
["backend exception", new BackendErrorException(tidbBackendError)],
["nested backendError", { backendError: tidbBackendError }],
["nested error", { error: tidbBackendError }],
["serialized envelope", JSON.stringify(tidbBackendError)],
])("keeps the screenshot table usable after the reported TiDB JSON rejection as %s", async (_label, error) => {
const tidbStandardSql = `EXPLAIN FORMAT=TRADITIONAL ${tidbSourceSql}`;
const tidbJsonSql = `EXPLAIN FORMAT=JSON ${tidbSourceSql}`;
mocks.buildExplainSql.mockImplementation(async (_databaseType: string, sql: string, format: string) => ({
ok: true,
sql: `EXPLAIN FORMAT=${format === "standard" ? "TRADITIONAL" : "JSON"} ${sql}`,
}));
mocks.executeQuery.mockResolvedValueOnce(tidbTableResult).mockRejectedValueOnce(error);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "ti_oms", "Query");
await expect(store.explainTabSql(tabId, tidbSourceSql, "mysql")).resolves.toEqual({ ok: true, sql: tidbJsonSql });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(mocks.buildExplainSql).toHaveBeenNthCalledWith(1, "mysql", tidbSourceSql, "standard");
expect(mocks.buildExplainSql).toHaveBeenNthCalledWith(2, "mysql", tidbSourceSql, "json");
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
expect(mocks.executeQuery).toHaveBeenNthCalledWith(1, "mysql-1", "ti_oms", tidbStandardSql, undefined, expect.any(String), expect.any(Object));
expect(mocks.executeQuery).toHaveBeenNthCalledWith(2, "mysql-1", "ti_oms", tidbJsonSql, undefined, expect.any(String), expect.any(Object));
expect(mocks.parseExplainResult).not.toHaveBeenCalled();
expect(tab.explainTableResult).toEqual(tidbTableResult);
expect(tab.explainTableSql).toBe(tidbStandardSql);
expect(tab.lastExplainedSql).toBe(tidbSourceSql);
expect(tab.explainTableError).toBeUndefined();
expect(tab.explainPlan).toBeUndefined();
expect(tab.explainSql).toBe(tidbJsonSql);
expect(tab.explainError).toBeUndefined();
expect(tab.isExplaining).toBe(false);
expect(tab.explainExecutionId).toBeUndefined();
});
it.each([
"ERROR 1105 (HY000): Invalid JSON text",
"ERROR 1064 (42000): syntax error near 'JSON'",
"ERROR 1045 (28000): Access denied for JSON EXPLAIN",
"ERROR 1142 (42000): SELECT command denied during JSON EXPLAIN",
"Lost connection during EXPLAIN FORMAT=JSON",
"Query timeout during EXPLAIN FORMAT=JSON",
"explain format 'TEXT' is not supported now",
"explain format 'TIDB_JSON' is not supported now",
null,
undefined,
])("keeps a real JSON request failure visible despite a table plan: %j", async (error) => {
mocks.executeQuery.mockResolvedValueOnce(tableResult).mockRejectedValueOnce(error);
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.explainTableSql).toBe(standardSql);
expect(tab.lastExplainedSql).toBe(sourceSql);
expect(tab.explainSql).toBe(jsonSql);
expect(tab.explainPlan).toBeUndefined();
expect(tab.explainError).toBe(formatError(error));
});
it.each([
["rejects", () => mocks.executeQuery.mockRejectedValueOnce(new Error("standard EXPLAIN failed")), "standard EXPLAIN failed"],
["returns null", () => mocks.executeQuery.mockResolvedValueOnce(null), undefined],
["returns undefined", () => mocks.executeQuery.mockResolvedValueOnce(undefined), undefined],
])("keeps the TiDB JSON failure visible when the table request %s", async (_label, configureTable, tableError) => {
configureTable();
mocks.executeQuery.mockRejectedValueOnce(new Error(tidbJsonError));
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).toBeUndefined();
expect(tab.explainTableError).toBe(tableError);
expect(tab.explainPlan).toBeUndefined();
expect(tab.explainError).toBe(tidbJsonError);
expect(tab.isExplaining).toBe(false);
});
it("still uses JSON when the next request succeeds after a TiDB rejection", async () => {
mocks.executeQuery.mockResolvedValueOnce(tableResult).mockRejectedValueOnce(new Error(tidbJsonError)).mockResolvedValueOnce(tableResult).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");
await store.explainTabSql(tabId, sourceSql, "mysql");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(mocks.executeQuery).toHaveBeenCalledTimes(4);
expect(mocks.executeQuery).toHaveBeenNthCalledWith(4, "mysql-1", "app", jsonSql, undefined, expect.any(String), expect.any(Object));
expect(mocks.parseExplainResult).toHaveBeenCalledOnce();
expect(mocks.parseExplainResult).toHaveBeenCalledWith("mysql", jsonResult);
expect(tab.explainTableResult).toEqual(tableResult);
expect(tab.explainPlan).toEqual(visualPlan);
expect(tab.explainError).toBeUndefined();
});
it("retains JSON after a TEXT fallback when the server advertises JSON support", async () => {
mocks.executeQuery.mockRejectedValueOnce(new Error("TRADITIONAL is an INVALID EXPLAIN option, valid options are: [TEXT, JSON]")).mockResolvedValueOnce(tableResult).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).toHaveBeenCalledTimes(3);
expect(mocks.executeQuery).toHaveBeenNthCalledWith(2, "mysql-1", "app", textSql, undefined, expect.any(String), expect.any(Object));
expect(mocks.executeQuery).toHaveBeenNthCalledWith(3, "mysql-1", "app", jsonSql, undefined, expect.any(String), expect.any(Object));
expect(tab.explainTableResult).toEqual(tableResult);
expect(tab.explainTableSql).toBe(textSql);
expect(tab.explainPlan).toEqual(visualPlan);
expect(tab.explainError).toBeUndefined();
});
it.each([
["is acknowledged", () => mocks.cancelQuery.mockResolvedValue(true), true],
["returns false", () => mocks.cancelQuery.mockResolvedValue(false), false],