diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 80556cbd4d..69ebaf1de8 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -86,6 +86,16 @@ fn mysql_value_to_json(row: &MySqlRow, idx: usize, type_name: &str) -> serde_jso let upper_type = type_name.to_uppercase(); + if upper_type == "JSON" { + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return serde_json::from_str::(&v).unwrap_or(serde_json::Value::String(v)); + } + return serde_json::Value::Null; + } + if upper_type == "BOOLEAN" { return row .try_get::(idx) diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs index d3ae177453..c251aa9f9a 100644 --- a/src-tauri/src/db/postgres.rs +++ b/src-tauri/src/db/postgres.rs @@ -29,6 +29,16 @@ fn pg_value_to_json(row: &PgRow, idx: usize, type_name: &str) -> serde_json::Val let upper = type_name.to_uppercase(); + if upper == "JSON" || upper == "JSONB" { + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return serde_json::from_str::(&v).unwrap_or(serde_json::Value::String(v)); + } + return serde_json::Value::Null; + } + if upper == "BOOL" { return row .try_get::(idx) diff --git a/src/App.vue b/src/App.vue index dde9ffa070..f8c17704dc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@ + + diff --git a/src/components/explain/ExplainPlanViewer.vue b/src/components/explain/ExplainPlanViewer.vue new file mode 100644 index 0000000000..e8055339a9 --- /dev/null +++ b/src/components/explain/ExplainPlanViewer.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index aae8b200a6..5614189890 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -8,6 +8,8 @@ export default { execute: "Execute", executeShortcut: "Execute selection/query (Cmd+Enter)", stopQuery: "Stop query", + explainPlan: "Explain plan", + stopExplain: "Stop explain", formatSql: "Format SQL", formatSqlFailed: "Failed to format SQL", }, @@ -165,6 +167,23 @@ export default { stopping: "Stopping...", close: "Close", }, + explain: { + title: "Explain Plan", + tree: "Tree", + summary: "Summary", + running: "Reading explain plan...", + empty: "No explain plan", + nodeCount: "{count} nodes", + node: "Node", + relation: "Table", + index: "Index", + cost: "Cost", + rows: "Rows", + details: "Details", + unsupported: "Explain plan is not supported for this database yet", + emptySql: "No SQL to explain", + unsafe: "This first version only explains SELECT / WITH / TABLE / VALUES statements", + }, ai: { placeholder: "Describe your query in natural language...", settings: "AI Settings", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index bdcfe8cd0b..d860b8c336 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -8,6 +8,8 @@ export default { execute: "执行", executeShortcut: "执行选中/全部 (Cmd+Enter)", stopQuery: "停止执行", + explainPlan: "执行计划", + stopExplain: "停止执行计划", formatSql: "格式化 SQL", formatSqlFailed: "SQL 格式化失败", }, @@ -165,6 +167,23 @@ export default { stopping: "正在停止...", close: "关闭", }, + explain: { + title: "执行计划", + tree: "树", + summary: "摘要", + running: "正在读取执行计划...", + empty: "暂无执行计划", + nodeCount: "{count} 个节点", + node: "节点", + relation: "表", + index: "索引", + cost: "Cost", + rows: "行数", + details: "详情", + unsupported: "当前数据库暂不支持执行计划", + emptySql: "当前没有可分析的 SQL", + unsafe: "第一版执行计划仅支持 SELECT / WITH / TABLE / VALUES", + }, ai: { placeholder: "描述你想查什么...", settings: "AI 设置", diff --git a/src/lib/explainPlan.ts b/src/lib/explainPlan.ts new file mode 100644 index 0000000000..7bbfb5d2a3 --- /dev/null +++ b/src/lib/explainPlan.ts @@ -0,0 +1,232 @@ +import type { DatabaseType, QueryResult } from "@/types/database"; + +export interface ExplainPlanNode { + id: string; + title: string; + nodeType: string; + relation?: string; + index?: string; + cost?: string; + rows?: string; + width?: string; + details: string[]; + children: ExplainPlanNode[]; +} + +export interface ParsedExplainPlan { + databaseType: "mysql" | "postgres"; + 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"]); +const SAFE_EXPLAIN_RE = /^(select|with|table|values)\b/i; + +export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" { + return !!databaseType && SUPPORTED_EXPLAIN_TYPES.has(databaseType); +} + +export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string): BuildExplainSqlResult { + if (!supportsExplainPlan(databaseType)) return { ok: false, reason: "unsupported" }; + + const source = stripTrailingSemicolons(sql.trim()); + if (!source) return { ok: false, reason: "empty" }; + if (!SAFE_EXPLAIN_RE.test(stripSqlComments(source).trim())) return { ok: false, reason: "unsafe" }; + + if (databaseType === "postgres") { + return { ok: true, sql: `EXPLAIN (FORMAT JSON) ${source}` }; + } + + return { ok: true, sql: `EXPLAIN FORMAT=JSON ${source}` }; +} + +export function parseExplainResult(databaseType: "mysql" | "postgres", result: QueryResult): ParsedExplainPlan { + const raw = parseExplainCell(result.rows[0]?.[0]); + const nodes = databaseType === "postgres" + ? parsePostgresExplain(raw) + : parseMysqlExplain(raw); + + return { databaseType, raw, nodes }; +} + +export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNode[] { + const rows: ExplainPlanNode[] = []; + function visit(node: ExplainPlanNode) { + rows.push(node); + node.children.forEach((child) => visit(child)); + } + nodes.forEach((node) => visit(node)); + return rows; +} + +function stripTrailingSemicolons(sql: string): string { + return sql.replace(/;\s*$/g, ""); +} + +function stripSqlComments(sql: string): string { + return sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/--.*$/gm, " ") + .replace(/#.*$/gm, " "); +} + +function parseExplainCell(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function parsePostgresExplain(raw: unknown): ExplainPlanNode[] { + const plans = Array.isArray(raw) ? raw : [raw]; + return plans + .map((item, index) => { + const root = objectValue(item); + if (!root) return null; + const plan = objectValue(root.Plan) || root; + return parsePostgresNode(plan, String(index)); + }) + .filter((node): node is ExplainPlanNode => !!node); +} + +function parsePostgresNode(plan: Record | null, id: string): ExplainPlanNode | null { + if (!plan) return null; + const nodeType = stringValue(plan["Node Type"]) || "Plan"; + const relation = stringValue(plan["Relation Name"]); + const index = stringValue(plan["Index Name"]); + const startupCost = numberLike(plan["Startup Cost"]); + const totalCost = numberLike(plan["Total Cost"]); + const rows = numberLike(plan["Plan Rows"]); + const width = numberLike(plan["Plan Width"]); + const filter = stringValue(plan.Filter); + const joinType = stringValue(plan["Join Type"]); + const sortKey = arrayValue(plan["Sort Key"])?.map(String).join(", "); + + const children = arrayValue(plan.Plans) + ?.map((child, childIndex) => parsePostgresNode(objectValue(child), `${id}.${childIndex}`)) + .filter((node): node is ExplainPlanNode => !!node) ?? []; + + return { + id, + title: relation ? `${nodeType} on ${relation}` : nodeType, + nodeType, + relation, + index, + cost: [startupCost, totalCost].every(Boolean) ? `${startupCost}..${totalCost}` : totalCost, + rows, + width, + details: [ + joinType ? `Join: ${joinType}` : "", + filter ? `Filter: ${filter}` : "", + sortKey ? `Sort: ${sortKey}` : "", + ].filter(Boolean), + children, + }; +} + +function parseMysqlExplain(raw: unknown): ExplainPlanNode[] { + const root = objectValue(raw); + if (!root) return []; + const block = objectValue(root.query_block) || root; + return [parseMysqlBlock(block, "0", "query_block")]; +} + +function parseMysqlBlock(block: Record, id: string, nodeType: string): ExplainPlanNode { + const costInfo = objectValue(block.cost_info); + const children: ExplainPlanNode[] = []; + + const table = objectValue(block.table); + if (table) children.push(parseMysqlTable(table, `${id}.0`)); + + const nestedLoop = arrayValue(block.nested_loop); + if (nestedLoop) { + nestedLoop.forEach((item) => { + const itemObject = objectValue(item); + if (!itemObject) return; + const nestedTable = objectValue(itemObject.table); + if (nestedTable) { + children.push(parseMysqlTable(nestedTable, `${id}.${children.length}`)); + return; + } + children.push(parseMysqlBlock(itemObject, `${id}.${children.length}`, "operation")); + }); + } + + [ + "ordering_operation", + "grouping_operation", + "duplicates_removal", + "union_result", + "materialized_from_subquery", + ].forEach((key) => { + const child = objectValue(block[key]); + if (child) children.push(parseMysqlBlock(child, `${id}.${children.length}`, key)); + }); + + return { + id, + title: nodeType, + nodeType, + cost: stringValue(costInfo?.query_cost), + rows: numberLike(block.select_id), + details: [stringValue(block.message)].filter(nonEmptyString), + children, + }; +} + +function parseMysqlTable(table: Record, id: string): ExplainPlanNode { + const relation = stringValue(table.table_name); + const accessType = stringValue(table.access_type) || "table"; + const costInfo = objectValue(table.cost_info); + const rows = numberLike(table.rows_examined_per_scan) || numberLike(table.rows_produced_per_join); + const cost = stringValue(costInfo?.query_cost) || stringValue(costInfo?.read_cost) || stringValue(costInfo?.eval_cost); + const details = [ + stringValue(table.attached_condition) ? `Condition: ${stringValue(table.attached_condition)}` : "", + arrayValue(table.used_columns)?.length ? `Columns: ${arrayValue(table.used_columns)!.map(String).join(", ")}` : "", + table.using_index === true ? "Using index" : "", + ].filter(Boolean); + + return { + id, + title: relation ? `${accessType} on ${relation}` : accessType, + nodeType: accessType, + relation, + index: stringValue(table.key), + cost, + rows, + details, + children: [], + }; +} + +function objectValue(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function arrayValue(value: unknown): unknown[] | null { + return Array.isArray(value) ? value : null; +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return undefined; +} + +function nonEmptyString(value: string | undefined): value is string { + return !!value; +} + +function numberLike(value: unknown): string | undefined { + if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(value); + if (typeof value === "string" && value.trim()) return value; + return undefined; +} diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index 271289c604..e2f8c9d33e 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -1,9 +1,10 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -import type { QueryTab } from "@/types/database"; +import type { DatabaseType, QueryTab } from "@/types/database"; import { orderPinnedFirst } from "@/lib/pinnedItems"; import { canCancelQueryExecution } from "@/lib/queryExecutionState"; import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions"; +import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan"; import * as api from "@/lib/tauri"; export const useQueryStore = defineStore("query", () => { @@ -33,6 +34,7 @@ export const useQueryStore = defineStore("query", () => { sql: "", isExecuting: false, isCancelling: false, + isExplaining: false, mode, }; tabs.value.push(tab); @@ -44,6 +46,7 @@ export const useQueryStore = defineStore("query", () => { const idx = tabs.value.findIndex((t) => t.id === id); if (idx < 0) return; if (tabs.value[idx].isExecuting) void cancelTabExecution(id); + if (tabs.value[idx].isExplaining) void cancelTabExplain(id); tabs.value.splice(idx, 1); if (activeTabId.value === id) { activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null; @@ -54,6 +57,9 @@ export const useQueryStore = defineStore("query", () => { tabs.value .filter((tab) => tab.id !== id && tab.isExecuting) .forEach((tab) => void cancelTabExecution(tab.id)); + tabs.value + .filter((tab) => tab.id !== id && tab.isExplaining) + .forEach((tab) => void cancelTabExplain(tab.id)); const next = closeOtherTabsState(tabs.value, activeTabId.value, id); tabs.value = next.tabs; activeTabId.value = next.activeTabId; @@ -63,6 +69,9 @@ export const useQueryStore = defineStore("query", () => { tabs.value .filter((tab) => tab.isExecuting) .forEach((tab) => void cancelTabExecution(tab.id)); + tabs.value + .filter((tab) => tab.isExplaining) + .forEach((tab) => void cancelTabExplain(tab.id)); const next = closeAllTabsState(tabs.value, activeTabId.value); tabs.value = next.tabs; activeTabId.value = next.activeTabId; @@ -86,6 +95,7 @@ export const useQueryStore = defineStore("query", () => { tab.database = database; tab.result = undefined; tab.lastExecutedSql = undefined; + clearExplain(tab); tab.tableMeta = undefined; } @@ -96,6 +106,7 @@ export const useQueryStore = defineStore("query", () => { tab.database = database; tab.result = undefined; tab.lastExecutedSql = undefined; + clearExplain(tab); tab.tableMeta = undefined; } @@ -114,6 +125,15 @@ export const useQueryStore = defineStore("query", () => { } } + function clearExplain(tab: QueryTab) { + tab.explainPlan = undefined; + tab.explainError = undefined; + tab.explainSql = undefined; + tab.lastExplainedSql = undefined; + tab.isExplaining = false; + tab.explainExecutionId = undefined; + } + function toErrorResult(e: any): NonNullable { return { columns: ["Error"], @@ -171,6 +191,46 @@ export const useQueryStore = defineStore("query", () => { trimResultCache(); } + async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab) return { ok: false as const, reason: "empty" as const }; + + const built = buildExplainSql(databaseType, sql); + if (!built.ok) { + tab.explainPlan = undefined; + tab.explainError = built.reason; + return built; + } + + const executionId = crypto.randomUUID(); + tab.isExplaining = true; + tab.explainExecutionId = executionId; + tab.explainError = undefined; + tab.explainSql = built.sql; + tab.lastExplainedSql = sql; + try { + const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, executionId); + const current = tabs.value.find((t) => t.id === id); + if (current?.explainExecutionId === executionId) { + current.explainPlan = parseExplainResult(databaseType as "mysql" | "postgres", result); + current.explainError = undefined; + } + } catch (e: any) { + const current = tabs.value.find((t) => t.id === id); + if (current?.explainExecutionId === executionId) { + current.explainPlan = undefined; + current.explainError = String(e?.message || e); + } + } finally { + const current = tabs.value.find((t) => t.id === id); + if (current?.explainExecutionId === executionId) { + current.isExplaining = false; + current.explainExecutionId = undefined; + } + } + return { ok: true as const, sql: built.sql }; + } + async function cancelTabExecution(id: string) { const tab = tabs.value.find((t) => t.id === id); if (!tab || !canCancelQueryExecution(tab)) return false; @@ -195,6 +255,28 @@ export const useQueryStore = defineStore("query", () => { } } + async function cancelTabExplain(id: string) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab?.isExplaining || !tab.explainExecutionId) return false; + + const executionId = tab.explainExecutionId; + try { + const canceled = await api.cancelQuery(executionId); + if (!canceled) { + const current = tabs.value.find((t) => t.id === id); + if (current && current.explainExecutionId === executionId) current.isExplaining = false; + } + return canceled; + } catch (e: any) { + const current = tabs.value.find((t) => t.id === id); + if (current && current.explainExecutionId === executionId) { + current.isExplaining = false; + current.explainError = String(e?.message || e); + } + return false; + } + } + function trimResultCache() { const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && t.result); if (inactive.length > MAX_CACHED_RESULTS) { @@ -220,6 +302,8 @@ export const useQueryStore = defineStore("query", () => { executeCurrentTab, executeCurrentSql, executeTabSql, + explainTabSql, cancelTabExecution, + cancelTabExplain, }; }); diff --git a/src/types/database.ts b/src/types/database.ts index c8377b6737..1a0ba9c921 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -104,9 +104,15 @@ export interface QueryTab { lastExecutedSql?: string; pinned?: boolean; result?: QueryResult; + explainPlan?: import("@/lib/explainPlan").ParsedExplainPlan; + explainError?: string; + explainSql?: string; + lastExplainedSql?: string; isExecuting: boolean; isCancelling?: boolean; executionId?: string; + isExplaining?: boolean; + explainExecutionId?: string; mode: "data" | "query" | "redis" | "mongo"; tableMeta?: { schema?: string; diff --git a/tests/explainPlan.test.ts b/tests/explainPlan.test.ts new file mode 100644 index 0000000000..b43916ecd2 --- /dev/null +++ b/tests/explainPlan.test.ts @@ -0,0 +1,114 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + buildExplainSql, + flattenExplainPlanNodes, + parseExplainResult, + supportsExplainPlan, +} from "../src/lib/explainPlan.ts"; + +test("builds PostgreSQL JSON explain SQL from a selected query", () => { + const result = buildExplainSql("postgres", " select * from users where id = 1; "); + + assert.deepEqual(result, { + ok: true, + sql: "EXPLAIN (FORMAT JSON) select * from users where id = 1", + }); +}); + +test("builds MySQL JSON explain SQL and rejects unsafe statement kinds", () => { + assert.deepEqual(buildExplainSql("mysql", "SELECT * FROM users;"), { + ok: true, + sql: "EXPLAIN FORMAT=JSON SELECT * FROM users", + }); + + assert.equal(buildExplainSql("mysql", "delete from users").ok, false); +}); + +test("reports explain support by database type", () => { + assert.equal(supportsExplainPlan("postgres"), true); + assert.equal(supportsExplainPlan("mysql"), true); + assert.equal(supportsExplainPlan("sqlite"), false); +}); + +test("parses PostgreSQL FORMAT JSON output into plan nodes", () => { + const plan = parseExplainResult("postgres", { + columns: ["QUERY PLAN"], + rows: [[[ + { + Plan: { + "Node Type": "Nested Loop", + "Startup Cost": 0.42, + "Total Cost": 42.9, + "Plan Rows": 12, + Plans: [ + { + "Node Type": "Index Scan", + "Relation Name": "users", + "Index Name": "users_pkey", + "Startup Cost": 0.28, + "Total Cost": 8.3, + "Plan Rows": 1, + }, + { + "Node Type": "Seq Scan", + "Relation Name": "orders", + "Filter": "(user_id = users.id)", + "Total Cost": 31.2, + "Plan Rows": 20, + }, + ], + }, + }, + ]]], + affected_rows: 0, + execution_time_ms: 3, + }); + + assert.equal(plan.nodes[0].title, "Nested Loop"); + assert.equal(plan.nodes[0].cost, "0.42..42.9"); + assert.equal(plan.nodes[0].rows, "12"); + assert.equal(plan.nodes[0].children[0].relation, "users"); + assert.equal(plan.nodes[0].children[0].index, "users_pkey"); + assert.equal(flattenExplainPlanNodes(plan.nodes).map((node) => node.nodeType).join(","), "Nested Loop,Index Scan,Seq Scan"); +}); + +test("parses MySQL FORMAT=JSON output into plan nodes", () => { + const plan = parseExplainResult("mysql", { + columns: ["EXPLAIN"], + rows: [[JSON.stringify({ + query_block: { + select_id: 1, + nested_loop: [ + { + table: { + table_name: "users", + access_type: "ref", + key: "idx_users_email", + rows_examined_per_scan: 3, + cost_info: { query_cost: "1.20" }, + attached_condition: "users.email is not null", + }, + }, + { + table: { + table_name: "orders", + access_type: "ALL", + rows_examined_per_scan: 200, + cost_info: { read_cost: "18.00" }, + }, + }, + ], + }, + })]], + affected_rows: 0, + execution_time_ms: 2, + }); + + const flat = flattenExplainPlanNodes(plan.nodes); + assert.equal(flat[0].nodeType, "query_block"); + assert.equal(flat[1].title, "ref on users"); + assert.equal(flat[1].index, "idx_users_email"); + assert.equal(flat[1].cost, "1.20"); + assert.equal(flat[2].rows, "200"); +});