feat(sqlserver): add execution plan support

This commit is contained in:
zipg 2026-07-16 16:32:52 +08:00 committed by GitHub
parent da7eedb49c
commit e4c56e6165
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 636 additions and 23 deletions

View File

@ -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" />

View File

@ -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");
});
});

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 });

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 });

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 {

View File

@ -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));
});
});

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;

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;

View File

@ -261,7 +261,7 @@
"sqlFileExecution": true,
"databaseCreate": true,
"fieldLineage": true,
"sqlExplain": false,
"sqlExplain": true,
"userAdmin": false,
"driverManagement": false
}

View File

@ -697,6 +697,9 @@ pub async fn stream_first_result_set(
}
fn sqlserver_cell_to_json(cell: &ColumnData<'static>) -> serde_json::Value {
if let Ok(Some(v)) = <&tiberius::xml::XmlData as FromSql>::from_sql(cell) {
return serde_json::Value::String(v.as_ref().to_string());
}
if let Ok(Some(v)) = <&str as FromSql>::from_sql(cell) {
return serde_json::Value::String(v.to_string());
}
@ -1733,6 +1736,20 @@ pub async fn execute_batch_with_max_rows(
);
}
}
execute_simple_batch_with_max_rows(client, sql, max_rows).await
}
/// Execute a SQL Server batch directly through TDS simple-query mode.
///
/// This intentionally bypasses result-set type probing and SQL rewriting. It is
/// required while `SHOWPLAN_XML` is enabled because any probe issued on the same
/// session is itself affected by SHOWPLAN state.
pub async fn execute_simple_batch_with_max_rows(
client: &mut SqlServerClient,
sql: &str,
max_rows: Option<usize>,
) -> Result<Vec<QueryResult>, String> {
let start = Instant::now();
let stream = sqlserver_driver_result(client.simple_query(sql)).await?;
let mut results = sqlserver_driver_result(collect_result_sets_limited(stream, start, max_rows)).await?;
for result in &mut results {
@ -1829,6 +1846,9 @@ fn contains_transaction_control(sql: &str) -> bool {
fn requires_simple_query_batch(sql: &str) -> bool {
let tokens = first_sql_tokens(sql, 4);
if tokens.len() >= 2 && tokens[0].eq_ignore_ascii_case("SET") && tokens[1].eq_ignore_ascii_case("SHOWPLAN_XML") {
return true;
}
if tokens.len() >= 2 && tokens[0].eq_ignore_ascii_case("CREATE") && tokens[1].eq_ignore_ascii_case("SCHEMA") {
return true;
}
@ -1904,7 +1924,7 @@ mod tests {
CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, QueryResult,
};
use chrono::NaiveDate;
use std::time::Instant;
use std::{borrow::Cow, time::Instant};
use tiberius::{ColumnData, IntoSql};
#[test]
@ -1919,6 +1939,18 @@ mod tests {
);
}
#[test]
fn sqlserver_xml_cells_are_returned_as_strings() {
let cell = ColumnData::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new(
"<ShowPlanXML><RelOp NodeId=\"0\" /></ShowPlanXML>",
))));
assert_eq!(
sqlserver_cell_to_json(&cell),
serde_json::Value::String("<ShowPlanXML><RelOp NodeId=\"0\" /></ShowPlanXML>".to_string())
);
}
#[test]
fn sqlserver_endpoint_keeps_regular_hosts() {
assert_eq!(
@ -1982,6 +2014,8 @@ mod tests {
#[test]
fn sqlserver_module_definitions_require_simple_query_batch() {
assert!(requires_simple_query_batch("SET SHOWPLAN_XML ON;"));
assert!(requires_simple_query_batch("SET SHOWPLAN_XML OFF;"));
assert!(requires_simple_query_batch("CREATE SCHEMA [analytics];"));
assert!(requires_simple_query_batch("CREATE FUNCTION dbo.fn_demo() RETURNS INT AS BEGIN RETURN 1; END;"));
assert!(requires_simple_query_batch("ALTER PROCEDURE dbo.usp_demo AS SELECT 1;"));
@ -2055,6 +2089,17 @@ mod tests {
assert!(!execute_batch.contains("into_results"));
}
#[test]
fn sqlserver_explicit_simple_batch_bypasses_result_type_probing() {
let source = include_str!("sqlserver.rs");
let simple_batch = source.split("pub async fn execute_simple_batch_with_max_rows").nth(1).unwrap();
let simple_batch = simple_batch.split("fn strip_dbx_sqlserver_row_number_column").next().unwrap();
assert!(simple_batch.contains("client.simple_query(sql)"));
assert!(!simple_batch.contains("sqlserver_unsafe_type_query"));
assert!(!simple_batch.contains("describe_sqlserver_result_set"));
}
#[test]
fn sqlserver_index_metadata_sql_avoids_string_agg_for_older_compatibility_levels() {
let sql = sqlserver_indexes_sql("dbo", "DF_Rule");

View File

@ -4,7 +4,7 @@ use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, NaiveDateTime, Nai
use duckdb::types::{TimeUnit, Value, ValueRef};
use futures::StreamExt;
use mysql_async::prelude::Queryable;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use sqlparser::ast::{
visit_relations_mut, Ident, ObjectName, ObjectNamePart, ObjectType, Statement, TableFactor, VisitMut, VisitorMut,
};
@ -367,6 +367,14 @@ fn qualifies_unqualified_agent_relations(db_type: Option<DatabaseType>) -> bool
matches!(db_type, Some(DatabaseType::Iris | DatabaseType::Kingbase))
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum QueryExecutionMode {
#[default]
Standard,
Simple,
}
#[derive(Clone, Debug, Default)]
pub struct QueryExecutionOptions {
pub max_rows: Option<usize>,
@ -385,6 +393,9 @@ pub struct QueryExecutionOptions {
/// When `true`, multi-statement execution continues after a statement error instead
/// of stopping at the first failure. Connection-level failures always stop the batch.
pub continue_on_error: bool,
/// Explicit low-level execution path. `Simple` is currently used by SQL Server
/// SHOWPLAN so the source SQL bypasses result-set probing and query rewriting.
pub execution_mode: QueryExecutionMode,
}
fn query_result_row_limit(max_rows: Option<usize>) -> usize {
@ -1405,6 +1416,7 @@ pub async fn do_execute(
PoolKind::SqlServer(client) => {
let client = client.clone();
let max_rows = options.max_rows;
let execution_mode = options.execution_mode;
drop(connections);
let mut client = match cancel_token.as_ref() {
Some(token) => tokio::select! {
@ -1414,13 +1426,18 @@ pub async fn do_execute(
},
None => client.lock().await,
};
let result = wait_for_query_opt(
cancel_token,
query_timeout,
db::sqlserver::execute_query_with_max_rows(&mut client, sql, max_rows),
)
.await
.map(|result| truncate_result_with_max_rows(result, max_rows));
let execution = async {
if execution_mode == QueryExecutionMode::Simple {
let mut results =
db::sqlserver::execute_simple_batch_with_max_rows(&mut client, sql, max_rows).await?;
Ok(results.remove(0))
} else {
db::sqlserver::execute_query_with_max_rows(&mut client, sql, max_rows).await
}
};
let result = wait_for_query_opt(cancel_token, query_timeout, execution)
.await
.map(|result| truncate_result_with_max_rows(result, max_rows));
drop(client);
if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(pool_db_type, err)) {
state.remove_pool_by_key(pool_key).await;
@ -2186,6 +2203,7 @@ async fn execute_multi_sqlserver(
let mut all_results = Vec::new();
let max_rows = options.max_rows;
let query_timeout = resolve_query_timeout(options.timeout_secs);
let execution_mode = options.execution_mode;
for batch in &batches {
if is_canceled(&cancel_token) {
@ -2226,12 +2244,14 @@ async fn execute_multi_sqlserver(
break;
}
let result = wait_for_result_opt(
cancel_token.clone(),
query_timeout,
db::sqlserver::execute_batch_with_max_rows(&mut client_guard, batch, max_rows),
)
.await;
let execution = async {
if execution_mode == QueryExecutionMode::Simple {
db::sqlserver::execute_simple_batch_with_max_rows(&mut client_guard, batch, max_rows).await
} else {
db::sqlserver::execute_batch_with_max_rows(&mut client_guard, batch, max_rows).await
}
};
let result = wait_for_result_opt(cancel_token.clone(), query_timeout, execution).await;
drop(client_guard);
match result {
@ -3235,6 +3255,14 @@ mod tests {
};
use crate::storage::Storage;
#[test]
fn query_execution_mode_deserializes_simple_client_value() {
let mode: QueryExecutionMode = serde_json::from_str("\"simple\"").unwrap();
assert_eq!(mode, QueryExecutionMode::Simple);
assert_eq!(QueryExecutionMode::default(), QueryExecutionMode::Standard);
}
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
ConnectionConfig {
id: "conn-1".to_string(),

View File

@ -53,6 +53,9 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
if !is_safe_explain_sql(&source) {
return explain_err("unsafe");
}
if options.database_type == Some(DatabaseType::SqlServer) && crate::sql::split_sql_batches(&source).len() != 1 {
return explain_err("unsafe");
}
let sql = match options.database_type {
Some(DatabaseType::Postgres | DatabaseType::MongoDb) => {
@ -62,6 +65,9 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
format!("EXPLAIN {source}")
}
Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"),
Some(DatabaseType::SqlServer) => {
format!("SET SHOWPLAN_XML ON;\nGO\n{source}\nGO\nSET SHOWPLAN_XML OFF;")
}
Some(DatabaseType::Mysql) if options.format == Some(ExplainFormat::Standard) => {
// MySQL 8.0.32+ may otherwise inherit TREE or JSON from the session-level explain_format.
format!("EXPLAIN FORMAT=TRADITIONAL {source}")
@ -99,6 +105,7 @@ pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
| DatabaseType::Questdb
| DatabaseType::Dameng
| DatabaseType::Oracle
| DatabaseType::SqlServer
)
)
}
@ -202,6 +209,9 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
}
fn is_write_sql_with_database_type(sql: &str, database_type: Option<DatabaseType>) -> bool {
if database_type == Some(DatabaseType::SqlServer) && is_sqlserver_showplan_xml_set(sql) {
return false;
}
if database_type.is_some_and(|database_type| has_dialect_specific_write(sql, database_type)) {
return true;
}
@ -220,6 +230,14 @@ fn is_write_sql_with_database_type(sql: &str, database_type: Option<DatabaseType
.any(|statement| is_write_sql_statement(statement, detect_mysql_executable_comments, detect_select_into))
}
fn is_sqlserver_showplan_xml_set(sql: &str) -> bool {
let normalized = strip_sql_comments(sql)
.split_whitespace()
.map(|part| part.trim_end_matches(';').to_ascii_uppercase())
.collect::<Vec<_>>();
matches!(normalized.as_slice(), [set, showplan, value] if set == "SET" && showplan == "SHOWPLAN_XML" && matches!(value.as_str(), "ON" | "OFF"))
}
fn is_mysql_compatible_database(database_type: DatabaseType) -> bool {
matches!(
database_type,
@ -716,6 +734,44 @@ mod tests {
);
}
#[test]
fn builds_sqlserver_showplan_xml_batches() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "WITH rows AS (SELECT 1 AS id) SELECT * FROM rows;".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some(
"SET SHOWPLAN_XML ON;\nGO\nWITH rows AS (SELECT 1 AS id) SELECT * FROM rows\nGO\nSET SHOWPLAN_XML OFF;"
.to_string()
),
reason: None,
}
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "SELECT 1\nGO\nSELECT 2".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
assert!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "SELECT 'first line\nGO\nlast line' AS text".to_string(),
})
.ok
);
}
#[test]
fn validates_dameng_autotrace_sql_safety() {
assert!(is_safe_dameng_autotrace_sql("SELECT * FROM t WHERE name = 'delete';"));
@ -1144,6 +1200,16 @@ mod tests {
assert!(show_create_err.unwrap_err().contains("Write operation"));
}
#[test]
fn check_read_only_allows_only_sqlserver_showplan_xml_session_switches() {
for sql in ["SET SHOWPLAN_XML ON;", "-- explain\nSET SHOWPLAN_XML OFF"] {
assert_eq!(check_read_only(sql, "readonly", DatabaseType::SqlServer), Ok(()));
}
assert!(check_read_only("SET SHOWPLAN_ALL ON", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML ON; SELECT 1", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML OFF; DROP TABLE users", "readonly", DatabaseType::SqlServer).is_err());
}
#[test]
fn check_read_only_only_treats_executable_comments_as_writes_for_mysql_compatible_connections() {
let mysql_executable_comment = "SELECT 3156 /*!50000 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */";

View File

@ -24,6 +24,7 @@ pub struct ExecuteQueryRequest {
pub timeout_secs: Option<u64>,
pub use_transaction: Option<bool>,
pub continue_on_error: Option<bool>,
pub execution_mode: Option<dbx_core::query::QueryExecutionMode>,
}
#[derive(Deserialize)]
@ -335,6 +336,7 @@ pub async fn execute_query(
timeout_secs: req.timeout_secs,
execution_id: Some(execution_id),
use_transaction: req.use_transaction,
execution_mode: req.execution_mode.unwrap_or_default(),
..Default::default()
},
)
@ -374,6 +376,7 @@ pub async fn execute_multi(
execution_id: Some(execution_id),
use_transaction: req.use_transaction,
continue_on_error: req.continue_on_error.unwrap_or(false),
execution_mode: req.execution_mode.unwrap_or_default(),
},
)
.await

View File

@ -96,6 +96,7 @@
"@types/node": "^25.9.1",
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^6.0.7",
"@xmldom/xmldom": "^0.8.13",
"happy-dom": "^20.10.6",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",

View File

@ -192,6 +192,9 @@ importers:
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))(vue@3.5.35(typescript@6.0.3))
'@xmldom/xmldom':
specifier: ^0.8.13
version: 0.8.13
happy-dom:
specifier: ^20.10.6
version: 20.10.6
@ -1805,6 +1808,10 @@ packages:
peerDependencies:
vue: ^3.5.0
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -5588,6 +5595,8 @@ snapshots:
dependencies:
vue: 3.5.35(typescript@6.0.3)
'@xmldom/xmldom@0.8.13': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2

View File

@ -23,6 +23,7 @@ pub async fn execute_query(
result_session_id: Option<String>,
client_session_id: Option<String>,
timeout_secs: Option<u64>,
execution_mode: Option<dbx_core::query::QueryExecutionMode>,
) -> Result<db::QueryResult, String> {
let execution_id = execution_id.filter(|id| !id.trim().is_empty());
let registered_query = execution_id.as_ref().map(|id| {
@ -48,6 +49,7 @@ pub async fn execute_query(
client_session_id,
timeout_secs,
execution_id,
execution_mode: execution_mode.unwrap_or_default(),
..Default::default()
},
)
@ -71,6 +73,7 @@ pub async fn execute_multi(
timeout_secs: Option<u64>,
use_transaction: Option<bool>,
continue_on_error: Option<bool>,
execution_mode: Option<dbx_core::query::QueryExecutionMode>,
) -> Result<Vec<dbx_core::query::ExecuteMultiResult>, String> {
let execution_id = execution_id.filter(|id| !id.trim().is_empty());
let registered_query = execution_id.as_ref().map(|id| {
@ -108,6 +111,7 @@ pub async fn execute_multi(
execution_id,
use_transaction,
continue_on_error: continue_on_error.unwrap_or(false),
execution_mode: execution_mode.unwrap_or_default(),
},
)
.await;