From 06fc0df234bffd6c75b626af915b5fc4563d31b6 Mon Sep 17 00:00:00 2001 From: efeire Date: Sun, 20 Sep 2026 11:05:39 +0800 Subject: [PATCH] feat(oceanbase-oracle): support graphical execution plans --- .../query/oceanbaseOracleExplainPlan.spec.ts | 53 ++++++++++++++++ apps/desktop/src/lib/diagram/explainPlan.ts | 38 ++++++++++- .../queryStore.oceanbaseOracleExplain.spec.ts | 63 +++++++++++++++++++ .../assets/database-drivers.manifest.json | 2 +- crates/dbx-sql/src/query_execution_sql.rs | 21 +++++++ .../connection-types/oceanbase-oracle.yaml | 2 +- 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts diff --git a/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts b/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts new file mode 100644 index 0000000000..d2fef97f03 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/query/oceanbaseOracleExplainPlan.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { flattenExplainPlanNodes, parseExplainResult, supportsExplainPlan } from "@/lib/diagram/explainPlan"; +import type { QueryResult } from "@/types/database"; + +const JSON_PLAN = { + ID: 0, + OPERATOR: "HASH JOIN ", + NAME: "", + "EST.ROWS": 1, + "EST.TIME(us)": 5, + output: "output([T1.C1], [T2.C1])", + CHILD_2: { ID: 2, OPERATOR: "TABLE FULL SCAN", NAME: "T2", "EST.ROWS": 3, "EST.TIME(us)": 3, filter: "filter([T2.C1 > 4])" }, + CHILD_1: { + ID: 1, + OPERATOR: "SUBPLAN SCAN", + NAME: "T1", + "EST.ROWS": 2, + "EST.TIME(us)": 3, + output: "output([T1.C1])", + CHILD_1: { ID: 3, OPERATOR: "EXPRESSION", NAME: "", "EST.ROWS": 1, "EST.TIME(us)": 1 }, + }, +}; + +function explainResult(text: string): QueryResult { + return { columns: ["Query Plan"], rows: text.split("\n").map((line) => [line]), affected_rows: 0, execution_time_ms: 1 }; +} + +describe("OceanBase Oracle explain plan", () => { + it("is enabled by the generated driver capability", () => { + expect(supportsExplainPlan("oceanbase-oracle")).toBe(true); + }); + + it("joins JDBC rows and keeps the ordered child tree and estimated metrics", () => { + const plan = parseExplainResult("oceanbase-oracle", explainResult(JSON.stringify(JSON_PLAN, null, 2))); + const nodes = flattenExplainPlanNodes(plan.nodes); + + expect(plan.raw).toEqual(JSON_PLAN); + expect(nodes.map((node) => node.id)).toEqual(["0", "1", "3", "2"]); + expect(nodes.map((node) => node.title)).toEqual(["HASH JOIN", "SUBPLAN SCAN on T1", "EXPRESSION", "TABLE FULL SCAN on T2"]); + expect(nodes[0]).toMatchObject({ rows: "1", details: ["Estimated time: 5 µs", "output: output([T1.C1], [T2.C1])"] }); + expect(nodes[1]).toMatchObject({ relation: "T1", rows: "2" }); + expect(nodes[3].details).toContain("filter: filter([T2.C1 > 4])"); + expect(nodes[0].cost).toBeUndefined(); + }); + + it("preserves an unrecognized plan as raw text", () => { + expect(parseExplainResult("oceanbase-oracle", explainResult("unexpected format"))).toEqual({ + databaseType: "oceanbase-oracle", + raw: "unexpected format", + nodes: [], + }); + }); +}); diff --git a/apps/desktop/src/lib/diagram/explainPlan.ts b/apps/desktop/src/lib/diagram/explainPlan.ts index 56605ca6b8..4d1578a532 100644 --- a/apps/desktop/src/lib/diagram/explainPlan.ts +++ b/apps/desktop/src/lib/diagram/explainPlan.ts @@ -16,7 +16,7 @@ export interface ExplainPlanNode { children: ExplainPlanNode[]; } -export type ExplainPlanDatabaseType = "mysql" | "postgres" | "dameng" | "questdb" | "doris" | "oracle" | "sqlserver"; +export type ExplainPlanDatabaseType = "mysql" | "postgres" | "dameng" | "questdb" | "doris" | "oracle" | "oceanbase-oracle" | "sqlserver"; export interface ParsedExplainPlan { databaseType: ExplainPlanDatabaseType; @@ -26,7 +26,7 @@ export interface ParsedExplainPlan { export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" }; -const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb", "doris", "oracle", "sqlserver"]); +const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb", "doris", "oracle", "oceanbase-oracle", "sqlserver"]); export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is ExplainPlanDatabaseType { return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType); } @@ -45,6 +45,8 @@ export function parseExplainResult(databaseType: ExplainPlanDatabaseType, result return parseDorisExplain(result); } else if (databaseType === "sqlserver") { return parseSqlServerExplain(result); + } else if (databaseType === "oceanbase-oracle") { + return parseOceanbaseOracleExplain(result); } const raw = parseExplainCell(result.rows[0]?.[0]); const nodes = databaseType === "postgres" ? parsePostgresExplain(raw) : parseMysqlExplain(raw); @@ -588,6 +590,38 @@ function parseExplainCell(value: unknown): unknown { } } +function parseOceanbaseOracleExplain(result: QueryResult): ParsedExplainPlan { + // OceanBase JDBC returns one line of the JSON document per Query Plan row. + const text = result.rows.map((row) => String(row[0] ?? "")).join("\n"); + const raw = parseExplainCell(text); + const root = objectValue(raw); + return { databaseType: "oceanbase-oracle", raw, nodes: root ? [parseOceanbaseOracleNode(root, "0")] : [] }; +} + +function parseOceanbaseOracleNode(plan: Record, fallbackId: string): ExplainPlanNode { + const nodeType = stringValue(plan.OPERATOR)?.trim() || "Plan"; + const relation = stringValue(plan.NAME)?.trim() || undefined; + const children = Object.entries(plan) + .filter(([key, value]) => /^CHILD_\d+$/.test(key) && objectValue(value)) + .sort(([left], [right]) => Number(left.slice(6)) - Number(right.slice(6))) + .map(([key, value]) => parseOceanbaseOracleNode(objectValue(value)!, `${fallbackId}.${key.slice(6)}`)); + const estimatedTime = numberLike(plan["EST.TIME(us)"]); + const details = Object.entries(plan) + .filter(([key, value]) => !["ID", "OPERATOR", "NAME", "EST.ROWS", "EST.TIME(us)", "COST"].includes(key) && !/^CHILD_\d+$/.test(key) && value !== null) + .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`); + if (estimatedTime !== undefined) details.unshift(`Estimated time: ${estimatedTime} µs`); + return { + id: numberLike(plan.ID) || fallbackId, + title: relation && relation !== nodeType ? `${nodeType} on ${relation}` : nodeType, + nodeType, + relation, + cost: numberLike(plan.COST), + rows: numberLike(plan["EST.ROWS"]), + details, + children, + }; +} + // ── DM (达梦) tabular explain parser ────────────────────────────────── interface DamengExplainRow { diff --git a/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts new file mode 100644 index 0000000000..782b03a946 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.oceanbaseOracleExplain.spec.ts @@ -0,0 +1,63 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildExplainSql: vi.fn(), + executeQuery: vi.fn(), + getExplainInfo: vi.fn(), + saveOpenTabsState: vi.fn(), + getConfig: vi.fn(), +})); + +vi.mock("@/lib/diagram/explainPlan", async (importOriginal) => ({ + ...(await importOriginal()), + buildExplainSql: mocks.buildExplainSql, +})); + +vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, + getExplainInfo: mocks.getExplainInfo, + 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 } }), +})); + +describe("queryStore OceanBase Oracle explain", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }); + setActivePinia(createPinia()); + mocks.getConfig.mockReturnValue({ id: "ob-1", name: "OceanBase", db_type: "oceanbase-oracle" }); + mocks.buildExplainSql.mockResolvedValue({ ok: true, sql: "EXPLAIN FORMAT=JSON SELECT 1 FROM DUAL" }); + mocks.executeQuery.mockResolvedValue({ + columns: ["Query Plan"], + rows: ["{", ' "ID":0,', ' "OPERATOR":"EXPRESSION",', ' "NAME":"",', ' "EST.ROWS":1,', ' "EST.TIME(us)":1', "}"].map((line) => [line]), + affected_rows: 0, + execution_time_ms: 1, + }); + mocks.saveOpenTabsState.mockResolvedValue(undefined); + }); + + it("executes the JSON explain SQL and routes its rows to the OceanBase parser", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("ob-1", "test", "Query", "query", "APP"); + const result = await store.explainTabSql(tabId, "SELECT 1 FROM DUAL", "oceanbase-oracle"); + + expect(result).toEqual({ ok: true, sql: "EXPLAIN FORMAT=JSON SELECT 1 FROM DUAL" }); + expect(mocks.executeQuery).toHaveBeenCalledWith("ob-1", "test", "EXPLAIN FORMAT=JSON SELECT 1 FROM DUAL", "APP", expect.any(String), expect.any(Object)); + expect(mocks.getExplainInfo).not.toHaveBeenCalled(); + expect(store.tabs.find((tab) => tab.id === tabId)).toMatchObject({ + isExplaining: false, + explainError: undefined, + explainSql: "EXPLAIN FORMAT=JSON SELECT 1 FROM DUAL", + explainPlan: { databaseType: "oceanbase-oracle", nodes: [{ id: "0", nodeType: "EXPRESSION", rows: "1", details: ["Estimated time: 1 µs"] }] }, + }); + }); +}); diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 1b69aaa509..35b8776ce3 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -1536,7 +1536,7 @@ "sqlFileExecution": true, "databaseCreate": false, "fieldLineage": true, - "sqlExplain": false, + "sqlExplain": true, "userAdmin": false, "driverManagement": true } diff --git a/crates/dbx-sql/src/query_execution_sql.rs b/crates/dbx-sql/src/query_execution_sql.rs index d2ca9c80bd..191b881b31 100644 --- a/crates/dbx-sql/src/query_execution_sql.rs +++ b/crates/dbx-sql/src/query_execution_sql.rs @@ -129,6 +129,7 @@ pub fn supports_explain_plan(database_type: Option) -> bool { | DatabaseType::Questdb | DatabaseType::Dameng | DatabaseType::Oracle + | DatabaseType::OceanbaseOracle | DatabaseType::SqlServer ) ) @@ -1383,6 +1384,26 @@ mod tests { ); } + #[test] + fn builds_nonexecuting_oceanbase_oracle_json_explain() { + let result = build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::OceanbaseOracle), + format: Some(ExplainFormat::Json), + analyze: Some(true), + sql: "SELECT * FROM events;".to_string(), + }); + assert_eq!(result.sql.as_deref(), Some("EXPLAIN FORMAT=JSON SELECT * FROM events")); + assert!(result.ok); + + let unsafe_result = build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::OceanbaseOracle), + format: None, + analyze: None, + sql: "DELETE FROM events".to_string(), + }); + assert_eq!(unsafe_result.reason.as_deref(), Some("unsafe")); + } + #[test] fn builds_dropped_file_preview_sql() { assert_eq!( diff --git a/plugins/connection-types/oceanbase-oracle.yaml b/plugins/connection-types/oceanbase-oracle.yaml index a6b2d480f1..df5589f018 100644 --- a/plugins/connection-types/oceanbase-oracle.yaml +++ b/plugins/connection-types/oceanbase-oracle.yaml @@ -34,6 +34,6 @@ capabilities: sqlFileExecution: true databaseCreate: false fieldLineage: true - sqlExplain: false + sqlExplain: true userAdmin: false driverManagement: true