feat: add explain plan viewer

This commit is contained in:
SuLea-IT 2026-05-02 20:57:47 +08:00
parent eab5556b6f
commit d180539778
11 changed files with 747 additions and 19 deletions

View File

@ -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::<serde_json::Value, _>(idx) {
return v;
}
if let Ok(v) = row.try_get::<String, _>(idx) {
return serde_json::from_str::<serde_json::Value>(&v).unwrap_or(serde_json::Value::String(v));
}
return serde_json::Value::Null;
}
if upper_type == "BOOLEAN" {
return row
.try_get::<bool, _>(idx)

View File

@ -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::<serde_json::Value, _>(idx) {
return v;
}
if let Ok(v) = row.try_get::<String, _>(idx) {
return serde_json::from_str::<serde_json::Value>(&v).unwrap_or(serde_json::Value::String(v));
}
return serde_json::Value::Null;
}
if upper == "BOOL" {
return row
.try_get::<bool, _>(idx)

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode, Settings, Sparkles } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode, Settings, Sparkles, GitBranch } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -40,6 +40,7 @@ import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog
import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue";
import TableImportDialog from "@/components/import/TableImportDialog.vue";
import TableStructureEditorDialog from "@/components/structure/TableStructureEditorDialog.vue";
import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue";
import type { ConnectionConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -119,6 +120,7 @@ const dangerSql = ref("");
const pendingDangerSql = ref("");
const selectedSql = ref("");
const formatSqlRequestId = ref(0);
const activeOutputView = ref<"result" | "explain">("result");
const showDangerDialog = ref(false);
const showTransferDialog = ref(false);
const showSchemaDiffDialog = ref(false);
@ -277,6 +279,7 @@ const executableSql = computed(() => {
watch(() => queryStore.activeTabId, () => {
selectedSql.value = "";
pendingDangerSql.value = "";
activeOutputView.value = "result";
});
const activeConnection = computed(() => {
@ -479,6 +482,7 @@ function tryExecute(sqlOverride?: string) {
async function doExecute(sql = executableSql.value) {
const tab = activeTab.value;
if (!tab || !sql.trim()) return;
activeOutputView.value = "result";
const connName = connectionStore.getConfig(tab.connectionId)?.name || "";
const start = Date.now();
await queryStore.executeCurrentSql(sql);
@ -496,7 +500,34 @@ async function doExecute(sql = executableSql.value) {
function cancelActiveExecution() {
const tab = activeTab.value;
if (tab) void queryStore.cancelTabExecution(tab.id);
if (!tab) return;
if (tab.isExecuting) void queryStore.cancelTabExecution(tab.id);
else if (tab.isExplaining) void queryStore.cancelTabExplain(tab.id);
}
function explainReasonMessage(reason: string): string {
if (reason === "unsupported") return t("explain.unsupported");
if (reason === "unsafe") return t("explain.unsafe");
return t("explain.emptySql");
}
async function tryExplain(sqlOverride?: string) {
const tab = activeTab.value;
const sql = sqlOverride ?? executableSql.value;
if (!tab || !sql.trim()) {
toast(t("explain.emptySql"));
return;
}
activeOutputView.value = "explain";
const result = await queryStore.explainTabSql(tab.id, sql, activeConnection.value?.db_type);
if (!result.ok) {
toast(explainReasonMessage(result.reason), 5000);
return;
}
const current = activeTab.value;
if (current?.explainError) toast(current.explainError, 5000);
}
function onDangerConfirm() {
@ -972,7 +1003,7 @@ async function setupFileDrop() {
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:disabled="activeTab.isCancelling || (!activeTab.isExecuting && !executableSql.trim())"
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
@click="activeTab.isExecuting ? cancelActiveExecution() : tryExecute()"
>
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
@ -984,7 +1015,22 @@ async function setupFileDrop() {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="activeTab.isExecuting || !activeTab.sql.trim()" @click="formatActiveSql">
<Button
:variant="activeTab.isExplaining ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())"
@click="activeTab.isExplaining ? cancelActiveExecution() : tryExplain()"
>
<Square v-if="activeTab.isExplaining" class="h-3.5 w-3.5 fill-current" />
<GitBranch v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="formatActiveSql">
<AlignLeft class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@ -1073,22 +1119,59 @@ async function setupFileDrop() {
</Pane>
<Pane :size="60" :min-size="20">
<div class="h-full flex flex-col">
<DataGrid v-if="activeTab.result" :key="activeTab.id" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
<Bot class="h-3.5 w-3.5 text-destructive" />
<button class="text-xs text-destructive hover:underline" @click="fixWithAi(String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
{{ t('ai.fixWithAi') }}
</button>
<div
v-if="activeTab.result || activeTab.explainPlan || activeTab.explainError || activeTab.isExecuting || activeTab.isExplaining"
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1"
>
<Button
size="sm"
:variant="activeOutputView === 'result' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs"
:disabled="!activeTab.result && !activeTab.isExecuting"
@click="activeOutputView = 'result'"
>
{{ t('tabs.tableData') }}
</Button>
<Button
size="sm"
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs gap-1"
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
@click="activeOutputView = 'explain'"
>
<GitBranch class="h-3.5 w-3.5" />
{{ t('explain.title') }}
</Button>
</div>
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
<ExplainPlanViewer
v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0"
:plan="activeTab.explainPlan"
:error="activeTab.explainError"
:loading="activeTab.isExplaining"
:source-sql="activeTab.lastExplainedSql"
:explain-sql="activeTab.explainSql"
/>
<template v-else>
<DataGrid v-if="activeTab.result" :key="activeTab.id" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
<Bot class="h-3.5 w-3.5 text-destructive" />
<button class="text-xs text-destructive hover:underline" @click="fixWithAi(String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
{{ t('ai.fixWithAi') }}
</button>
</div>
</div>
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
{{ t('editor.pressToExecute') }}
</div>
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
</div>
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
{{ t('editor.pressToExecute') }}
</div>
</template>
</div>
</Pane>
</Splitpanes>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ChevronRight } from "lucide-vue-next";
import type { ExplainPlanNode } from "@/lib/explainPlan";
defineProps<{
node: ExplainPlanNode;
depth?: number;
}>();
</script>
<template>
<div class="space-y-2">
<div class="rounded border bg-background px-3 py-2 shadow-xs">
<div class="flex min-w-0 items-center gap-2">
<ChevronRight class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ node.title }}</div>
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span class="rounded bg-muted px-1.5 py-0.5">{{ node.nodeType }}</span>
<span v-if="node.relation" class="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700 dark:bg-blue-950 dark:text-blue-300">{{ node.relation }}</span>
<span v-if="node.index" class="rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">{{ node.index }}</span>
<span v-if="node.cost">{{ node.cost }}</span>
<span v-if="node.rows">{{ node.rows }} rows</span>
</div>
</div>
</div>
<div v-if="node.details.length" class="mt-2 space-y-1 border-t pt-2 text-xs text-muted-foreground">
<div v-for="detail in node.details" :key="detail" class="break-all">{{ detail }}</div>
</div>
</div>
<div v-if="node.children.length" class="space-y-2 border-l pl-4">
<ExplainPlanNodeTree
v-for="child in node.children"
:key="child.id"
:node="child"
:depth="(depth || 0) + 1"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { AlertCircle, Braces, GitBranch, Table2 } from "lucide-vue-next";
import type { ParsedExplainPlan, ExplainPlanNode } from "@/lib/explainPlan";
import { flattenExplainPlanNodes } from "@/lib/explainPlan";
import { Button } from "@/components/ui/button";
import ExplainPlanNodeTree from "./ExplainPlanNodeTree.vue";
const props = defineProps<{
plan?: ParsedExplainPlan;
error?: string;
loading?: boolean;
sourceSql?: string;
explainSql?: string;
}>();
const { t } = useI18n();
const activeView = ref<"tree" | "summary" | "json">("tree");
const flatRows = computed(() => {
const rows: Array<{ node: ExplainPlanNode; depth: number }> = [];
function visit(node: ExplainPlanNode, depth: number) {
rows.push({ node, depth });
node.children.forEach((child) => visit(child, depth + 1));
}
props.plan?.nodes.forEach((node) => visit(node, 0));
return rows;
});
const rawJson = computed(() => props.plan ? JSON.stringify(props.plan.raw, null, 2) : "");
const nodeCount = computed(() => props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0);
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="h-9 shrink-0 border-b px-3 flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded border bg-muted px-2 py-0.5 font-medium">
<GitBranch class="h-3.5 w-3.5" />
{{ t('explain.title') }}
</span>
<span v-if="plan" class="text-muted-foreground">{{ plan.databaseType.toUpperCase() }} · {{ t('explain.nodeCount', { count: nodeCount }) }}</span>
<span class="flex-1" />
<div v-if="plan" class="inline-flex rounded-md border bg-muted/40 p-0.5">
<Button size="sm" :variant="activeView === 'tree' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'tree'">
<GitBranch class="h-3.5 w-3.5" />
{{ t('explain.tree') }}
</Button>
<Button size="sm" :variant="activeView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'summary'">
<Table2 class="h-3.5 w-3.5" />
{{ t('explain.summary') }}
</Button>
<Button size="sm" :variant="activeView === 'json' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'json'">
<Braces class="h-3.5 w-3.5" />
JSON
</Button>
</div>
</div>
<div v-if="loading" class="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
{{ t('explain.running') }}
</div>
<div v-else-if="error" class="flex-1 min-h-0 flex items-center justify-center">
<div class="flex max-w-xl items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ error }}</span>
</div>
</div>
<div v-else-if="!plan" class="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
{{ t('explain.empty') }}
</div>
<div v-else class="flex-1 min-h-0 overflow-auto">
<div v-if="activeView === 'tree'" class="mx-auto max-w-5xl space-y-3 p-4">
<ExplainPlanNodeTree v-for="node in plan.nodes" :key="node.id" :node="node" />
</div>
<div v-else-if="activeView === 'summary'" class="p-3">
<div class="overflow-auto rounded border">
<table class="w-full min-w-[760px] text-left text-xs">
<thead class="bg-muted/70 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 font-medium">{{ t('explain.node') }}</th>
<th class="px-2 py-1.5 font-medium">{{ t('explain.relation') }}</th>
<th class="px-2 py-1.5 font-medium">{{ t('explain.index') }}</th>
<th class="px-2 py-1.5 font-medium">{{ t('explain.cost') }}</th>
<th class="px-2 py-1.5 font-medium">{{ t('explain.rows') }}</th>
<th class="px-2 py-1.5 font-medium">{{ t('explain.details') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in flatRows" :key="row.node.id" class="border-t">
<td class="px-2 py-1.5 font-medium" :style="{ paddingLeft: `${8 + row.depth * 18}px` }">{{ row.node.title }}</td>
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.relation || '-' }}</td>
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.index || '-' }}</td>
<td class="px-2 py-1.5 tabular-nums">{{ row.node.cost || '-' }}</td>
<td class="px-2 py-1.5 tabular-nums">{{ row.node.rows || '-' }}</td>
<td class="px-2 py-1.5 text-muted-foreground">{{ row.node.details.join('; ') || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<pre v-else class="m-3 overflow-auto rounded border bg-muted/30 p-3 text-xs leading-relaxed">{{ rawJson }}</pre>
</div>
</div>
</template>

View File

@ -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",

View File

@ -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 设置",

232
src/lib/explainPlan.ts Normal file
View File

@ -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<DatabaseType>(["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<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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;
}

View File

@ -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<QueryTab["result"]> {
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,
};
});

View File

@ -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;

114
tests/explainPlan.test.ts Normal file
View File

@ -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");
});