Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/src/components/explain/ExplainPlanViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" />
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src/lib/__tests__/query/sqlserverExplainPlan.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions apps/desktop/src/lib/backend/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/backend/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
142 changes: 138 additions & 4 deletions apps/desktop/src/lib/diagram/explainPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,29 @@ 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);
}

export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string, format: "json" | "standard" = "json"): Promise<BuildExplainSqlResult> {
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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading