feat: add explain plan viewer
This commit is contained in:
parent
eab5556b6f
commit
d180539778
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
119
src/App.vue
119
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 设置",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue