fix(doris): enable explain plan support

This commit is contained in:
jearhe authored and GitHub committed 2026-09-10 00:28:40 +08:00
1 parent 1663f3633f
commit e2240d4ed1
8 files changed
+193 -9

No files matched your search

@@ -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);
@@ -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:<slot 11> : 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:<slot 11> : 11: order_id", "PARTITION: UNPARTITIONED", "VEXCHANGE", "0:VSCAN NODE", "TABLE: internal.sales.orders", "PREAGGREGATION: ON"]);
});
});
+31 -4
View File
@@ -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<DatabaseType>(["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<DatabaseType>(["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<BuildExplainSqlResult>;
}
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<string, unknown> | null {
@@ -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<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)),
});
}
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,
});
});
});
+2 -2
View File
@@ -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) {