mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(mysql): support compatible explain formats
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user