feat(sqlserver): add execution plan support

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 16:32:52 +08:00
1 parent da7eedb49c
commit e4c56e6165
16 files changed
+636 -23

No files matched your search

@@ -45,6 +45,7 @@ const rawContent = computed(() => {
});
const isRawString = computed(() => typeof props.plan?.raw === "string");
const rawFormatLabel = computed(() => (props.plan?.databaseType === "sqlserver" ? "XML" : isRawString.value ? "TEXT" : "JSON"));
const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0));
function tableCellText(value: unknown): string {
@@ -77,7 +78,7 @@ function tableCellText(value: unknown): string {
<Button v-if="plan" size="sm" :variant="activeView === 'raw' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'raw'">
<FileText v-if="isRawString" class="h-3.5 w-3.5" />
<Braces v-else class="h-3.5 w-3.5" />
{{ isRawString ? "TEXT" : "JSON" }}
{{ rawFormatLabel }}
</Button>
<Button v-if="hasTableView" size="sm" :variant="activeView === 'table' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'table'">
<Table2 class="h-3.5 w-3.5" />
@@ -0,0 +1,92 @@
import { DOMParser } from "@xmldom/xmldom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flattenExplainPlanNodes, parseExplainResult, sqlServerExplainResult, supportsExplainPlan } from "@/lib/diagram/explainPlan";
import type { QueryResult } from "@/types/database";
const SHOWPLAN_XML = `<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan">
<BatchSequence><Batch><Statements><StmtSimple><QueryPlan>
<RelOp NodeId="0" PhysicalOp="Sort" LogicalOp="Sort" EstimateRows="2" EstimatedTotalSubtreeCost="0.029466" AvgRowSize="36">
<Sort>
<RelOp NodeId="1" PhysicalOp="Index Seek" LogicalOp="Index Seek" EstimateRows="1" EstimatedRowsRead="4" EstimateIO="0.003125" EstimateCPU="0.0001581" EstimatedTotalSubtreeCost="0.0034412" AvgRowSize="16">
<IndexScan>
<Object Database="[dbx_explain_plan_test]" Schema="[dbo]" Table="[orders]" Index="[ix_orders_customer_status]" />
<SeekPredicates><SeekPredicateNew><SeekKeys><Prefix>
<RangeExpressions><ScalarOperator ScalarString="[orders].[status]='paid'" /></RangeExpressions>
</Prefix></SeekKeys></SeekPredicateNew></SeekPredicates>
</IndexScan>
</RelOp>
</Sort>
</RelOp>
</QueryPlan></StmtSimple></Statements></Batch></BatchSequence>
</ShowPlanXML>`;
function result(columns: string[], rows: unknown[][]): QueryResult {
return { columns, rows, affected_rows: 0, execution_time_ms: 1 };
}
describe("SQL Server explain plan", () => {
beforeEach(() => {
vi.stubGlobal("DOMParser", DOMParser);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("is enabled by the driver capability manifest", () => {
expect(supportsExplainPlan("sqlserver")).toBe(true);
});
it("parses SHOWPLAN XML into its operator hierarchy", () => {
const planResult = result(["Microsoft SQL Server 2005 XML Showplan"], [[SHOWPLAN_XML]]);
expect(sqlServerExplainResult([result([], []), planResult, result([], [])])).toEqual({ result: planResult });
const plan = parseExplainResult("sqlserver", planResult);
const nodes = flattenExplainPlanNodes(plan.nodes);
expect(plan.databaseType).toBe("sqlserver");
expect(plan.raw).toBe(SHOWPLAN_XML);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({ id: "0", nodeType: "Sort", rows: "2", cost: "0.029466" });
expect(nodes[1]).toMatchObject({
id: "1",
nodeType: "Index Seek",
relation: "dbo.orders",
index: "ix_orders_customer_status",
rows: "1",
cost: "0.0034412",
});
expect(nodes[1].details).toContain("Estimated Rows Read: 4");
expect(nodes[1].details).toContain("Expression: [orders].[status]='paid'");
});
it("surfaces a SQL Server batch error instead of treating it as a plan", () => {
expect(sqlServerExplainResult([result(["Error"], [["Invalid object name 'missing_table'"]])])).toEqual({
error: "Invalid object name 'missing_table'",
});
});
it("rejects malformed SHOWPLAN XML", () => {
const malformed = result(["Microsoft SQL Server 2005 XML Showplan"], [["<ShowPlanXML><RelOp></ShowPlanXML>"]]);
expect(() => parseExplainResult("sqlserver", malformed)).toThrow("Invalid SQL Server ShowPlan XML");
});
it("rejects truncated SHOWPLAN XML", () => {
const truncated = result(["Microsoft SQL Server 2005 XML Showplan"], [["<ShowPlanXML><RelOp>"]]);
expect(() => parseExplainResult("sqlserver", truncated)).toThrow("Invalid SQL Server ShowPlan XML");
});
it("rejects XML that is not a SHOWPLAN document", () => {
const unrelated = result(["XML"], [["<Root><RelOp /></Root>"]]);
expect(() => parseExplainResult("sqlserver", unrelated)).toThrow("SQL Server did not return ShowPlan XML");
expect(sqlServerExplainResult([unrelated])).toEqual({ error: "SQL Server did not return ShowPlan XML" });
});
it("rejects SHOWPLAN XML without RelOp nodes", () => {
const emptyPlan = result(["Microsoft SQL Server 2005 XML Showplan"], [[`<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan"><BatchSequence /></ShowPlanXML>`]]);
expect(() => parseExplainResult("sqlserver", emptyPlan)).toThrow("SQL Server ShowPlan XML contains no RelOp nodes");
});
});
+2
View File
@@ -712,6 +712,7 @@ export async function executeQuery(
resultSessionId?: string;
clientSessionId?: string;
timeoutSecs?: number;
executionMode?: "simple";
},
): Promise<QueryResult> {
return post("/api/query/execute", { connectionId, database, sql, schema, executionId, ...options });
@@ -732,6 +733,7 @@ export async function executeMulti(
timeoutSecs?: number;
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
},
): Promise<QueryResult[]> {
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
+2
View File
@@ -780,6 +780,7 @@ export async function executeQuery(
resultSessionId?: string;
clientSessionId?: string;
timeoutSecs?: number;
executionMode?: "simple";
},
): Promise<QueryResult> {
return invoke("execute_query", { connectionId, database, sql, schema, executionId, ...options });
@@ -800,6 +801,7 @@ export async function executeMulti(
timeoutSecs?: number;
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
},
): Promise<QueryResult[]> {
return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
+138 -4
View File
@@ -16,15 +16,15 @@ export interface ExplainPlanNode {
}
export interface ParsedExplainPlan {
databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "oracle";
databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "oracle" | "sqlserver";
raw: unknown;
nodes: ExplainPlanNode[];
}
export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" };
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng", "questdb", "oracle"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" | "oracle" {
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng", "questdb", "oracle", "sqlserver"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" | "oracle" | "sqlserver" {
return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
}
@@ -32,11 +32,13 @@ export function buildExplainSql(databaseType: DatabaseType | undefined, sql: str
return api.buildExplainSql({ databaseType, sql, format }) as Promise<BuildExplainSqlResult>;
}
export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng" | "questdb", result: QueryResult): ParsedExplainPlan {
export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "sqlserver", result: QueryResult): ParsedExplainPlan {
if (databaseType === "dameng") {
return parseDamengExplain(result);
} else if (databaseType === "questdb") {
return parseQuestdbExplain(result);
} else if (databaseType === "sqlserver") {
return parseSqlServerExplain(result);
}
const raw = parseExplainCell(result.rows[0]?.[0]);
const nodes = databaseType === "postgres" ? parsePostgresExplain(raw) : parseMysqlExplain(raw);
@@ -373,6 +375,138 @@ export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNo
return rows;
}
export function sqlServerExplainResult(results: QueryResult[]): { result?: QueryResult; error?: string } {
const errorResult = results.find((result) => result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
if (errorResult) return { error: String(errorResult.rows[0]?.[0] ?? "") };
const result = results.find((candidate) => firstSqlServerShowplanXml(candidate) !== undefined);
return result ? { result } : { error: "SQL Server did not return ShowPlan XML" };
}
function parseSqlServerExplain(result: QueryResult): ParsedExplainPlan {
const raw = firstSqlServerShowplanXml(result);
if (!raw) throw new Error("SQL Server did not return ShowPlan XML");
if (typeof DOMParser === "undefined") throw new Error("XML parser is unavailable");
const parserIssues: string[] = [];
type XmlParserConstructor = new (options?: {
errorHandler?: {
warning: (message: string) => void;
error: (message: string) => void;
fatalError: (message: string) => void;
};
}) => DOMParser;
const Parser = DOMParser as unknown as XmlParserConstructor;
const recordParserIssue = (message: string) => parserIssues.push(message);
const document = new Parser({
errorHandler: {
warning: recordParserIssue,
error: recordParserIssue,
fatalError: recordParserIssue,
},
}).parseFromString(raw, "application/xml");
if (parserIssues.length > 0 || xmlElements(document, "parsererror").length > 0 || !document.documentElement) {
throw new Error("Invalid SQL Server ShowPlan XML");
}
const rootName = document.documentElement.localName || document.documentElement.nodeName.split(":").pop();
if (rootName !== "ShowPlanXML") throw new Error("Invalid SQL Server ShowPlan XML root element");
const relOps = xmlElements(document, "RelOp");
if (relOps.length === 0) throw new Error("SQL Server ShowPlan XML contains no RelOp nodes");
const roots = relOps.filter((element) => !hasRelOpAncestor(element));
if (roots.length === 0) throw new Error("SQL Server ShowPlan XML contains no root RelOp node");
return {
databaseType: "sqlserver",
raw,
nodes: roots.map(parseSqlServerRelOp),
};
}
function firstSqlServerShowplanXml(result: QueryResult): string | undefined {
for (const row of result.rows) {
for (const cell of row) {
if (typeof cell === "string" && cell.includes("<ShowPlanXML")) return cell;
}
}
return undefined;
}
function parseSqlServerRelOp(element: Element): ExplainPlanNode {
const nodeType = element.getAttribute("PhysicalOp") || element.getAttribute("LogicalOp") || "Plan";
const logicalOp = element.getAttribute("LogicalOp") || undefined;
const object = planRegionElements(element, "Object")[0];
const schema = sqlServerIdentifier(object?.getAttribute("Schema"));
const table = sqlServerIdentifier(object?.getAttribute("Table"));
const relation = table ? [schema, table].filter(Boolean).join(".") : undefined;
const index = sqlServerIdentifier(object?.getAttribute("Index"));
const expressions = [
...new Set(
planRegionElements(element, "ScalarOperator")
.map((operator) => operator.getAttribute("ScalarString")?.trim())
.filter((value): value is string => !!value),
),
];
const details = [
logicalOp && logicalOp !== nodeType ? `Logical: ${logicalOp}` : "",
element.getAttribute("EstimatedRowsRead") ? `Estimated Rows Read: ${element.getAttribute("EstimatedRowsRead")}` : "",
element.getAttribute("EstimateIO") ? `Estimated I/O: ${element.getAttribute("EstimateIO")}` : "",
element.getAttribute("EstimateCPU") ? `Estimated CPU: ${element.getAttribute("EstimateCPU")}` : "",
...expressions.slice(0, 3).map((expression) => `Expression: ${expression}`),
].filter(Boolean);
return {
id: element.getAttribute("NodeId") || "0",
title: relation ? `${nodeType} on ${relation}` : nodeType,
nodeType,
relation,
index,
cost: element.getAttribute("EstimatedTotalSubtreeCost") || undefined,
rows: element.getAttribute("EstimateRows") || undefined,
width: element.getAttribute("AvgRowSize") || undefined,
details,
children: planRegionElements(element, "RelOp").map(parseSqlServerRelOp),
};
}
function planRegionElements(root: Element, localName: string): Element[] {
const matches: Element[] = [];
const visit = (element: Element) => {
for (const child of elementChildren(element)) {
if (child.localName === "RelOp") {
if (localName === "RelOp") matches.push(child);
continue;
}
if (child.localName === localName) matches.push(child);
visit(child);
}
};
visit(root);
return matches;
}
function xmlElements(root: Document | Element, localName: string): Element[] {
return Array.from(root.getElementsByTagNameNS("*", localName));
}
function hasRelOpAncestor(element: Element): boolean {
let parent = element.parentNode;
while (parent) {
if (parent.nodeType === 1 && (parent as Element).localName === "RelOp") return true;
parent = parent.parentNode;
}
return false;
}
function elementChildren(element: Element): Element[] {
return Array.from(element.childNodes).filter((node): node is Element => node.nodeType === 1);
}
function sqlServerIdentifier(value: string | null | undefined): string | undefined {
if (!value) return undefined;
return value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1).replaceAll("]]", "]") : value;
}
function parseExplainCell(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
@@ -0,0 +1,146 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ParsedExplainPlan } from "@/lib/diagram/explainPlan";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
buildExplainSql: vi.fn(),
parseExplainResult: vi.fn(),
sqlServerExplainResult: vi.fn(),
executeQuery: vi.fn(),
executeMulti: vi.fn(),
closeClientConnectionSession: vi.fn(),
saveOpenTabsState: vi.fn(),
getConfig: vi.fn(),
}));
vi.mock("@/lib/diagram/explainPlan", () => ({
buildExplainSql: mocks.buildExplainSql,
parseExplainResult: mocks.parseExplainResult,
parseDamengExplainText: vi.fn(),
parseOracleExplainText: vi.fn(),
sqlServerExplainResult: mocks.sqlServerExplainResult,
}));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeMulti: mocks.executeMulti,
closeClientConnectionSession: mocks.closeClientConnectionSession,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: mocks.getConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { pageSize: 100, openTabsRestoreMode: "all", confirmUnsavedSqlClose: false },
}),
}));
const sourceSql = "SELECT * FROM dbo.orders WHERE status = 'paid'";
const explainSql = `SET SHOWPLAN_XML ON;
GO
${sourceSql}
GO
SET SHOWPLAN_XML OFF;`;
const planResult: QueryResult = {
columns: ["Microsoft SQL Server 2005 XML Showplan"],
rows: [["<ShowPlanXML />"]],
affected_rows: 0,
execution_time_ms: 2,
};
const visualPlan: ParsedExplainPlan = { databaseType: "sqlserver", raw: "<ShowPlanXML />", nodes: [] };
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
describe("queryStore SQL Server explain", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConfig.mockReturnValue({ id: "sqlserver-1", db_type: "sqlserver", query_timeout_secs: 45 });
mocks.buildExplainSql.mockResolvedValue({ ok: true, sql: explainSql });
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 });
mocks.executeMulti.mockResolvedValue([planResult]);
mocks.sqlServerExplainResult.mockReturnValue({ result: planResult });
mocks.parseExplainResult.mockReturnValue(visualPlan);
mocks.closeClientConnectionSession.mockResolvedValue(true);
mocks.saveOpenTabsState.mockResolvedValue(undefined);
});
it("executes all SHOWPLAN batches in an isolated session and closes it", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("sqlserver-1", "dbx_explain_plan_test", "Query", "query", "dbo");
await store.explainTabSql(tabId, sourceSql, "sqlserver");
const enableCall = mocks.executeQuery.mock.calls[0]!;
const planCall = mocks.executeMulti.mock.calls[0]!;
const disableCall = mocks.executeQuery.mock.calls[1]!;
const executionId = enableCall[4] as string;
const clientSessionId = enableCall[5].clientSessionId as string;
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(enableCall.slice(0, 4)).toEqual(["sqlserver-1", "dbx_explain_plan_test", "SET SHOWPLAN_XML ON;", "dbo"]);
expect(planCall.slice(0, 4)).toEqual(["sqlserver-1", "dbx_explain_plan_test", sourceSql, "dbo"]);
expect(disableCall.slice(0, 4)).toEqual(["sqlserver-1", "dbx_explain_plan_test", "SET SHOWPLAN_XML OFF;", "dbo"]);
expect(planCall[4]).toBe(executionId);
expect(disableCall[4]).toBeUndefined();
expect(clientSessionId).toBe(`${tabId}:explain:${executionId}`);
expect(enableCall[5]).toMatchObject({ clientSessionId, timeoutSecs: 45, executionMode: "simple" });
expect(planCall[5]).toMatchObject({ clientSessionId, timeoutSecs: 45, executionMode: "simple" });
expect(disableCall[5]).toMatchObject({ clientSessionId, timeoutSecs: 5, executionMode: "simple" });
expect(mocks.parseExplainResult).toHaveBeenCalledWith("sqlserver", planResult);
expect(tab.explainPlan).toEqual(visualPlan);
expect(tab.explainError).toBeUndefined();
expect(tab.isExplaining).toBe(false);
await vi.waitFor(() => expect(mocks.closeClientConnectionSession).toHaveBeenCalledWith("sqlserver-1", "dbx_explain_plan_test", clientSessionId));
});
it("shows an execution error and still closes the SHOWPLAN session", async () => {
mocks.sqlServerExplainResult.mockReturnValue({ error: "Invalid object name 'missing_table'" });
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("sqlserver-1", "dbx_explain_plan_test", "Query");
await store.explainTabSql(tabId, sourceSql, "sqlserver");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.explainPlan).toBeUndefined();
expect(tab.explainError).toBe("Invalid object name 'missing_table'");
expect(mocks.parseExplainResult).not.toHaveBeenCalled();
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
expect(mocks.executeQuery.mock.calls[1]?.[2]).toBe("SET SHOWPLAN_XML OFF;");
await vi.waitFor(() => expect(mocks.closeClientConnectionSession).toHaveBeenCalledTimes(1));
});
it("does not execute the source SQL when SHOWPLAN cannot be enabled", async () => {
mocks.executeQuery.mockReset().mockRejectedValueOnce(new Error("SHOWPLAN permission denied"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("sqlserver-1", "dbx_explain_plan_test", "Query");
await store.explainTabSql(tabId, sourceSql, "sqlserver");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.explainError).toBe("SHOWPLAN permission denied");
expect(mocks.executeQuery).toHaveBeenCalledTimes(1);
expect(mocks.executeMulti).not.toHaveBeenCalled();
expect(mocks.executeQuery.mock.calls[0]?.[2]).toBe("SET SHOWPLAN_XML ON;");
await vi.waitFor(() => expect(mocks.closeClientConnectionSession).toHaveBeenCalledTimes(1));
});
});
+81 -1
View File
@@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n";
import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
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";
@@ -3619,6 +3619,86 @@ export const useQueryStore = defineStore("query", () => {
return { ok: true as const, sql: jsonBuilt.sql };
}
if (databaseType === "sqlserver") {
let built: BuildExplainSqlResult;
try {
built = await buildExplainSql(databaseType, sql);
} catch (e: any) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainError = String(e?.message || e);
return { ok: true as const, sql: "" };
}
if (!built.ok) {
tab.isExplaining = false;
tab.explainExecutionId = undefined;
tab.explainError = built.reason;
return built;
}
tab.explainSql = built.sql;
const clientSessionId = `${tabClientSessionId(tab, "explain")}:${executionId}`;
tab.explainClientSessionId = clientSessionId;
let showplanEnabled = false;
try {
await api.executeQuery(tab.connectionId, tab.database, "SET SHOWPLAN_XML ON;", tab.schema, executionId, {
clientSessionId,
timeoutSecs: queryTimeoutSecs,
executionMode: "simple",
});
showplanEnabled = true;
if (tabs.value.find((t) => t.id === id)?.explainExecutionId !== executionId) {
return { ok: true as const, sql: built.sql };
}
const results = await api.executeMulti(tab.connectionId, tab.database, sql, tab.schema, executionId, {
clientSessionId,
timeoutSecs: queryTimeoutSecs,
executionMode: "simple",
});
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
const outcome = sqlServerExplainResult(results);
if (outcome.error !== undefined) {
current.explainPlan = undefined;
current.explainError = outcome.error;
} else if (outcome.result) {
current.explainPlan = parseExplainResult("sqlserver", outcome.result);
current.explainError = undefined;
} else {
current.explainPlan = undefined;
current.explainError = t("explain.empty");
}
}
} 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);
}
} finally {
if (showplanEnabled) {
try {
await api.executeQuery(tab.connectionId, tab.database, "SET SHOWPLAN_XML OFF;", tab.schema, undefined, {
clientSessionId,
timeoutSecs: queryTimeoutSecs > 0 ? Math.min(queryTimeoutSecs, 5) : 5,
executionMode: "simple",
});
} catch (error) {
console.warn("[DBX][sqlserver-explain:cleanup:error]", { tabId: tab.id, error });
}
}
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.isExplaining = false;
current.explainExecutionId = undefined;
}
if (current?.explainClientSessionId === clientSessionId) current.explainClientSessionId = undefined;
await closeClientSessionId(tab.connectionId, tab.database, clientSessionId, { tabId: tab.id, explainExecutionId: executionId });
}
return { ok: true as const, sql: built.sql };
}
const built = await buildExplainSql(databaseType, sql);
if (!built.ok) {
tab.explainPlan = undefined;
+1 -1
View File
@@ -813,7 +813,7 @@ export interface QueryTab {
executionId?: string;
isExplaining?: boolean;
explainExecutionId?: string;
/** Per-run connection session for sequential MySQL explain formats. */
/** Per-run connection session for explain flows that require session state. */
explainClientSessionId?: string;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard";
mqTenant?: string;