| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- import type { ExplainNode, QueryPlanRow } from './ExplainVisualizer.types'
- export interface ExplainSummary {
- totalTime: number
- totalCost: number
- maxCost: number
- hasSeqScan: boolean
- seqScanTables: string[]
- hasIndexScan: boolean
- }
- function parseFloatMetric(value: string): number | undefined {
- const parsed = parseFloat(value)
- return Number.isFinite(parsed) ? parsed : undefined
- }
- function parseIntMetric(value: string): number | undefined {
- const parsed = parseInt(value, 10)
- return Number.isNaN(parsed) ? undefined : parsed
- }
- // Parse the QUERY PLAN text into a tree structure
- export function parseExplainOutput(rows: readonly QueryPlanRow[]): ExplainNode[] {
- const lines = rows.map((row) => row['QUERY PLAN'] || '').filter(Boolean)
- const root: ExplainNode[] = []
- const stack: { node: ExplainNode; indent: number }[] = []
- // Detail line patterns that should be attached to the previous node
- const detailPatterns =
- /^(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):/
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i]
- // Skip empty lines
- if (!line.trim()) continue
- // Calculate the indentation (number of leading spaces)
- const leadingMatch = line.match(/^(\s*)/)
- const leadingSpaces = leadingMatch ? leadingMatch[1].length : 0
- // Check if this line has an arrow (indicates a child operation node)
- const hasArrow = line.includes('->')
- // Extract the content after any arrow
- let content = line
- let effectiveIndent = leadingSpaces
- if (hasArrow) {
- // Find position of -> and use that for indent calculation
- const arrowIndex = line.indexOf('->')
- effectiveIndent = arrowIndex
- content = line.substring(arrowIndex + 2).trim()
- } else {
- content = line.trim()
- }
- // Skip Planning Time and Execution Time summary lines (at root level)
- if (
- content.startsWith('Planning Time:') ||
- content.startsWith('Execution Time:') ||
- content.startsWith('Planning:') ||
- content.startsWith('Execution:')
- ) {
- continue
- }
- // Check if this is a detail line (like Filter:, Sort Key:, etc.)
- if (detailPatterns.test(content) && stack.length > 0) {
- // Attach to the most recent node at or above this indentation
- const currentNode = stack[stack.length - 1].node
- currentNode.details += (currentNode.details ? '\n' : '') + content
- continue
- }
- // Check if this is a continuation of details (indented text without operation pattern)
- // These are typically wrapped condition expressions
- if (!hasArrow && stack.length > 0 && leadingSpaces > 0) {
- const lastItem = stack[stack.length - 1]
- // If it's more indented than the last node and doesn't look like an operation
- if (leadingSpaces > lastItem.indent && !content.match(/^\w+.*\(cost=/)) {
- lastItem.node.details += (lastItem.node.details ? '\n' : '') + content
- continue
- }
- }
- // Parse main operation line: "Operation on table (metrics)"
- // Match operation with optional metrics in parentheses
- // Handle multiple metric groups like (cost=...) (actual time=...)
- const metricsMatch = content.match(/^(.+?)\s*(\([^)]*cost=[^)]+\)(?:\s*\([^)]+\))*)?\s*$/)
- if (!metricsMatch) {
- continue
- }
- const [, operationPart, metricsStr] = metricsMatch
- const metrics = metricsStr
- ? metricsStr.replace(/^\(|\)$/g, '').replace(/\)\s*\(/g, ' ')
- : undefined
- // Split operation and object name (e.g., "Seq Scan on users" -> operation: "Seq Scan", details: "users")
- let operation = operationPart.trim()
- let details = ''
- // Check for "on tablename" or "using indexname" patterns
- const onMatch = operationPart.match(/^(.+?)\s+on\s+(.+)$/i)
- const usingMatch = operationPart.match(/^(.+?)\s+using\s+(.+)$/i)
- if (onMatch) {
- operation = onMatch[1].trim()
- details = 'on ' + onMatch[2].trim()
- } else if (usingMatch) {
- operation = usingMatch[1].trim()
- details = 'using ' + usingMatch[2].trim()
- }
- // Calculate the tree level based on indentation
- // PostgreSQL typically uses 6 spaces per level for -> nodes
- const level = hasArrow ? Math.floor(effectiveIndent / 6) + 1 : 0
- const node = createNode(operation, details, metrics, level, line)
- addNodeToTree(node, effectiveIndent, root, stack)
- }
- return root
- }
- function createNode(
- operation: string,
- details: string | undefined,
- metrics: string | undefined,
- level: number,
- raw: string
- ): ExplainNode {
- const node: ExplainNode = {
- operation: operation.trim(),
- details: details?.trim() || '',
- level,
- children: [],
- raw,
- }
- if (metrics) {
- // Parse cost=start..end
- const costMatch = metrics.match(/cost=([\d.]+)\.\.([\d.]+)/)
- if (costMatch) {
- const start = parseFloatMetric(costMatch[1])
- const end = parseFloatMetric(costMatch[2])
- // Only set cost if both values are valid numbers
- if (start !== undefined && end !== undefined) {
- node.cost = { start, end }
- }
- }
- // Parse rows=N (estimated rows, always the first occurrence)
- const rowsMatch = metrics.match(/rows=(\d+)/)
- if (rowsMatch) {
- node.rows = parseIntMetric(rowsMatch[1])
- }
- // Parse width=N
- const widthMatch = metrics.match(/width=(\d+)/)
- if (widthMatch) {
- node.width = parseIntMetric(widthMatch[1])
- }
- // Parse actual time=start..end
- const actualTimeMatch = metrics.match(/actual time=([\d.]+)\.\.([\d.]+)/)
- if (actualTimeMatch) {
- const start = parseFloatMetric(actualTimeMatch[1])
- const end = parseFloatMetric(actualTimeMatch[2])
- // Only set actualTime if both values are valid numbers
- if (start !== undefined && end !== undefined) {
- node.actualTime = { start, end }
- }
- // When EXPLAIN ANALYZE is used, the second rows= value (after actual time) is the actual rows
- const actualTimePart = metrics.substring(metrics.indexOf('actual time='))
- const actualRowsMatch = actualTimePart.match(/rows=(\d+)/)
- if (actualRowsMatch) {
- node.actualRows = parseIntMetric(actualRowsMatch[1])
- }
- }
- }
- return node
- }
- // After node creation, parse detail fields like "Rows Removed by Filter"
- export function parseNodeDetails(node: ExplainNode): void {
- if (node.details) {
- const rowsRemovedMatch = node.details.match(/Rows Removed by Filter:\s*(\d+)/)
- if (rowsRemovedMatch) {
- node.rowsRemovedByFilter = parseIntMetric(rowsRemovedMatch[1])
- }
- }
- node.children.forEach(parseNodeDetails)
- }
- function addNodeToTree(
- node: ExplainNode,
- indent: number,
- root: ExplainNode[],
- stack: { node: ExplainNode; indent: number }[]
- ) {
- // Remove nodes from stack that are at the same or deeper indentation
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
- stack.pop()
- }
- if (stack.length === 0) {
- root.push(node)
- } else {
- stack[stack.length - 1].node.children.push(node)
- }
- stack.push({ node, indent })
- }
- // Calculate max cost for scaling the visualization bars
- function getNodeMaxCost(node: ExplainNode): number {
- const nodeCost = node.cost?.end || node.actualTime?.end || 0
- const childrenMax = node.children.reduce((max, child) => Math.max(max, getNodeMaxCost(child)), 0)
- return Math.max(nodeCost, childrenMax)
- }
- export function calculateMaxCost(tree: ExplainNode[]): number {
- return tree.reduce((max, node) => Math.max(max, getNodeMaxCost(node)), 0)
- }
- // Calculate max duration across all nodes for scaling the visualization bars
- function getNodeMaxDuration(node: ExplainNode): number {
- const nodeDuration = node.actualTime ? node.actualTime.end - node.actualTime.start : 0
- const childrenMax = node.children.reduce(
- (max, child) => Math.max(max, getNodeMaxDuration(child)),
- 0
- )
- return Math.max(nodeDuration, childrenMax)
- }
- export function calculateMaxDuration(tree: ExplainNode[]): number {
- return tree.reduce((max, node) => Math.max(max, getNodeMaxDuration(node)), 0)
- }
- // Calculate summary stats
- export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
- const stats: ExplainSummary = {
- totalTime: 0,
- totalCost: 0,
- maxCost: 0,
- hasSeqScan: false,
- seqScanTables: [],
- hasIndexScan: false,
- }
- const traverse = (node: ExplainNode) => {
- if (node.actualTime) {
- stats.totalTime = Math.max(stats.totalTime, node.actualTime.end)
- }
- if (node.cost) {
- stats.maxCost = Math.max(stats.maxCost, node.cost.end)
- }
- const op = node.operation.toLowerCase()
- if (op.includes('seq scan')) {
- stats.hasSeqScan = true
- const tableMatch = node.details.match(/on\s+((?:"[^"]+"|[\w]+)(?:\.(?:"[^"]+"|[\w]+))*)/)
- if (tableMatch) stats.seqScanTables.push(tableMatch[1])
- }
- if (op.includes('index')) {
- stats.hasIndexScan = true
- }
- node.children.forEach(traverse)
- }
- tree.forEach(traverse)
- stats.totalCost = tree[0]?.cost?.end ?? 0
- return stats
- }
- export function createNodeTree(rows: readonly QueryPlanRow[]): ExplainNode[] {
- const tree = parseExplainOutput(rows)
- // Parse additional details from each node
- tree.forEach(parseNodeDetails)
- return tree
- }
- export function parseDetailLines(details: string): { label: string; value: string }[] {
- if (!details) return []
- const lines = details.split('\n').filter(Boolean)
- const result: { label: string; value: string }[] = []
- for (const line of lines) {
- const colonIndex = line.indexOf(':')
- if (colonIndex > 0) {
- result.push({
- label: line.substring(0, colonIndex + 1),
- value: line.substring(colonIndex + 1).trim(),
- })
- } else if (line.trim()) {
- // Lines without colons (like table names)
- result.push({ label: '', value: line.trim() })
- }
- }
- return result
- }
|