ExplainVisualizer.utils.ts230 lines · main
1import {
2 Activity,
3 Database,
4 GitMerge,
5 Hash,
6 Layers,
7 ListFilter,
8 SortAsc,
9 Zap,
10 type LucideIcon,
11} from 'lucide-react'
12
13// Get human-readable description for an operation
14export function getOperationDescription(operation: string): string {
15 const op = operation.toLowerCase()
16
17 if (op.includes('seq scan')) {
18 return 'Reads entire table row by row'
19 }
20 if (op.includes('index only scan')) {
21 return 'Reads data directly from index (fastest)'
22 }
23 if (op.includes('bitmap index scan')) {
24 return 'Builds bitmap of matching rows from index'
25 }
26 if (op.includes('bitmap heap scan')) {
27 return 'Fetches rows using bitmap'
28 }
29 if (op.includes('index scan')) {
30 return 'Uses index to find matching rows'
31 }
32 if (op.includes('hash left join')) {
33 return 'Returns all left rows with matching right rows via hash'
34 }
35 if (op.includes('hash right join')) {
36 return 'Returns all right rows with matching left rows via hash'
37 }
38 if (op.includes('hash full join')) {
39 return 'Returns all rows from both tables via hash'
40 }
41 if (op.includes('hash anti join')) {
42 return 'Returns rows without matches via hash'
43 }
44 if (op.includes('hash semi join')) {
45 return 'Returns rows with at least one match via hash'
46 }
47 if (op.includes('hash join')) {
48 return 'Joins tables using hash lookup'
49 }
50 if (op.includes('merge left join')) {
51 return 'Returns all left rows with matching right rows via merge'
52 }
53 if (op.includes('merge right join')) {
54 return 'Returns all right rows with matching left rows via merge'
55 }
56 if (op.includes('merge full join')) {
57 return 'Returns all rows from both tables via merge'
58 }
59 if (op.includes('merge anti join')) {
60 return 'Returns rows without matches via merge'
61 }
62 if (op.includes('merge semi join')) {
63 return 'Returns rows with at least one match via merge'
64 }
65 if (op.includes('merge join')) {
66 return 'Joins pre-sorted tables'
67 }
68 if (op.includes('nested loop left join')) {
69 return 'Returns all left rows with matching right rows via loop'
70 }
71 if (op.includes('nested loop anti join')) {
72 return 'Returns rows without matches via loop'
73 }
74 if (op.includes('nested loop semi join')) {
75 return 'Returns rows with at least one match via loop'
76 }
77 if (op.includes('nested loop')) {
78 return 'Joins by looping through each row'
79 }
80 if (op === 'hash') {
81 return 'Builds hash table for fast lookups'
82 }
83 if (op.includes('sort')) {
84 return 'Sorts rows for output or join'
85 }
86 if (op.includes('aggregate') || op.includes('group')) {
87 return 'Groups rows and calculates aggregates'
88 }
89 if (op.includes('limit')) {
90 return 'Returns only first N rows'
91 }
92 if (op.includes('materialize')) {
93 return 'Stores results in memory for reuse'
94 }
95 if (op.includes('gather')) {
96 return 'Collects results from parallel workers'
97 }
98
99 return ''
100}
101
102// Get an icon for the operation type
103export function getOperationIcon(operation: string): LucideIcon {
104 const op = operation.toLowerCase()
105 if (op === 'hash') return Hash
106 if (op.includes('hash join')) return GitMerge
107 if (op.includes('merge join')) return GitMerge
108 if (op.includes('nested loop')) return GitMerge
109 if (op.includes('join')) return Layers
110 if (op.includes('index')) return Zap
111 if (op.includes('seq scan')) return Database
112 if (op.includes('scan')) return Database
113 if (op.includes('filter')) return ListFilter
114 if (op.includes('sort')) return SortAsc
115 if (op.includes('aggregate') || op.includes('group')) return Activity
116 return Database
117}
118
119// Get a color class for the operation type
120export function getOperationColor(operation: string): string {
121 const op = operation.toLowerCase()
122 if (op.includes('seq scan')) return 'text-warning'
123 if (op.includes('index')) return 'text-brand'
124 if (op.includes('join')) return 'text-foreground-light'
125 if (op.includes('sort') || op.includes('aggregate')) return 'text-foreground-light'
126 return 'text-foreground-light'
127}
128
129export function isExplainQuery(rows: readonly unknown[]): boolean {
130 if (rows.length === 0) return false
131 const firstRow = rows[0]
132 if (typeof firstRow !== 'object' || firstRow === null) return false
133 return 'QUERY PLAN' in firstRow && Object.keys(firstRow).length === 1
134}
135
136export function isTextFormatExplain(rows: readonly unknown[]): boolean {
137 if (!isExplainQuery(rows)) return false
138 const firstRow = rows[0] as Record<string, unknown>
139 return typeof firstRow['QUERY PLAN'] === 'string'
140}
141
142export function isExplainSql(sql: string): boolean {
143 return /^\s*explain\b/i.test(sql)
144}
145
146export function formatNodeDuration(ms: number | undefined): string {
147 if (ms === undefined) return '-'
148 if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
149 if (ms >= 1) return `${ms.toFixed(2)}ms`
150 if (ms >= 0.01) return `${ms.toFixed(2)}ms`
151 if (ms >= 0.001) return `${ms.toFixed(3)}ms`
152
153 const us = ms * 1000
154 if (us >= 0.1) return `${us.toFixed(1)}µs`
155 return `${us.toFixed(2)}µs`
156}
157
158export function getScanBarColor(operation: string): string {
159 const op = operation.toLowerCase()
160
161 // Index scans are green
162 if (
163 op.includes('index scan') ||
164 op.includes('index only scan') ||
165 op.includes('bitmap index scan')
166 ) {
167 return 'bg-brand/20'
168 }
169
170 // Sequential scans are yellow
171 if (op.includes('seq scan') || op.includes('sequential scan')) {
172 return 'bg-warning/20'
173 }
174
175 // Default neutral color for other operations
176 return 'bg-foreground/6'
177}
178
179export function getScanBorderColor(operation: string): string {
180 const op = operation.toLowerCase()
181
182 // Index scans are green
183 if (
184 op.includes('index scan') ||
185 op.includes('index only scan') ||
186 op.includes('bitmap index scan')
187 ) {
188 return 'border-l-brand'
189 }
190
191 // Sequential scans are yellow
192 if (op.includes('seq scan') || op.includes('sequential scan')) {
193 return 'border-l-warning'
194 }
195
196 // Default neutral color for other operations
197 return 'border-l-border-muted'
198}
199
200export function splitSqlStatements(sql: string): string[] {
201 // Enhanced tokenizer that handles:
202 // - Single-quoted strings: '...' (with '' escaping)
203 // - Double-quoted strings: "..." (with "" escaping)
204 // - Dollar-quoted strings: $tag$...$tag$
205 // - Line comments: -- (until end of line)
206 // - Block comments: /* ... */ (may be multiline)
207 // - Semicolons: ;
208 const tokens =
209 sql.match(
210 /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|--[^\r\n]*|\/\*[\s\S]*?\*\/|;|[^'"$;\-\/]+|./g
211 ) || []
212
213 const statements: string[] = []
214 let current = ''
215
216 for (const token of tokens) {
217 if (token === ';') {
218 if (current.trim()) statements.push(current.trim())
219 current = ''
220 } else {
221 current += token
222 }
223 }
224
225 if (current.trim()) {
226 statements.push(current.trim())
227 }
228
229 return statements
230}