fix(doris): enable explain plan support
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user