From e2240d4ed1e65c6985a92c28abb66bc7770c53f9 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Thu, 10 Sep 2026 00:28:40 +0800 Subject: [PATCH] fix(doris): enable explain plan support --- .../src/components/editor/AiAssistant.vue | 2 +- .../__tests__/query/dorisExplainPlan.spec.ts | 36 ++++++++ apps/desktop/src/lib/diagram/explainPlan.ts | 35 ++++++- .../__tests__/queryStore.dorisExplain.spec.ts | 91 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 4 +- .../assets/database-drivers.manifest.json | 2 +- crates/dbx-core/src/query_execution_sql.rs | 30 ++++++ plugins/connection-types/doris.yaml | 2 +- 8 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/query/dorisExplainPlan.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.dorisExplain.spec.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 6a2b5042a6..6d4cf9dcca 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -1713,7 +1713,7 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla return parseOracleExplainText(explainData); } if (!explainData || typeof explainData !== "object") return undefined; - const supportedTypes = ["mysql", "postgres", "dameng", "questdb"] as const; + const supportedTypes = ["mysql", "postgres", "dameng", "questdb", "doris"] as const; if (!supportedTypes.includes(dbType as (typeof supportedTypes)[number])) return undefined; try { return parseExplainResult(dbType as (typeof supportedTypes)[number], explainData as import("@/types/database").QueryResult); diff --git a/apps/desktop/src/lib/__tests__/query/dorisExplainPlan.spec.ts b/apps/desktop/src/lib/__tests__/query/dorisExplainPlan.spec.ts new file mode 100644 index 0000000000..32e29e3aba --- /dev/null +++ b/apps/desktop/src/lib/__tests__/query/dorisExplainPlan.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { flattenExplainPlanNodes, parseExplainResult, supportsExplainPlan } from "@/lib/diagram/explainPlan"; +import type { QueryResult } from "@/types/database"; + +const DORIS_PLAN = `PLAN FRAGMENT 0 +OUTPUT EXPRS: + 1: : 11: order_id +PARTITION: UNPARTITIONED + +VEXCHANGE + 0:VSCAN NODE + TABLE: internal.sales.orders + PREAGGREGATION: ON`; + +function explainResult(planText: string): QueryResult { + return { + columns: ["Explain String"], + rows: planText.split("\n").map((line) => [line]), + affected_rows: 0, + execution_time_ms: 1, + }; +} + +describe("Doris explain plan", () => { + it("is enabled by the driver capability manifest", () => { + expect(supportsExplainPlan("doris")).toBe(true); + }); + + it("keeps the text plan available without sending it through a JSON parser", () => { + const parsed = parseExplainResult("doris", explainResult(DORIS_PLAN)); + + expect(parsed.databaseType).toBe("doris"); + expect(parsed.raw).toBe(DORIS_PLAN); + expect(flattenExplainPlanNodes(parsed.nodes).map((node) => node.title)).toEqual(["PLAN FRAGMENT 0", "OUTPUT EXPRS:", "1: : 11: order_id", "PARTITION: UNPARTITIONED", "VEXCHANGE", "0:VSCAN NODE", "TABLE: internal.sales.orders", "PREAGGREGATION: ON"]); + }); +}); diff --git a/apps/desktop/src/lib/diagram/explainPlan.ts b/apps/desktop/src/lib/diagram/explainPlan.ts index a024d06e2e..56605ca6b8 100644 --- a/apps/desktop/src/lib/diagram/explainPlan.ts +++ b/apps/desktop/src/lib/diagram/explainPlan.ts @@ -16,16 +16,18 @@ export interface ExplainPlanNode { children: ExplainPlanNode[]; } +export type ExplainPlanDatabaseType = "mysql" | "postgres" | "dameng" | "questdb" | "doris" | "oracle" | "sqlserver"; + export interface ParsedExplainPlan { - databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "oracle" | "sqlserver"; + databaseType: ExplainPlanDatabaseType; raw: unknown; nodes: ExplainPlanNode[]; } export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" }; -const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb", "oracle", "sqlserver"]); -export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" | "oracle" | "sqlserver" { +const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb", "doris", "oracle", "sqlserver"]); +export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is ExplainPlanDatabaseType { return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType); } @@ -34,11 +36,13 @@ export function buildExplainSql(databaseType: DatabaseType | undefined, sql: str return api.buildExplainSql({ databaseType, sql, format, analyze }) as Promise; } -export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng" | "questdb" | "sqlserver", result: QueryResult): ParsedExplainPlan { +export function parseExplainResult(databaseType: ExplainPlanDatabaseType, result: QueryResult): ParsedExplainPlan { if (databaseType === "dameng") { return parseDamengExplain(result); } else if (databaseType === "questdb") { return parseQuestdbExplain(result); + } else if (databaseType === "doris") { + return parseDorisExplain(result); } else if (databaseType === "sqlserver") { return parseSqlServerExplain(result); } @@ -873,6 +877,29 @@ function parseQuestdbExplain(result: QueryResult): ParsedExplainPlan { return { databaseType: "questdb", raw: result, nodes }; } +// ── Doris explain parser ──────────────────────────────────────────── +function parseDorisExplain(result: QueryResult): ParsedExplainPlan { + const lines = result.rows.flatMap((row) => String(row[0] ?? "").split(/\r?\n/)); + const raw = lines.join("\n"); + const nodes = lines + .filter((line) => line.trim().length > 0) + .map((line, index): ExplainPlanNode => { + const text = line.trim(); + return { + id: `plan_${index}`, + title: text, + nodeType: "Plan", + relation: "", + index: String(index), + cost: undefined, + rows: undefined, + details: [text], + children: [], + }; + }); + return { databaseType: "doris", raw, nodes }; +} + // ── Helpers ─────────────────────────────────────────────────────────── function objectValue(value: unknown): Record | null { diff --git a/apps/desktop/src/stores/__tests__/queryStore.dorisExplain.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.dorisExplain.spec.ts new file mode 100644 index 0000000000..3e4cc6dfcd --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.dorisExplain.spec.ts @@ -0,0 +1,91 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueryResult } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + buildExplainSql: vi.fn(), + parseExplainResult: vi.fn(), + executeQuery: vi.fn(), + closeClientSession: 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: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, + closeClientConnectionSession: mocks.closeClientSession, + 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 }, + }), +})); + +function installLocalStorage() { + const data = new Map(); + 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)), + }); +} + +const SOURCE_SQL = "SELECT * FROM users"; +const EXPLAIN_SQL = "EXPLAIN SELECT * FROM users"; +const DORIS_RESULT: QueryResult = { + columns: ["Explain String"], + rows: [["PLAN FRAGMENT 0"], [" 0:VSCAN NODE"]], + affected_rows: 0, + execution_time_ms: 1, +}; +const PARSED_PLAN = { databaseType: "doris", raw: "PLAN FRAGMENT 0\n 0:VSCAN NODE", nodes: [] }; + +describe("queryStore Doris EXPLAIN", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + mocks.getConfig.mockReturnValue({ id: "doris-1", name: "Doris", db_type: "doris", query_timeout_secs: 30 }); + mocks.buildExplainSql.mockResolvedValue({ ok: true, sql: EXPLAIN_SQL }); + mocks.executeQuery.mockResolvedValue(DORIS_RESULT); + mocks.parseExplainResult.mockReturnValue(PARSED_PLAN); + mocks.closeClientSession.mockResolvedValue(undefined); + mocks.saveOpenTabsState.mockResolvedValue(undefined); + }); + + it("executes native EXPLAIN and dispatches the text result to the Doris parser", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("doris-1", "analytics", "Query", "query", "warehouse"); + + await store.explainTabSql(tabId, SOURCE_SQL, "doris"); + + expect(mocks.buildExplainSql).toHaveBeenCalledWith("doris", SOURCE_SQL); + expect(mocks.executeQuery).toHaveBeenCalledWith("doris-1", "analytics", EXPLAIN_SQL, "warehouse", expect.any(String), expect.objectContaining({ clientSessionId: `${tabId}:explain` })); + expect(mocks.parseExplainResult).toHaveBeenCalledWith("doris", DORIS_RESULT); + expect(store.tabs.find((tab) => tab.id === tabId)).toMatchObject({ + isExplaining: false, + explainPlan: PARSED_PLAN, + explainSql: EXPLAIN_SQL, + explainError: undefined, + }); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index be982ac41f..cdcd923b36 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n"; import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; -import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan"; +import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan"; import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility"; import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys"; @@ -7097,7 +7097,7 @@ export const useQueryStore = defineStore("query", () => { }); const current = tabs.value.find((t) => t.id === id); if (current?.explainExecutionId === executionId) { - current.explainPlan = parseExplainResult(databaseType as "mysql" | "postgres", result); + current.explainPlan = parseExplainResult(databaseType as ExplainPlanDatabaseType, result); current.explainError = undefined; } } catch (e: any) { diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 87b5454f94..2c76d2f523 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -737,7 +737,7 @@ "sqlFileExecution": true, "databaseCreate": true, "fieldLineage": false, - "sqlExplain": false, + "sqlExplain": true, "userAdmin": true, "driverManagement": false } diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs index b61cd5da94..c7e998cb5d 100644 --- a/crates/dbx-core/src/query_execution_sql.rs +++ b/crates/dbx-core/src/query_execution_sql.rs @@ -81,6 +81,7 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult { Some(DatabaseType::Dameng | DatabaseType::Questdb) => { format!("EXPLAIN {source}") } + Some(DatabaseType::Doris) => format!("EXPLAIN {source}"), Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"), // STATISTICS XML returns the same ShowPlanXML document plus per-operator // runtime counters, at the price of actually running the statement. @@ -123,6 +124,7 @@ pub fn supports_explain_plan(database_type: Option) -> bool { database_type, Some( DatabaseType::Mysql + | DatabaseType::Doris | DatabaseType::Postgres | DatabaseType::Questdb | DatabaseType::Dameng @@ -1174,6 +1176,34 @@ mod tests { ); } + #[test] + fn builds_doris_explain_sql_without_mysql_json_format() { + assert!(supports_explain_plan(Some(DatabaseType::Doris))); + + assert_eq!( + build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::Doris), + format: None, + analyze: None, + sql: "SELECT * FROM users;".to_string(), + }), + ExplainSqlBuildResult { ok: true, sql: Some("EXPLAIN SELECT * FROM users".to_string()), reason: None } + ); + + for sql in ["DELETE FROM users", "SELECT * FROM users; DROP TABLE users"] { + assert_eq!( + build_explain_sql(ExplainSqlOptions { + database_type: Some(DatabaseType::Doris), + format: None, + analyze: None, + sql: sql.to_string(), + }), + ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }, + "{sql}" + ); + } + } + #[test] fn builds_oracle_explain_plan_sql() { let result = build_explain_sql(ExplainSqlOptions { diff --git a/plugins/connection-types/doris.yaml b/plugins/connection-types/doris.yaml index 81cc98339f..bd96f0523c 100644 --- a/plugins/connection-types/doris.yaml +++ b/plugins/connection-types/doris.yaml @@ -25,6 +25,6 @@ capabilities: sqlFileExecution: true databaseCreate: true fieldLineage: false - sqlExplain: false + sqlExplain: true userAdmin: true driverManagement: false