diff --git a/ui/chen/components/ExecutionPlanNodeRow.vue b/ui/chen/components/ExecutionPlanNodeRow.vue index 5ac55fe33..36a779b62 100644 --- a/ui/chen/components/ExecutionPlanNodeRow.vue +++ b/ui/chen/components/ExecutionPlanNodeRow.vue @@ -2,10 +2,8 @@ import type { ChenPlanNode } from "~/chen/types/plan"; import { chenPlanFormatEstimated, - chenPlanNodeDetail, - chenPlanNodePrimaryFacts, - chenPlanNodeSecondaryFacts, - chenPlanNodeTitle + chenPlanNodeIcon, + chenPlanNodeKeyFacts } from "~/chen/utils/executionPlanPresentation"; defineOptions({ name: "ExecutionPlanNodeRow" }); @@ -13,104 +11,83 @@ defineOptions({ name: "ExecutionPlanNodeRow" }); const props = defineProps<{ node: ChenPlanNode; depth: number; - showCost: boolean; - showRows: boolean; + selectedId: string | null; + collapsedIds: Set; +}>(); +const emit = defineEmits<{ + select: [id: string]; + toggle: [id: string]; }>(); const { t } = useI18n(); -const expanded = ref(true); -const showDetails = ref(false); const hasChildren = computed(() => props.node.children.length > 0); -const title = computed(() => chenPlanNodeTitle(props.node)); -const primaryFacts = computed(() => chenPlanNodePrimaryFacts(props.node)); -const secondaryFacts = computed(() => { - const facts = chenPlanNodeSecondaryFacts(props.node); - const startup = chenPlanFormatEstimated(props.node.startupCost); - if (startup != null && !facts.some((fact) => fact.key === "Startup Cost")) { - facts.push({ key: "Startup Cost", value: startup }); - } - return facts; -}); -const detail = computed(() => chenPlanNodeDetail(props.node)); +const expanded = computed(() => !props.collapsedIds.has(props.node.id)); +const objectName = computed(() => props.node.relation || props.node.table); +const keyFacts = computed(() => chenPlanNodeKeyFacts(props.node)); const estimatedRows = computed(() => chenPlanFormatEstimated(props.node.rows) ?? "—"); const estimatedCost = computed(() => chenPlanFormatEstimated(props.node.cost) ?? "—"); -const factLabelKeys: Record = { - "Join Type": "ExecutionPlan.factJoinType", - "Sort Key": "ExecutionPlan.factSortKey", - Strategy: "ExecutionPlan.factStrategy", - "Group Key": "ExecutionPlan.factGroupKey", - "Index Name": "ExecutionPlan.factIndexName", - "CTE Name": "ExecutionPlan.factCteName", - "Subplan Name": "ExecutionPlan.factSubplanName", - "Parent Relationship": "ExecutionPlan.factParentRelationship", - "Hash Cond": "ExecutionPlan.factHashCondition", - "Join Filter": "ExecutionPlan.factJoinFilter", - "Merge Cond": "ExecutionPlan.factMergeCondition", - "Index Cond": "ExecutionPlan.factIndexCondition", - Filter: "ExecutionPlan.factFilter", - Output: "ExecutionPlan.factOutput", - "Startup Cost": "ExecutionPlan.factStartupCost" -}; -const factLabel = (key: string) => { - const translationKey = factLabelKeys[key]; - return translationKey ? t(translationKey) : key; -}; diff --git a/ui/chen/components/ExecutionPlanTree.vue b/ui/chen/components/ExecutionPlanTree.vue index 96c93d7a4..8795f9ebc 100644 --- a/ui/chen/components/ExecutionPlanTree.vue +++ b/ui/chen/components/ExecutionPlanTree.vue @@ -1,37 +1,143 @@ diff --git a/ui/chen/components/QueryConsolePanel.vue b/ui/chen/components/QueryConsolePanel.vue index 40bfc4fb1..e1dedb158 100644 --- a/ui/chen/components/QueryConsolePanel.vue +++ b/ui/chen/components/QueryConsolePanel.vue @@ -17,7 +17,7 @@ import SqlSnippetSaveDialog from "~/chen/components/SqlSnippetSaveDialog.vue"; import SqlSnippetSelectDialog from "~/chen/components/SqlSnippetSelectDialog.vue"; import { useChenSqlSnippets } from "~/chen/composables/useChenSqlSnippets"; import { createChenCompletionSource } from "~/chen/utils/sqlCompletion"; -import { chenPlanRequestSql, chenSqlDialect } from "~/chen/utils/sqlEditor"; +import { chenSqlDialect } from "~/chen/utils/sqlEditor"; import { formatChenSql } from "~/chen/utils/sqlFormat"; const props = defineProps<{ @@ -118,8 +118,11 @@ const sqlFileItems = computed(() => [ { label: t("ExecutionPlan.explain"), icon: "i-lucide-git-fork", - disabled: contextBusy.value || !(sqlEditor.value?.executionText() || props.tab.statement).trim(), - onSelect: () => emit("explainPlan", props.tab, currentPlanSql()) + disabled: contextBusy.value || !props.tab.statement.trim(), + onSelect: () => { + const sql = currentPlanSql(); + if (sql.trim()) emit("explainPlan", props.tab, sql); + } }, { label: t("Chen.Open"), @@ -153,8 +156,7 @@ const statementValue = computed({ }); function currentPlanSql() { - const snapshot = editorSnapshot(); - return chenPlanRequestSql(snapshot.documentSql || props.tab.statement, snapshot.selectedSql); + return sqlEditor.value?.executionText() || ""; } function runCurrentQuery() { diff --git a/ui/chen/utils/executionPlanPresentation.ts b/ui/chen/utils/executionPlanPresentation.ts index 9bbca6f00..7aa8bb62d 100644 --- a/ui/chen/utils/executionPlanPresentation.ts +++ b/ui/chen/utils/executionPlanPresentation.ts @@ -1,10 +1,17 @@ -import type { ChenNormalizedNodeType, ChenPlanNode } from "~/chen/types/plan"; +import type { ChenNormalizedNodeType, ChenPlanNode, PlanRawFormat } from "~/chen/types/plan"; export interface ChenPlanFact { key: string; value: string; } +export interface ChenPlanStep { + node: ChenPlanNode; + depth: number; + step: string; + ancestors: string[]; +} + const JOIN_TYPES: ChenNormalizedNodeType[] = ["JOIN", "HASH_JOIN", "MERGE_JOIN", "NESTED_LOOP"]; const INDEX_TYPES: ChenNormalizedNodeType[] = ["INDEX_SCAN", "INDEX_ONLY_SCAN"]; @@ -19,7 +26,7 @@ export function chenPlanNodeTitle(node: ChenPlanNode): string { export function chenPlanNodePrimaryFacts(node: ChenPlanNode): ChenPlanFact[] { const facts: ChenPlanFact[] = []; - const title = chenPlanNodeTitle(node); + const title = node.nativeOperator || node.nodeType; addFact(facts, "Join Type", node.logicalOperator || node.attributes?.["Join Type"], title); for (const key of primaryAttributeKeys(node.nodeType)) { if (key === "Join Type") continue; @@ -45,10 +52,102 @@ export function chenPlanNodeSecondaryFacts(node: ChenPlanNode): ChenPlanFact[] { return facts; } +export function chenPlanNodeFacts(node: ChenPlanNode): ChenPlanFact[] { + const facts = [...chenPlanNodePrimaryFacts(node), ...chenPlanNodeSecondaryFacts(node)]; + const startup = chenPlanFormatEstimated(node.startupCost); + if (startup != null && !facts.some((fact) => fact.key === "Startup Cost")) { + facts.push({ key: "Startup Cost", value: startup }); + } + if ( + node.logicalOperator && + node.logicalOperator !== node.nativeOperator && + !facts.some((fact) => fact.key === "Join Type" && fact.value === node.logicalOperator) + ) { + addFact(facts, "Logical Operator", node.logicalOperator); + } + if (node.physicalOperator && node.physicalOperator !== node.nativeOperator) { + addFact(facts, "Physical Operator", node.physicalOperator); + } + return facts; +} + +export function chenPlanNodeKeyFacts(node: ChenPlanNode): ChenPlanFact[] { + const primary = chenPlanNodePrimaryFacts(node); + if (primary.length) return primary.slice(0, 2); + const db2Predicate = Object.entries(node.predicates || {}).find( + ([key, value]) => key.startsWith("Predicate[") && value + ); + if (db2Predicate) return [{ key: db2Predicate[0], value: db2Predicate[1] }]; + const detail = chenPlanNodeDetail(node); + return detail ? [{ key: "Detail", value: detail }] : []; +} + +export function chenPlanNodeIcon(nodeType: ChenNormalizedNodeType): string { + switch (nodeType) { + case "SCAN": + return "i-lucide-scan-search"; + case "INDEX_SCAN": + case "INDEX_ONLY_SCAN": + return "i-lucide-list-filter"; + case "JOIN": + case "HASH_JOIN": + case "MERGE_JOIN": + case "NESTED_LOOP": + return "i-lucide-git-merge"; + case "SORT": + return "i-lucide-arrow-down-wide-narrow"; + case "AGGREGATE": + return "i-lucide-sigma"; + case "FILTER": + return "i-lucide-filter"; + case "LIMIT": + return "i-lucide-list-end"; + case "SUBQUERY": + return "i-lucide-boxes"; + case "HASH": + return "i-lucide-hash"; + case "PARALLEL": + return "i-lucide-git-fork"; + case "UNION": + return "i-lucide-combine"; + case "VALUES": + return "i-lucide-list"; + case "MATERIALIZE": + return "i-lucide-database"; + case "PROJECTION": + return "i-lucide-columns-3"; + default: + return "i-lucide-circle-dot"; + } +} + +export function chenPlanSteps(roots: ChenPlanNode[]): ChenPlanStep[] { + const steps: ChenPlanStep[] = []; + const visit = (nodes: ChenPlanNode[], depth: number, prefix: string, ancestors: string[]) => { + nodes.forEach((node, index) => { + const step = prefix ? `${prefix}.${index + 1}` : String(index + 1); + steps.push({ node, depth, step, ancestors }); + visit(node.children, depth + 1, step, [...ancestors, node.id]); + }); + }; + visit(roots, 0, "", []); + return steps; +} + +export function chenPlanFormatRaw(rawText: string, rawFormat: PlanRawFormat | null): string { + if (rawFormat === "TEXT") return rawText; + try { + return JSON.stringify(JSON.parse(rawText), null, 2); + } catch { + return rawFormat === "XML" ? formatXml(rawText) : rawText; + } +} + export function chenPlanNodeDetail(node: ChenPlanNode): string | null { const detail = node.detail?.trim(); if (!detail) return null; if (detail === node.relation || detail === node.table) return null; + if (node.relation && detail === `Object: ${node.relation}`) return null; const facts = [...chenPlanNodePrimaryFacts(node), ...chenPlanNodeSecondaryFacts(node)]; if (facts.some((fact) => fact.value === detail)) return null; return detail; @@ -61,29 +160,100 @@ export function chenPlanFormatEstimated(value: number | null | undefined): strin } function primaryAttributeKeys(nodeType: ChenNormalizedNodeType): string[] { - if (JOIN_TYPES.includes(nodeType)) return ["Join Type"]; - if (nodeType === "SORT") return ["Sort Key"]; + if (JOIN_TYPES.includes(nodeType)) return ["Join Type", "join_type", "join_algorithm"]; + if (nodeType === "SORT") return ["Sort Key", "sort_fields"]; if (nodeType === "AGGREGATE") return ["Strategy", "Group Key"]; - if (INDEX_TYPES.includes(nodeType)) return ["Index Name"]; + if (INDEX_TYPES.includes(nodeType)) return ["Index Name", "index_name", "key"]; if (nodeType === "SUBQUERY") return ["CTE Name", "Subplan Name", "Parent Relationship"]; if (nodeType === "UNION") return ["CTE Name"]; - if (nodeType === "SCAN") return ["CTE Name"]; + if (nodeType === "SCAN") return ["CTE Name", "key", "index_name"]; return []; } function primaryPredicateKeys(nodeType: ChenNormalizedNodeType): string[] { - if (nodeType === "HASH_JOIN") return ["Hash Cond", "Join Filter"]; - if (nodeType === "MERGE_JOIN") return ["Merge Cond", "Join Filter"]; - if (nodeType === "NESTED_LOOP" || nodeType === "JOIN") return ["Join Filter", "Hash Cond", "Merge Cond"]; - if (INDEX_TYPES.includes(nodeType)) return ["Index Cond"]; - if (nodeType === "SCAN") return ["Filter"]; - if (nodeType === "SUBQUERY") return ["Filter"]; + if (nodeType === "HASH_JOIN") + return [ + "Hash Cond", + "hash_condition", + "Join Filter", + "Access Predicates", + "ProbeResidual", + "Predicate", + "condition" + ]; + if (nodeType === "MERGE_JOIN") + return ["Merge Cond", "Join Filter", "Access Predicates", "ProbeResidual", "Predicate", "condition"]; + if (nodeType === "NESTED_LOOP" || nodeType === "JOIN") + return [ + "Join Filter", + "Hash Cond", + "Merge Cond", + "Access Predicates", + "ProbeResidual", + "Predicate", + "condition", + "lookup_condition" + ]; + if (INDEX_TYPES.includes(nodeType)) + return [ + "Index Cond", + "Access Predicates", + "SeekPredicates", + "Predicate", + "condition", + "lookup_condition", + "attached_condition" + ]; + if (nodeType === "SCAN") + return ["Filter", "Filter Predicates", "Access Predicates", "Predicate", "attached_condition", "condition"]; + if (nodeType === "SUBQUERY") return ["Filter", "condition"]; return []; } +function formatXml(rawText: string): string { + // SQL Server Showplan is element-only XML. Preserve mixed content and uncommon + // XML constructs verbatim rather than inserting whitespace into their content. + if (!rawText.trim().startsWith("<") || /") { + break; + } + } + if (end === rawText.length) return rawText; + tags.push(rawText.slice(offset, end + 1).trim()); + offset = end + 1; + } + let depth = 0; + return tags + .map((tag) => { + if (tag.startsWith("")) depth++; + return formatted; + }) + .join("\n"); +} + function addFact(facts: ChenPlanFact[], key: string, value: string | null | undefined, title = "") { if (!value) return; if (title && title.toLowerCase().includes(value.toLowerCase()) && key === "Join Type") return; - if (facts.some((fact) => fact.key === key || fact.value === value)) return; + if (facts.some((fact) => fact.key === key)) return; facts.push({ key, value }); } diff --git a/ui/chen/utils/sqlEditor.ts b/ui/chen/utils/sqlEditor.ts index c513d76b6..94b657d24 100644 --- a/ui/chen/utils/sqlEditor.ts +++ b/ui/chen/utils/sqlEditor.ts @@ -74,9 +74,3 @@ export function executableChenSql(state: EditorState) { if (from !== to) return state.doc.sliceString(from, to); return chenSqlStatementAtCursor(state, head)?.sql || ""; } - -/** Estimated plan uses the selection, otherwise the full editor buffer. Never the cursor statement fragment. */ -export function chenPlanRequestSql(documentSql: string, selectedSql = "") { - const selected = selectedSql.trim(); - return selected ? selectedSql : documentSql; -}