Skip to content

Commit bb6be3e

Browse files
authored
Add query explanation diagram components (#212)
1 parent 3030106 commit bb6be3e

8 files changed

Lines changed: 515 additions & 29 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Edge, NodeProps, Node, MarkerType, Position } from "@xyflow/react";
2+
import Dagre from "@dagrejs/dagre";
3+
4+
export interface ExplanationMysql {
5+
query_block: {
6+
id: string;
7+
cost_info: {
8+
query_cost: number;
9+
prefix_cost: number;
10+
};
11+
table?: {
12+
id: string;
13+
table_name: string;
14+
cost_info: {
15+
query_cost: number;
16+
prefix_cost: number;
17+
};
18+
};
19+
nested_loop?: {
20+
table: {
21+
id: string;
22+
table_name: string;
23+
cost_info: {
24+
query_cost: number;
25+
prefix_cost: number;
26+
};
27+
rows_produced_per_join: string;
28+
};
29+
}[];
30+
};
31+
}
32+
33+
export interface ExplainNodeProps extends NodeProps {
34+
data: {
35+
id: string;
36+
cost_info: {
37+
query_cost: number;
38+
prefix_cost: number;
39+
read_cost: number;
40+
eval_cost: number;
41+
};
42+
key: string;
43+
select_id: number;
44+
label: string;
45+
target?: string;
46+
rows_examined_per_scan: string;
47+
rows_produced_per_join: string;
48+
access_type: string;
49+
table_name: string;
50+
};
51+
}
52+
53+
const dagreGraph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
54+
const nodeWidth = 172;
55+
const nodeHeight = 36;
56+
const position = { x: 0, y: 0 };
57+
58+
export function formatCost(cost: number) {
59+
return parseFloat(String(cost)).toLocaleString("en-US", {
60+
maximumFractionDigits: 2,
61+
notation: "compact",
62+
compactDisplay: "short",
63+
});
64+
}
65+
66+
function getLayoutedExplanationElements(
67+
nodes: Node[],
68+
edges: Edge[],
69+
direction = "TB"
70+
) {
71+
const isHorizontal = direction === "LR";
72+
73+
dagreGraph.setGraph({ rankdir: direction });
74+
75+
nodes.forEach((node) => {
76+
dagreGraph.setNode(node.id, {
77+
width: node.measured?.width || nodeWidth,
78+
height: node.measured?.height || nodeHeight,
79+
});
80+
});
81+
82+
edges.forEach((edge) => {
83+
dagreGraph.setEdge(edge.source, edge.target);
84+
});
85+
86+
Dagre.layout(dagreGraph);
87+
88+
const newNodes = nodes.map((node) => {
89+
const nodeWithPosition = dagreGraph.node(node.id);
90+
const newNode = {
91+
...node,
92+
targetPosition: isHorizontal ? "left" : "top",
93+
sourcePosition: isHorizontal ? "right" : "bottom",
94+
// We are shifting the dagre node position (anchor=center center) to the top left
95+
// so it matches the React Flow node anchor point (top left).
96+
position: {
97+
x: nodeWithPosition.x - (node.measured?.width || nodeWidth) / 2,
98+
y: nodeWithPosition.y - (node.measured?.height || nodeHeight) / 2,
99+
},
100+
};
101+
102+
return newNode;
103+
});
104+
105+
return { nodes: newNodes, edges };
106+
}
107+
108+
export function buildQueryExplanationFlow(item: ExplanationMysql) {
109+
const nodes: Node[] = [];
110+
const edges: Edge[] = [];
111+
// Keep all tables
112+
const nodesTables = new Set();
113+
const edgesTable = new Set();
114+
115+
const table = item.query_block.table || null;
116+
const nested_loop = item.query_block.nested_loop || [];
117+
const nested_reverse = (nested_loop || []).reverse();
118+
119+
if (item.query_block) {
120+
nodes.push({
121+
id: "query_block",
122+
data: { ...item.query_block },
123+
type: "QUERY_BLOCK",
124+
position,
125+
});
126+
}
127+
128+
if (table) {
129+
nodes.push({
130+
id: table.table_name,
131+
data: table,
132+
type: "TABLE",
133+
position,
134+
});
135+
136+
edges.push({
137+
id: `query_block-${table.table_name}`,
138+
source: "query_block",
139+
target: table.table_name,
140+
sourceHandle: "query_block",
141+
targetHandle: table.table_name,
142+
type: "smoothstep",
143+
markerStart: {
144+
type: MarkerType.ArrowClosed,
145+
width: 14,
146+
height: 14,
147+
},
148+
style: {
149+
strokeWidth: 2,
150+
},
151+
animated: true,
152+
});
153+
}
154+
155+
if (!table && nested_reverse.length > 0) {
156+
for (const [index, value] of nested_reverse.entries()) {
157+
nodes.push({
158+
id: "nested_loop_" + index,
159+
data: {
160+
label: "nested loop",
161+
cost_info: {
162+
prefix_cost: value.table.cost_info.prefix_cost,
163+
},
164+
target: `nested_loop_${index}-${value.table.table_name}`,
165+
rows_produced_per_join: value.table.rows_produced_per_join,
166+
},
167+
type: "NESTED_LOOP",
168+
position,
169+
measured: {
170+
width: 100,
171+
height: 50,
172+
},
173+
});
174+
edges.push({
175+
id:
176+
index === 0
177+
? "query_block-nested_loop_0"
178+
: `nested_loop_${index - 1}-nested_loop_${index}`,
179+
source: index === 0 ? "query_block" : "nested_loop_" + (index - 1),
180+
target: index === 0 ? "nested_loop_0" : "nested_loop_" + index,
181+
sourceHandle: index === 0 ? "query_block" : "left",
182+
targetHandle: index === 0 ? "nested_loop_0" : "nested_loop_" + index,
183+
type: "smoothstep",
184+
markerStart: {
185+
type: MarkerType.ArrowClosed,
186+
width: 14,
187+
height: 14,
188+
},
189+
style: {
190+
strokeWidth: 2,
191+
},
192+
animated: true,
193+
label: formatCost(Number(value.table.rows_produced_per_join)) + " rows",
194+
labelShowBg: false,
195+
labelStyle: {
196+
fill: "#AAAAAA",
197+
color: "#AAAAAA",
198+
transform: "translate(-5%, -5%)",
199+
},
200+
});
201+
}
202+
203+
const layout = getLayoutedExplanationElements(nodes, edges, "RL");
204+
205+
for (const [index, value] of nested_reverse.entries()) {
206+
const key = index === nested_reverse.length - 1 ? index - 1 : index;
207+
const nested = layout.nodes.find((f) => f.id === `nested_loop_${index}`);
208+
nodesTables.add({
209+
id: value.table.table_name,
210+
data: {
211+
...value.table,
212+
},
213+
type: "TABLE",
214+
position: {
215+
x: (nested?.position.x || 0) + 50,
216+
y: (nested?.position.y || 0) + (nested?.measured?.height || 0) + 100,
217+
},
218+
targetPosition: Position.Top,
219+
sourcePosition: Position.Bottom,
220+
});
221+
edgesTable.add({
222+
id: `nested_loop_${key}-${value.table.table_name}`,
223+
source: "nested_loop_" + key,
224+
target: value.table.table_name,
225+
sourceHandle: index === nested_reverse.length - 1 ? "left" : "bottom",
226+
targetHandle: value.table.table_name,
227+
type: "smoothstep",
228+
markerStart: {
229+
type: MarkerType.ArrowClosed,
230+
width: 14,
231+
height: 14,
232+
},
233+
style: {
234+
strokeWidth: 2,
235+
},
236+
animated: true,
237+
});
238+
}
239+
return {
240+
nodes: [
241+
...layout.nodes.filter((_, i) => i < layout.nodes.length - 1),
242+
...nodesTables,
243+
],
244+
edges: [...layout.edges, ...edgesTable],
245+
};
246+
}
247+
248+
return {
249+
nodes: [],
250+
edges: [],
251+
};
252+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Edge, Node, Position, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState } from "@xyflow/react";
2+
import { useEffect, useMemo, useState } from "react";
3+
import { buildQueryExplanationFlow, ExplanationMysql } from "./buildQueryExplanationFlow";
4+
import { QueryBlock } from "./node-type/query-block";
5+
import { NestedLoop } from "./node-type/nested-loop";
6+
import { TableBlock } from "./node-type/table-block";
7+
8+
interface LayoutFlowProps {
9+
items: ExplanationMysql;
10+
}
11+
12+
function QueryExplanationFlow(props: LayoutFlowProps) {
13+
const [loading, setLoading] = useState(true);
14+
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
15+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
16+
17+
const nodeTypes = useMemo(() => ({
18+
QUERY_BLOCK: QueryBlock,
19+
NESTED_LOOP: NestedLoop,
20+
TABLE: TableBlock
21+
}), [])
22+
23+
useEffect(() => {
24+
if (loading) {
25+
const build = buildQueryExplanationFlow(props.items as unknown as ExplanationMysql);
26+
setNodes(build.nodes.map((node: any) => ({
27+
...node,
28+
sourcePosition: node.sourcePosition as Position,
29+
targetPosition: node.targetPosition as Position
30+
})))
31+
setEdges(build.edges as Edge[])
32+
setLoading(false)
33+
}
34+
}, [props, loading])
35+
36+
return (
37+
<ReactFlow
38+
nodes={nodes}
39+
edges={edges}
40+
onNodesChange={onNodesChange}
41+
onEdgesChange={onEdgesChange}
42+
fitView
43+
nodeTypes={nodeTypes}
44+
maxZoom={1}
45+
minZoom={1}
46+
>
47+
48+
</ReactFlow>
49+
)
50+
}
51+
52+
export default function QueryExplanationDiagram(props: LayoutFlowProps) {
53+
return (
54+
<ReactFlowProvider>
55+
<QueryExplanationFlow {...props} />
56+
</ReactFlowProvider>
57+
);
58+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
2+
import { Position } from "@xyflow/react";
3+
import { BaseHandle } from "@/components/base-handle";
4+
import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow";
5+
6+
export function NestedLoop(props: ExplainNodeProps) {
7+
console.log(props.data)
8+
return (
9+
<Tooltip>
10+
<TooltipTrigger asChild>
11+
<div>
12+
<BaseHandle
13+
type="target"
14+
position={Position.Right}
15+
id={props.id}
16+
className="opacity-0 group-hover:opacity-100 !w-[10px] !h-[10px]"
17+
/>
18+
<BaseHandle
19+
type="source"
20+
position={Position.Left}
21+
id={'left'}
22+
className="opacity-0 group-hover:opacity-100 !w-[10px] !h-[10px]"
23+
/>
24+
<BaseHandle
25+
type="source"
26+
position={Position.Bottom}
27+
id={'bottom'}
28+
className="opacity-0 group-hover:opacity-100 !w-[10px] !h-[10px]"
29+
/>
30+
<div className="flex flex-row justify-between items-center text-[8pt]">
31+
<div><small>{formatCost(props.data.cost_info.prefix_cost)}</small></div>
32+
</div>
33+
<div className="w-[50px] h-[50px] rotate-45 p-2 bg-secondary text-muted-foreground text-[9pt] border-b rounded-md overflow-hidden text-center my-2 mx-2">
34+
<div className="-rotate-45"><small>{props.data.label}</small></div>
35+
</div>
36+
</div>
37+
</TooltipTrigger>
38+
<TooltipContent>
39+
<div>
40+
<p className="!text-[9pt]">Prefix Cost: {props.data.cost_info.prefix_cost}</p>
41+
</div>
42+
</TooltipContent>
43+
</Tooltip>
44+
)
45+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
2+
import { Position } from "@xyflow/react";
3+
import { BaseHandle } from "@/components/base-handle";
4+
import { ExplainNodeProps, formatCost } from "../buildQueryExplanationFlow";
5+
6+
export function QueryBlock(props: ExplainNodeProps) {
7+
return (
8+
<Tooltip>
9+
<TooltipTrigger asChild>
10+
<div>
11+
<BaseHandle
12+
type="source"
13+
position={Position.Left}
14+
id={props.id}
15+
className="opacity-0 group-hover:opacity-100 !w-[10px] !h-[10px]"
16+
/>
17+
<div className="flex flex-row justify-between items-center text-[8pt]">
18+
<div><small>Query cost: {formatCost(props.data.cost_info.query_cost || 0)}</small></div>
19+
</div>
20+
<div className="flex flex-row items-center">
21+
<div className="max-w-[200px] p-2 bg-gray-300 text-gray-900 border-gray-900 text-[9pt] border-b rounded-md py-4">
22+
<div><small>{props.id} #{props.data.select_id}</small></div>
23+
</div>
24+
</div>
25+
</div>
26+
</TooltipTrigger>
27+
<TooltipContent>
28+
<div>
29+
<p className="!text-[9pt]">Select ID: {props.data.select_id}</p>
30+
<p className="!text-[9pt]">Query Cost: {props.data.cost_info.query_cost}</p>
31+
</div>
32+
</TooltipContent>
33+
</Tooltip>
34+
)
35+
}

0 commit comments

Comments
 (0)