feat(sqlserver): add execution plan support
This commit is contained in:
parent
da7eedb49c
commit
e4c56e6165
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@
|
|||
"sqlFileExecution": true,
|
||||
"databaseCreate": true,
|
||||
"fieldLineage": true,
|
||||
"sqlExplain": false,
|
||||
"sqlExplain": true,
|
||||
"userAdmin": false,
|
||||
"driverManagement": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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' */";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue