From 98ed32066c68e5309b9988fd8e154c16e21ae7cc Mon Sep 17 00:00:00 2001 From: t8y2 Date: Wed, 23 Sep 2026 09:22:34 +0800 Subject: [PATCH] fix(tidb): handle unsupported JSON explain format Closes #9168 --- .../mysqlExplainCompatibility.spec.ts | 83 ++++++++++ .../lib/diagram/mysqlExplainCompatibility.ts | 2 + .../__tests__/queryStore.mysqlExplain.spec.ts | 154 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 apps/desktop/src/lib/diagram/__tests__/mysqlExplainCompatibility.spec.ts diff --git a/apps/desktop/src/lib/diagram/__tests__/mysqlExplainCompatibility.spec.ts b/apps/desktop/src/lib/diagram/__tests__/mysqlExplainCompatibility.spec.ts new file mode 100644 index 0000000000..a667f6afd6 --- /dev/null +++ b/apps/desktop/src/lib/diagram/__tests__/mysqlExplainCompatibility.spec.ts @@ -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 }); + }); +}); diff --git a/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts b/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts index 75f48a07f4..c573095a3b 100644 --- a/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts +++ b/apps/desktop/src/lib/diagram/mysqlExplainCompatibility.ts @@ -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); diff --git a/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts index ae864c9b61..420cd9eacd 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mysqlExplain.spec.ts @@ -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],