ExplainVisualizer.parser.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import type { ExplainNode, QueryPlanRow } from './ExplainVisualizer.types'
  2. export interface ExplainSummary {
  3. totalTime: number
  4. totalCost: number
  5. maxCost: number
  6. hasSeqScan: boolean
  7. seqScanTables: string[]
  8. hasIndexScan: boolean
  9. }
  10. function parseFloatMetric(value: string): number | undefined {
  11. const parsed = parseFloat(value)
  12. return Number.isFinite(parsed) ? parsed : undefined
  13. }
  14. function parseIntMetric(value: string): number | undefined {
  15. const parsed = parseInt(value, 10)
  16. return Number.isNaN(parsed) ? undefined : parsed
  17. }
  18. // Parse the QUERY PLAN text into a tree structure
  19. export function parseExplainOutput(rows: readonly QueryPlanRow[]): ExplainNode[] {
  20. const lines = rows.map((row) => row['QUERY PLAN'] || '').filter(Boolean)
  21. const root: ExplainNode[] = []
  22. const stack: { node: ExplainNode; indent: number }[] = []
  23. // Detail line patterns that should be attached to the previous node
  24. const detailPatterns =
  25. /^(Filter|Sort Key|Group Key|Hash Cond|Join Filter|Index Cond|Recheck Cond|Rows Removed by Filter|Rows Removed by Index Recheck|Output|Merge Cond|Sort Method|Worker \d+|Buffers|Planning Time|Execution Time|One-Time Filter|InitPlan|SubPlan):/
  26. for (let i = 0; i < lines.length; i++) {
  27. const line = lines[i]
  28. // Skip empty lines
  29. if (!line.trim()) continue
  30. // Calculate the indentation (number of leading spaces)
  31. const leadingMatch = line.match(/^(\s*)/)
  32. const leadingSpaces = leadingMatch ? leadingMatch[1].length : 0
  33. // Check if this line has an arrow (indicates a child operation node)
  34. const hasArrow = line.includes('->')
  35. // Extract the content after any arrow
  36. let content = line
  37. let effectiveIndent = leadingSpaces
  38. if (hasArrow) {
  39. // Find position of -> and use that for indent calculation
  40. const arrowIndex = line.indexOf('->')
  41. effectiveIndent = arrowIndex
  42. content = line.substring(arrowIndex + 2).trim()
  43. } else {
  44. content = line.trim()
  45. }
  46. // Skip Planning Time and Execution Time summary lines (at root level)
  47. if (
  48. content.startsWith('Planning Time:') ||
  49. content.startsWith('Execution Time:') ||
  50. content.startsWith('Planning:') ||
  51. content.startsWith('Execution:')
  52. ) {
  53. continue
  54. }
  55. // Check if this is a detail line (like Filter:, Sort Key:, etc.)
  56. if (detailPatterns.test(content) && stack.length > 0) {
  57. // Attach to the most recent node at or above this indentation
  58. const currentNode = stack[stack.length - 1].node
  59. currentNode.details += (currentNode.details ? '\n' : '') + content
  60. continue
  61. }
  62. // Check if this is a continuation of details (indented text without operation pattern)
  63. // These are typically wrapped condition expressions
  64. if (!hasArrow && stack.length > 0 && leadingSpaces > 0) {
  65. const lastItem = stack[stack.length - 1]
  66. // If it's more indented than the last node and doesn't look like an operation
  67. if (leadingSpaces > lastItem.indent && !content.match(/^\w+.*\(cost=/)) {
  68. lastItem.node.details += (lastItem.node.details ? '\n' : '') + content
  69. continue
  70. }
  71. }
  72. // Parse main operation line: "Operation on table (metrics)"
  73. // Match operation with optional metrics in parentheses
  74. // Handle multiple metric groups like (cost=...) (actual time=...)
  75. const metricsMatch = content.match(/^(.+?)\s*(\([^)]*cost=[^)]+\)(?:\s*\([^)]+\))*)?\s*$/)
  76. if (!metricsMatch) {
  77. continue
  78. }
  79. const [, operationPart, metricsStr] = metricsMatch
  80. const metrics = metricsStr
  81. ? metricsStr.replace(/^\(|\)$/g, '').replace(/\)\s*\(/g, ' ')
  82. : undefined
  83. // Split operation and object name (e.g., "Seq Scan on users" -> operation: "Seq Scan", details: "users")
  84. let operation = operationPart.trim()
  85. let details = ''
  86. // Check for "on tablename" or "using indexname" patterns
  87. const onMatch = operationPart.match(/^(.+?)\s+on\s+(.+)$/i)
  88. const usingMatch = operationPart.match(/^(.+?)\s+using\s+(.+)$/i)
  89. if (onMatch) {
  90. operation = onMatch[1].trim()
  91. details = 'on ' + onMatch[2].trim()
  92. } else if (usingMatch) {
  93. operation = usingMatch[1].trim()
  94. details = 'using ' + usingMatch[2].trim()
  95. }
  96. // Calculate the tree level based on indentation
  97. // PostgreSQL typically uses 6 spaces per level for -> nodes
  98. const level = hasArrow ? Math.floor(effectiveIndent / 6) + 1 : 0
  99. const node = createNode(operation, details, metrics, level, line)
  100. addNodeToTree(node, effectiveIndent, root, stack)
  101. }
  102. return root
  103. }
  104. function createNode(
  105. operation: string,
  106. details: string | undefined,
  107. metrics: string | undefined,
  108. level: number,
  109. raw: string
  110. ): ExplainNode {
  111. const node: ExplainNode = {
  112. operation: operation.trim(),
  113. details: details?.trim() || '',
  114. level,
  115. children: [],
  116. raw,
  117. }
  118. if (metrics) {
  119. // Parse cost=start..end
  120. const costMatch = metrics.match(/cost=([\d.]+)\.\.([\d.]+)/)
  121. if (costMatch) {
  122. const start = parseFloatMetric(costMatch[1])
  123. const end = parseFloatMetric(costMatch[2])
  124. // Only set cost if both values are valid numbers
  125. if (start !== undefined && end !== undefined) {
  126. node.cost = { start, end }
  127. }
  128. }
  129. // Parse rows=N (estimated rows, always the first occurrence)
  130. const rowsMatch = metrics.match(/rows=(\d+)/)
  131. if (rowsMatch) {
  132. node.rows = parseIntMetric(rowsMatch[1])
  133. }
  134. // Parse width=N
  135. const widthMatch = metrics.match(/width=(\d+)/)
  136. if (widthMatch) {
  137. node.width = parseIntMetric(widthMatch[1])
  138. }
  139. // Parse actual time=start..end
  140. const actualTimeMatch = metrics.match(/actual time=([\d.]+)\.\.([\d.]+)/)
  141. if (actualTimeMatch) {
  142. const start = parseFloatMetric(actualTimeMatch[1])
  143. const end = parseFloatMetric(actualTimeMatch[2])
  144. // Only set actualTime if both values are valid numbers
  145. if (start !== undefined && end !== undefined) {
  146. node.actualTime = { start, end }
  147. }
  148. // When EXPLAIN ANALYZE is used, the second rows= value (after actual time) is the actual rows
  149. const actualTimePart = metrics.substring(metrics.indexOf('actual time='))
  150. const actualRowsMatch = actualTimePart.match(/rows=(\d+)/)
  151. if (actualRowsMatch) {
  152. node.actualRows = parseIntMetric(actualRowsMatch[1])
  153. }
  154. }
  155. }
  156. return node
  157. }
  158. // After node creation, parse detail fields like "Rows Removed by Filter"
  159. export function parseNodeDetails(node: ExplainNode): void {
  160. if (node.details) {
  161. const rowsRemovedMatch = node.details.match(/Rows Removed by Filter:\s*(\d+)/)
  162. if (rowsRemovedMatch) {
  163. node.rowsRemovedByFilter = parseIntMetric(rowsRemovedMatch[1])
  164. }
  165. }
  166. node.children.forEach(parseNodeDetails)
  167. }
  168. function addNodeToTree(
  169. node: ExplainNode,
  170. indent: number,
  171. root: ExplainNode[],
  172. stack: { node: ExplainNode; indent: number }[]
  173. ) {
  174. // Remove nodes from stack that are at the same or deeper indentation
  175. while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
  176. stack.pop()
  177. }
  178. if (stack.length === 0) {
  179. root.push(node)
  180. } else {
  181. stack[stack.length - 1].node.children.push(node)
  182. }
  183. stack.push({ node, indent })
  184. }
  185. // Calculate max cost for scaling the visualization bars
  186. function getNodeMaxCost(node: ExplainNode): number {
  187. const nodeCost = node.cost?.end || node.actualTime?.end || 0
  188. const childrenMax = node.children.reduce((max, child) => Math.max(max, getNodeMaxCost(child)), 0)
  189. return Math.max(nodeCost, childrenMax)
  190. }
  191. export function calculateMaxCost(tree: ExplainNode[]): number {
  192. return tree.reduce((max, node) => Math.max(max, getNodeMaxCost(node)), 0)
  193. }
  194. // Calculate max duration across all nodes for scaling the visualization bars
  195. function getNodeMaxDuration(node: ExplainNode): number {
  196. const nodeDuration = node.actualTime ? node.actualTime.end - node.actualTime.start : 0
  197. const childrenMax = node.children.reduce(
  198. (max, child) => Math.max(max, getNodeMaxDuration(child)),
  199. 0
  200. )
  201. return Math.max(nodeDuration, childrenMax)
  202. }
  203. export function calculateMaxDuration(tree: ExplainNode[]): number {
  204. return tree.reduce((max, node) => Math.max(max, getNodeMaxDuration(node)), 0)
  205. }
  206. // Calculate summary stats
  207. export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
  208. const stats: ExplainSummary = {
  209. totalTime: 0,
  210. totalCost: 0,
  211. maxCost: 0,
  212. hasSeqScan: false,
  213. seqScanTables: [],
  214. hasIndexScan: false,
  215. }
  216. const traverse = (node: ExplainNode) => {
  217. if (node.actualTime) {
  218. stats.totalTime = Math.max(stats.totalTime, node.actualTime.end)
  219. }
  220. if (node.cost) {
  221. stats.maxCost = Math.max(stats.maxCost, node.cost.end)
  222. }
  223. const op = node.operation.toLowerCase()
  224. if (op.includes('seq scan')) {
  225. stats.hasSeqScan = true
  226. const tableMatch = node.details.match(/on\s+((?:"[^"]+"|[\w]+)(?:\.(?:"[^"]+"|[\w]+))*)/)
  227. if (tableMatch) stats.seqScanTables.push(tableMatch[1])
  228. }
  229. if (op.includes('index')) {
  230. stats.hasIndexScan = true
  231. }
  232. node.children.forEach(traverse)
  233. }
  234. tree.forEach(traverse)
  235. stats.totalCost = tree[0]?.cost?.end ?? 0
  236. return stats
  237. }
  238. export function createNodeTree(rows: readonly QueryPlanRow[]): ExplainNode[] {
  239. const tree = parseExplainOutput(rows)
  240. // Parse additional details from each node
  241. tree.forEach(parseNodeDetails)
  242. return tree
  243. }
  244. export function parseDetailLines(details: string): { label: string; value: string }[] {
  245. if (!details) return []
  246. const lines = details.split('\n').filter(Boolean)
  247. const result: { label: string; value: string }[] = []
  248. for (const line of lines) {
  249. const colonIndex = line.indexOf(':')
  250. if (colonIndex > 0) {
  251. result.push({
  252. label: line.substring(0, colonIndex + 1),
  253. value: line.substring(colonIndex + 1).trim(),
  254. })
  255. } else if (line.trim()) {
  256. // Lines without colons (like table names)
  257. result.push({ label: '', value: line.trim() })
  258. }
  259. }
  260. return result
  261. }