feat(oracle): support explain plans

This commit is contained in:
zipg authored and GitHub committed 2026-07-10 17:55:03 +08:00
1 parent 0f4abce2b8
commit e1d6d1224e
13 files changed
+605 -117

No files matched your search

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { flattenExplainPlanNodes, parseOracleExplainText, supportsExplainPlan } from "@/lib/diagram/explainPlan";
const ORACLE_PLAN = `Plan hash value: 321708281
-----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
-----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 34 | 5 (0)| 00:00:01 |
| 1 | NESTED LOOPS | | 1 | 34 | 5 (0)| 00:00:01 |
| 2 | NESTED LOOPS | | 1 | 31 | 4 (0)| 00:00:01 |
|* 3 | TABLE ACCESS BY INDEX ROWID| USER$ | 1 | 28 | 3 (0)| 00:00:01 |
|* 4 | INDEX RANGE SCAN | I_USER1 | 3 | | 1 (0)| 00:00:01 |
| 5 | TABLE ACCESS CLUSTER | TS$ | 1 | 3 | 1 (0)| 00:00:01 |
|* 6 | INDEX UNIQUE SCAN | I_TS# | 1 | | 0 (0)| 00:00:01 |
| 7 | TABLE ACCESS CLUSTER | TS$ | 1 | 3 | 1 (0)| 00:00:01 |
|* 8 | INDEX UNIQUE SCAN | I_TS# | 1 | | 0 (0)| 00:00:01 |
-----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
3 - filter("U"."TYPE#"=1 AND "U"."USER#">0)
4 - access("U"."NAME" LIKE 'S%' AND
"U"."CREATED_AT">=TO_DATE(' 2025-12-01 00:00:00',
'syyyy-mm-dd hh24:mi:ss'))
filter("U"."NAME" LIKE 'S%')`;
describe("Oracle explain plan", () => {
it("is enabled by the driver capability manifest", () => {
expect(supportsExplainPlan("oracle")).toBe(true);
});
it("parses DBMS_XPLAN text into a hierarchy with predicates", () => {
const plan = parseOracleExplainText(ORACLE_PLAN);
const nodes = flattenExplainPlanNodes(plan.nodes);
expect(plan.databaseType).toBe("oracle");
expect(plan.raw).toBe(ORACLE_PLAN);
expect(plan.nodes).toHaveLength(1);
expect(plan.nodes[0].nodeType).toBe("SELECT STATEMENT");
expect(plan.nodes[0].children[0].nodeType).toBe("NESTED LOOPS");
expect(plan.nodes[0].children[0].children.map((node) => node.id)).toEqual(["2", "7"]);
const tableAccess = nodes.find((node) => node.id === "3");
expect(tableAccess).toMatchObject({ relation: "USER$", rows: "1", cost: "3 (0)" });
expect(tableAccess?.details).toContain('Predicate: filter("U"."TYPE#"=1 AND "U"."USER#">0)');
const indexScan = nodes.find((node) => node.id === "4");
expect(indexScan?.index).toBe("I_USER1");
expect(indexScan?.details).toEqual(["Time: 00:00:01", 'Predicate: access("U"."NAME" LIKE \'S%\' AND "U"."CREATED_AT">=TO_DATE(\' 2025-12-01 00:00:00\', \'syyyy-mm-dd hh24:mi:ss\'))', 'Predicate: filter("U"."NAME" LIKE \'S%\')']);
});
it("keeps unrecognized text available in the raw view", () => {
expect(parseOracleExplainText("Oracle plan unavailable")).toEqual({
databaseType: "oracle",
raw: "Oracle plan unavailable",
nodes: [],
});
});
});
+121 -3
View File
@@ -16,15 +16,15 @@ export interface ExplainPlanNode {
}
export interface ParsedExplainPlan {
databaseType: "mysql" | "postgres" | "dameng" | "questdb";
databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "oracle";
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"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" {
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres", "dameng", "questdb", "oracle"]);
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" | "oracle" {
return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
}
@@ -142,6 +142,124 @@ export function parseDamengExplainText(planText: string): ParsedExplainPlan {
return { databaseType: "dameng", raw: planText, nodes: rootNodes };
}
export function parseOracleExplainText(planText: string): ParsedExplainPlan {
const lines = planText.split("\n");
const headerIndex = lines.findIndex((line) => /^\|\s*Id\s*\|/i.test(line) && line.includes("Operation"));
if (headerIndex < 0) return { databaseType: "oracle", raw: planText, nodes: [] };
const headers = splitOraclePlanColumns(lines[headerIndex]).map((header) => header.trim().toLowerCase().replace(/\s+/g, " "));
const columnIndex = (name: string) => headers.findIndex((header) => header === name || header.startsWith(`${name} `));
const idIndex = columnIndex("id");
const operationIndex = columnIndex("operation");
const nameIndex = columnIndex("name");
const rowsIndex = columnIndex("rows");
const bytesIndex = columnIndex("bytes");
const costIndex = columnIndex("cost");
const timeIndex = columnIndex("time");
const predicates = oraclePredicateDetails(lines);
const parsedRows: Array<{
id: string;
depth: number;
operation: string;
name?: string;
rows?: string;
cost?: string;
bytes?: string;
time?: string;
}> = [];
let baseIndent: number | undefined;
for (const line of lines.slice(headerIndex + 1)) {
if (!line.startsWith("|")) continue;
const cells = splitOraclePlanColumns(line);
const idMatch = cells[idIndex]?.match(/\d+/);
const operationCell = cells[operationIndex];
if (!idMatch || operationCell == null) continue;
const indent = operationCell.search(/\S/);
if (indent < 0) continue;
baseIndent ??= indent;
parsedRows.push({
id: idMatch[0],
depth: Math.max(0, indent - baseIndent),
operation: operationCell.trim(),
name: cellValue(cells, nameIndex),
rows: cellValue(cells, rowsIndex),
cost: cellValue(cells, costIndex),
bytes: cellValue(cells, bytesIndex),
time: cellValue(cells, timeIndex),
});
}
const roots: ExplainPlanNode[] = [];
const parents: ExplainPlanNode[] = [];
for (const row of parsedRows) {
const isIndex = /\bINDEX\b/i.test(row.operation) && !/\bTABLE ACCESS\b/i.test(row.operation);
const relation = row.name && !isIndex ? row.name : undefined;
const index = row.name && isIndex ? row.name : undefined;
const details = [row.bytes ? `Bytes: ${row.bytes}` : "", row.time ? `Time: ${row.time}` : "", ...(predicates.get(row.id) ?? []).map((predicate) => `Predicate: ${predicate}`)].filter(Boolean);
const node: ExplainPlanNode = {
id: row.id,
title: row.name ? `${row.operation} on ${row.name}` : row.operation,
nodeType: row.operation,
relation,
index,
cost: row.cost,
rows: row.rows,
details,
children: [],
};
while (parents.length > row.depth) parents.pop();
const parent = row.depth > 0 ? parents[row.depth - 1] : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
parents[row.depth] = node;
parents.length = row.depth + 1;
}
return { databaseType: "oracle", raw: planText, nodes: roots };
}
function splitOraclePlanColumns(line: string): string[] {
const columns = line.split("|");
return columns.length >= 3 ? columns.slice(1, -1) : [];
}
function cellValue(cells: string[], index: number): string | undefined {
if (index < 0) return undefined;
const value = cells[index]?.trim();
return value || undefined;
}
function oraclePredicateDetails(lines: string[]): Map<string, string[]> {
const predicates = new Map<string, string[]>();
const start = lines.findIndex((line) => line.trim().toLowerCase().startsWith("predicate information"));
if (start < 0) return predicates;
let currentId = "";
for (const rawLine of lines.slice(start + 1)) {
const line = rawLine.trim();
if (!line || /^-+$/.test(line)) continue;
if (/^note\b/i.test(line)) break;
const entry = line.match(/^(\d+)\s*-\s*(.+)$/);
if (entry) {
currentId = entry[1];
predicates.set(currentId, [entry[2]]);
} else if (currentId) {
const details = predicates.get(currentId);
if (!details?.length) continue;
if (/^(?:access|filter|storage)\s*\(/i.test(line)) {
details.push(line);
} else {
details[details.length - 1] = `${details[details.length - 1]} ${line}`;
}
}
}
return predicates;
}
interface DamengOpInfo {
operation: string;
nodeType: string;