From 375681ead8a395545e28ce14fbef8277e87cdc50 Mon Sep 17 00:00:00 2001 From: sokphaladam Date: Fri, 20 Dec 2024 00:55:08 +0700 Subject: [PATCH 1/2] Add query explanation diagram components --- .../buildQueryExplanationFlow.ts | 252 ++++++++++++++++++ .../gui/query-explanation-diagram/index.tsx | 58 ++++ .../node-type/nested-loop.tsx | 45 ++++ .../node-type/query-block.tsx | 35 +++ .../node-type/table-block.tsx | 41 +++ src/components/gui/query-explanation.tsx | 73 ++++- src/components/gui/query-result.tsx | 2 +- src/components/gui/tabs/query-tab.tsx | 38 +-- 8 files changed, 515 insertions(+), 29 deletions(-) create mode 100644 src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts create mode 100644 src/components/gui/query-explanation-diagram/index.tsx create mode 100644 src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx create mode 100644 src/components/gui/query-explanation-diagram/node-type/query-block.tsx create mode 100644 src/components/gui/query-explanation-diagram/node-type/table-block.tsx diff --git a/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts b/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts new file mode 100644 index 00000000..4917e8e4 --- /dev/null +++ b/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts @@ -0,0 +1,252 @@ +import { Edge, NodeProps, Node, MarkerType, Position } from "@xyflow/react"; +import Dagre from "@dagrejs/dagre"; + +export interface ExplanationMysql { + query_block: { + id: string; + cost_info: { + query_cost: number; + prefix_cost: number; + }; + table?: { + id: string; + table_name: string; + cost_info: { + query_cost: number; + prefix_cost: number; + }; + }; + nested_loop?: { + table: { + id: string; + table_name: string; + cost_info: { + query_cost: number; + prefix_cost: number; + }; + rows_produced_per_join: string; + }; + }[]; + }; +} + +export interface ExplainNodeProps extends NodeProps { + data: { + id: string; + cost_info: { + query_cost: number; + prefix_cost: number; + read_cost: number; + eval_cost: number; + }; + key: string; + select_id: number; + label: string; + target?: string; + rows_examined_per_scan: string; + rows_produced_per_join: string; + access_type: string; + table_name: string; + }; +} + +const dagreGraph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); +const nodeWidth = 172; +const nodeHeight = 36; +const position = { x: 0, y: 0 }; + +export function formatCost(cost: number) { + return parseFloat(String(cost)).toLocaleString("en-US", { + maximumFractionDigits: 2, + notation: "compact", + compactDisplay: "short", + }); +} + +function getLayoutedExplanationElements( + nodes: Node[], + edges: Edge[], + direction = "TB" +) { + const isHorizontal = direction === "LR"; + + dagreGraph.setGraph({ rankdir: direction }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: node.measured?.width || nodeWidth, + height: node.measured?.height || nodeHeight, + }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + Dagre.layout(dagreGraph); + + const newNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + const newNode = { + ...node, + targetPosition: isHorizontal ? "left" : "top", + sourcePosition: isHorizontal ? "right" : "bottom", + // We are shifting the dagre node position (anchor=center center) to the top left + // so it matches the React Flow node anchor point (top left). + position: { + x: nodeWithPosition.x - (node.measured?.width || nodeWidth) / 2, + y: nodeWithPosition.y - (node.measured?.height || nodeHeight) / 2, + }, + }; + + return newNode; + }); + + return { nodes: newNodes, edges }; +} + +export function buildQueryExplanationFlow(item: ExplanationMysql) { + const nodes: Node[] = []; + const edges: Edge[] = []; + // Keep all tables + const nodesTables = new Set(); + const edgesTable = new Set(); + + const table = item.query_block.table || null; + const nested_loop = item.query_block.nested_loop || []; + const nested_reverse = (nested_loop || []).reverse(); + + if (item.query_block) { + nodes.push({ + id: "query_block", + data: { ...item.query_block }, + type: "QUERY_BLOCK", + position, + }); + } + + if (table) { + nodes.push({ + id: table.table_name, + data: table, + type: "TABLE", + position, + }); + + edges.push({ + id: `query_block-${table.table_name}`, + source: "query_block", + target: table.table_name, + sourceHandle: "query_block", + targetHandle: table.table_name, + type: "smoothstep", + markerStart: { + type: MarkerType.ArrowClosed, + width: 14, + height: 14, + }, + style: { + strokeWidth: 2, + }, + animated: true, + }); + } + + if (!table && nested_reverse.length > 0) { + for (const [index, value] of nested_reverse.entries()) { + nodes.push({ + id: "nested_loop_" + index, + data: { + label: "nested loop", + cost_info: { + prefix_cost: value.table.cost_info.prefix_cost, + }, + target: `nested_loop_${index}-${value.table.table_name}`, + rows_produced_per_join: value.table.rows_produced_per_join, + }, + type: "NESTED_LOOP", + position, + measured: { + width: 100, + height: 50, + }, + }); + edges.push({ + id: + index === 0 + ? "query_block-nested_loop_0" + : `nested_loop_${index - 1}-nested_loop_${index}`, + source: index === 0 ? "query_block" : "nested_loop_" + (index - 1), + target: index === 0 ? "nested_loop_0" : "nested_loop_" + index, + sourceHandle: index === 0 ? "query_block" : "left", + targetHandle: index === 0 ? "nested_loop_0" : "nested_loop_" + index, + type: "smoothstep", + markerStart: { + type: MarkerType.ArrowClosed, + width: 14, + height: 14, + }, + style: { + strokeWidth: 2, + }, + animated: true, + label: formatCost(Number(value.table.rows_produced_per_join)) + " rows", + labelShowBg: false, + labelStyle: { + fill: "#AAAAAA", + color: "#AAAAAA", + transform: "translate(-5%, -5%)", + }, + }); + } + + const layout = getLayoutedExplanationElements(nodes, edges, "RL"); + + for (const [index, value] of nested_reverse.entries()) { + const key = index === nested_reverse.length - 1 ? index - 1 : index; + const nested = layout.nodes.find((f) => f.id === `nested_loop_${index}`); + nodesTables.add({ + id: value.table.table_name, + data: { + ...value.table, + }, + type: "TABLE", + position: { + x: (nested?.position.x || 0) + 50, + y: (nested?.position.y || 0) + (nested?.measured?.height || 0) + 100, + }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }); + edgesTable.add({ + id: `nested_loop_${key}-${value.table.table_name}`, + source: "nested_loop_" + key, + target: value.table.table_name, + sourceHandle: index === nested_reverse.length - 1 ? "left" : "bottom", + targetHandle: value.table.table_name, + type: "smoothstep", + markerStart: { + type: MarkerType.ArrowClosed, + width: 14, + height: 14, + }, + style: { + strokeWidth: 2, + }, + animated: true, + }); + } + return { + nodes: [ + ...layout.nodes.filter((_, i) => i < layout.nodes.length - 1), + ...nodesTables, + ], + edges: [...layout.edges, ...edgesTable], + }; + } + + return { + nodes: [], + edges: [], + }; +} diff --git a/src/components/gui/query-explanation-diagram/index.tsx b/src/components/gui/query-explanation-diagram/index.tsx new file mode 100644 index 00000000..e2c40968 --- /dev/null +++ b/src/components/gui/query-explanation-diagram/index.tsx @@ -0,0 +1,58 @@ +import { Edge, Node, Position, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState } from "@xyflow/react"; +import { useEffect, useMemo, useState } from "react"; +import { buildQueryExplanationFlow, ExplanationMysql } from "./buildQueryExplanationFlow"; +import { QueryBlock } from "./node-type/query-block"; +import { NestedLoop } from "./node-type/nested-loop"; +import { TableBlock } from "./node-type/table-block"; + +interface LayoutFlowProps { + items: ExplanationMysql; +} + +function QueryExplanationFlow(props: LayoutFlowProps) { + const [loading, setLoading] = useState(true); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const nodeTypes = useMemo(() => ({ + QUERY_BLOCK: QueryBlock, + NESTED_LOOP: NestedLoop, + TABLE: TableBlock + }), []) + + useEffect(() => { + if (loading) { + const build = buildQueryExplanationFlow(props.items as unknown as ExplanationMysql); + setNodes(build.nodes.map((node: any) => ({ + ...node, + sourcePosition: node.sourcePosition as Position, + targetPosition: node.targetPosition as Position + }))) + setEdges(build.edges as Edge[]) + setLoading(false) + } + }, [props, loading]) + + return ( + + + + ) +} + +export default function QueryExplanationDiagram(props: LayoutFlowProps) { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx b/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx new file mode 100644 index 00000000..80d9d8c1 --- /dev/null +++ b/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx @@ -0,0 +1,45 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Position } from "@xyflow/react"; +import { BaseHandle } from "@/components/base-handle"; +import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow"; + +export function NestedLoop(props: ExplainNodeProps) { + console.log(props.data) + return ( + + +
+ + + +
+
{formatCost(props.data.cost_info.prefix_cost)}
+
+
+
{props.data.label}
+
+
+
+ +
+

Prefix Cost: {props.data.cost_info.prefix_cost}

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/gui/query-explanation-diagram/node-type/query-block.tsx b/src/components/gui/query-explanation-diagram/node-type/query-block.tsx new file mode 100644 index 00000000..5f8483f2 --- /dev/null +++ b/src/components/gui/query-explanation-diagram/node-type/query-block.tsx @@ -0,0 +1,35 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Position } from "@xyflow/react"; +import { BaseHandle } from "@/components/base-handle"; +import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow"; + +export function QueryBlock(props: ExplainNodeProps) { + return ( + + +
+ +
+
Query cost: {formatCost(props.data.cost_info.query_cost || 0)}
+
+
+
+
{props.id} #{props.data.select_id}
+
+
+
+
+ +
+

Select ID: {props.data.select_id}

+

Query Cost: {props.data.cost_info.query_cost}

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/gui/query-explanation-diagram/node-type/table-block.tsx b/src/components/gui/query-explanation-diagram/node-type/table-block.tsx new file mode 100644 index 00000000..7368b730 --- /dev/null +++ b/src/components/gui/query-explanation-diagram/node-type/table-block.tsx @@ -0,0 +1,41 @@ +import { BaseHandle } from "@/components/base-handle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Position } from "@xyflow/react"; +import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow"; + +export function TableBlock(props: ExplainNodeProps) { + return ( + + +
+ +
+
{formatCost(Number(props.data.cost_info.read_cost) + Number(props.data.cost_info.eval_cost))}
+
{formatCost(Number(props.data.rows_examined_per_scan))} rows
+
+
+ {props.data.access_type === 'ALL' ? 'Full Table Scan' : props.data.access_type === 'eq_ref' ? 'Unique Key Lookup' : 'Non-Unique Key Lookup'} +
+
+
+ {props.data.table_name} +
+
+ {props.data.key} +
+
+
+
+ +
+

Prefix Cost: {props.data.cost_info.prefix_cost}

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/gui/query-explanation.tsx b/src/components/gui/query-explanation.tsx index 8f79f974..1125ff8a 100644 --- a/src/components/gui/query-explanation.tsx +++ b/src/components/gui/query-explanation.tsx @@ -1,9 +1,11 @@ -import { DatabaseResultSet } from "@/drivers/base-driver"; +import { DatabaseResultSet, SupportedDialect } from "@/drivers/base-driver"; import { useMemo } from "react"; import { z } from "zod"; +import QueryExplanationDiagram from "./query-explanation-diagram"; interface QueryExplanationProps { data: DatabaseResultSet; + dialect?: SupportedDialect } interface ExplanationRow { @@ -25,7 +27,19 @@ const queryExplanationRowSchema = z.object({ }); export function isExplainQueryPlan(sql: string) { - return sql.toLowerCase().startsWith("explain query plan"); + if (sql.toLowerCase().startsWith("explain query plan")) { + return true + } + + if (sql.toLowerCase().startsWith("explain format=json")) { + return true + } + + if (sql.toLowerCase().startsWith("explain (format json)")) { + return true + } + + return false } function buildQueryExplanationTree(nodes: ExplanationRow[]) { @@ -47,9 +61,11 @@ function buildQueryExplanationTree(nodes: ExplanationRow[]) { return tree; } -export function QueryExplanation(props: QueryExplanationProps) { - const tree = useMemo(() => { - const isExplanationRows = z.array(queryExplanationRowSchema).safeParse( +function mapExplanationRows(props: QueryExplanationProps) { + let isExplanationRows = null; + + if (props.dialect === 'sqlite') { + isExplanationRows = z.array(queryExplanationRowSchema).safeParse( props.data.rows.map((r) => ({ ...r, id: Number(r.id), @@ -57,16 +73,33 @@ export function QueryExplanation(props: QueryExplanationProps) { notused: Number(r.notused), })) ); + } - if (isExplanationRows.error) { - return { _tag: "ERROR" as const, value: isExplanationRows.error }; + if (props.dialect === 'mysql') { + const row = (props.data.rows || [])[0] + const explain = String(row.EXPLAIN) + return { + _tag: "SUCCESS", + value: JSON.parse(explain) } + } - return { - _tag: "SUCCESS" as const, - value: buildQueryExplanationTree(isExplanationRows.data), - }; - }, [props.data]); + if (props.dialect === 'postgres') { + return { _tag: "SUCCESS" as const, value: 'Postgres dialect is not supported yet' }; + } + + if (isExplanationRows?.error) { + return { _tag: "ERROR" as const, value: isExplanationRows.error }; + } + + return { + _tag: "SUCCESS" as const, + value: buildQueryExplanationTree(isExplanationRows?.data || []), + }; +} + +export function QueryExplanation(props: QueryExplanationProps) { + const tree = useMemo(() => mapExplanationRows(props), [props]); if (tree._tag === "ERROR") { // The row structure doesn't match the explanation structure @@ -79,10 +112,24 @@ export function QueryExplanation(props: QueryExplanationProps) { ); } + if (props.dialect !== 'sqlite') { + return ( +
+ { + props.dialect === 'mysql' ? ( + + ) : ( +

{tree.value}

+ ) + } +
+ ) + } + return (
    - {tree.value.map((node) => ( + {tree.value.map((node: ExplanationRowWithChildren) => (
  • diff --git a/src/components/gui/query-result.tsx b/src/components/gui/query-result.tsx index 90f86e7f..02e1fcab 100644 --- a/src/components/gui/query-result.tsx +++ b/src/components/gui/query-result.tsx @@ -36,7 +36,7 @@ export default function QueryResult({ {data._tag === "QUERY" ? ( ) : ( - + )}
{stats && ( diff --git a/src/components/gui/tabs/query-tab.tsx b/src/components/gui/tabs/query-tab.tsx index 5865cd01..780b18c9 100644 --- a/src/components/gui/tabs/query-tab.tsx +++ b/src/components/gui/tabs/query-tab.tsx @@ -115,7 +115,15 @@ export default function QueryWindow({ explained && statement.toLowerCase().indexOf("explain query plan") !== 0 ) { - statement = "explain query plan " + statement; + if (databaseDriver.getFlags().dialect === 'sqlite') { + statement = "explain query plan " + statement; + } + else if (databaseDriver.getFlags().dialect === 'mysql') { + statement = "explain format=json " + statement + } + else if (databaseDriver.getFlags().dialect === 'postgres') { + statement = 'explain (format json) ' + statement + } } if (statement) { @@ -153,7 +161,7 @@ export default function QueryWindow({ } else if ( databaseDriver.getFlags().supportUseStatement && log.sql.trim().substring(0, "use ".length).toLowerCase() === - "use " + "use " ) { hasAlterSchema = true; break; @@ -186,7 +194,7 @@ export default function QueryWindow({ {}} + onTabsChange={() => { }} hideCloseButton selected={queryTabIndex} tabs={[ @@ -203,18 +211,18 @@ export default function QueryWindow({ })), ...(progress ? [ - { - key: "summary", - identifier: "summary", - title: "Summary", - icon: LucideMessageSquareWarning, - component: ( -
- -
- ), - }, - ] + { + key: "summary", + identifier: "summary", + title: "Summary", + icon: LucideMessageSquareWarning, + component: ( +
+ +
+ ), + }, + ] : []), ]} /> From 26c5ba671923beb6c4c4b60c4ac35fc4c3593673 Mon Sep 17 00:00:00 2001 From: sokphaladam Date: Fri, 20 Dec 2024 23:31:01 +0700 Subject: [PATCH 2/2] Enhance query explanation diagram: improve table block styling and remove debug log --- .../buildQueryExplanationFlow.ts | 2 ++ .../node-type/nested-loop.tsx | 1 - .../node-type/table-block.tsx | 21 +++++++++++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts b/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts index 4917e8e4..0f033591 100644 --- a/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts +++ b/src/components/gui/query-explanation-diagram/buildQueryExplanationFlow.ts @@ -150,6 +150,8 @@ export function buildQueryExplanationFlow(item: ExplanationMysql) { }, animated: true, }); + + return getLayoutedExplanationElements(nodes, edges, "RL"); } if (!table && nested_reverse.length > 0) { diff --git a/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx b/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx index 80d9d8c1..7726d1a2 100644 --- a/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx +++ b/src/components/gui/query-explanation-diagram/node-type/nested-loop.tsx @@ -4,7 +4,6 @@ import { BaseHandle } from "@/components/base-handle"; import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow"; export function NestedLoop(props: ExplainNodeProps) { - console.log(props.data) return ( diff --git a/src/components/gui/query-explanation-diagram/node-type/table-block.tsx b/src/components/gui/query-explanation-diagram/node-type/table-block.tsx index 7368b730..76e91110 100644 --- a/src/components/gui/query-explanation-diagram/node-type/table-block.tsx +++ b/src/components/gui/query-explanation-diagram/node-type/table-block.tsx @@ -4,6 +4,23 @@ import { Position } from "@xyflow/react"; import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow"; export function TableBlock(props: ExplainNodeProps) { + let bgColor = 'bg-emerald-700'; + let label = 'Unique Key Lookup'; + + if (props.data.access_type === 'ALL') { + bgColor = 'bg-rose-700'; + label = 'Full Table Scan' + } + + if (props.data.access_type === 'range') { + bgColor = 'bg-yellow-700'; + label = 'Index Range Scan'; + } + + if (props.data.access_type === 'ref') { + label = 'Non-Unique Key Lookup' + } + return ( @@ -18,8 +35,8 @@ export function TableBlock(props: ExplainNodeProps) {
{formatCost(Number(props.data.cost_info.read_cost) + Number(props.data.cost_info.eval_cost))}
{formatCost(Number(props.data.rows_examined_per_scan))} rows
-
- {props.data.access_type === 'ALL' ? 'Full Table Scan' : props.data.access_type === 'eq_ref' ? 'Unique Key Lookup' : 'Non-Unique Key Lookup'} +
+ {label}