mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user