mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 09:50:33 -04:00
1143 lines
36 KiB
TypeScript
1143 lines
36 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
calculateMaxCost,
|
|
calculateSummary,
|
|
parseExplainOutput,
|
|
parseNodeDetails,
|
|
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
|
|
import type {
|
|
ExplainNode,
|
|
QueryPlanRow,
|
|
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
|
|
|
|
// Helper to create QueryPlanRow array from strings
|
|
const toQueryPlanRows = (lines: string[]): QueryPlanRow[] =>
|
|
lines.map((line) => ({ 'QUERY PLAN': line }))
|
|
|
|
describe('parseExplainOutput', () => {
|
|
describe('simple operations', () => {
|
|
test('parses a simple Seq Scan', () => {
|
|
const input = toQueryPlanRows(['Seq Scan on users (cost=0.00..10.50 rows=100 width=36)'])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
expect(result[0].details).toBe('on users')
|
|
expect(result[0].cost).toEqual({ start: 0, end: 10.5 })
|
|
expect(result[0].rows).toBe(100)
|
|
expect(result[0].width).toBe(36)
|
|
expect(result[0].actualTime).toBeUndefined()
|
|
expect(result[0].actualRows).toBeUndefined()
|
|
expect(result[0].level).toBe(0)
|
|
expect(result[0].children).toHaveLength(0)
|
|
})
|
|
|
|
test('parses an Index Scan', () => {
|
|
const input = toQueryPlanRows([
|
|
'Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=48)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
// Parser keeps "using indexname" as part of operation when "on tablename" is present
|
|
expect(result[0].operation).toBe('Index Scan using users_pkey')
|
|
expect(result[0].details).toBe('on users')
|
|
expect(result[0].cost).toEqual({ start: 0.29, end: 8.3 })
|
|
expect(result[0].rows).toBe(1)
|
|
expect(result[0].width).toBe(48)
|
|
})
|
|
|
|
test('parses an Index Only Scan', () => {
|
|
const input = toQueryPlanRows([
|
|
'Index Only Scan using idx_users_email on users (cost=0.15..4.17 rows=1 width=32)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
// Parser keeps "using indexname" as part of operation when "on tablename" is present
|
|
expect(result[0].operation).toBe('Index Only Scan using idx_users_email')
|
|
expect(result[0].details).toBe('on users')
|
|
})
|
|
|
|
test('parses Bitmap Index Scan and Bitmap Heap Scan', () => {
|
|
const input = toQueryPlanRows([
|
|
'Bitmap Heap Scan on users (cost=4.18..13.65 rows=3 width=36)',
|
|
' -> Bitmap Index Scan on idx_users_status (cost=0.00..4.18 rows=3 width=0)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Bitmap Heap Scan')
|
|
expect(result[0].children).toHaveLength(1)
|
|
expect(result[0].children[0].operation).toBe('Bitmap Index Scan')
|
|
expect(result[0].children[0].details).toBe('on idx_users_status')
|
|
})
|
|
})
|
|
|
|
describe('EXPLAIN ANALYZE output', () => {
|
|
test('parses actual time and actual rows from EXPLAIN ANALYZE', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].cost).toEqual({ start: 0, end: 10.5 })
|
|
expect(result[0].rows).toBe(100) // estimated rows
|
|
expect(result[0].actualTime).toEqual({ start: 0.015, end: 0.123 })
|
|
expect(result[0].actualRows).toBe(85) // actual rows
|
|
})
|
|
|
|
test('skips Planning Time and Execution Time lines', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
|
|
'Planning Time: 0.089 ms',
|
|
'Execution Time: 0.156 ms',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
})
|
|
})
|
|
|
|
describe('nested operations', () => {
|
|
test('parses nested Hash Join with children', () => {
|
|
const input = toQueryPlanRows([
|
|
'Hash Join (cost=10.50..25.30 rows=50 width=72)',
|
|
' Hash Cond: (orders.user_id = users.id)',
|
|
' -> Seq Scan on orders (cost=0.00..12.00 rows=200 width=36)',
|
|
' -> Hash (cost=10.50..10.50 rows=100 width=36)',
|
|
' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Hash Join')
|
|
expect(result[0].details).toContain('Hash Cond:')
|
|
expect(result[0].children).toHaveLength(2)
|
|
expect(result[0].children[0].operation).toBe('Seq Scan')
|
|
expect(result[0].children[0].details).toBe('on orders')
|
|
expect(result[0].children[1].operation).toBe('Hash')
|
|
expect(result[0].children[1].children).toHaveLength(1)
|
|
expect(result[0].children[1].children[0].operation).toBe('Seq Scan')
|
|
})
|
|
|
|
test('parses Merge Join', () => {
|
|
const input = toQueryPlanRows([
|
|
'Merge Join (cost=200.00..350.00 rows=1000 width=80)',
|
|
' Merge Cond: (a.id = b.a_id)',
|
|
' -> Index Scan using a_pkey on a (cost=0.29..50.00 rows=500 width=40)',
|
|
' -> Sort (cost=150.00..155.00 rows=2000 width=40)',
|
|
' Sort Key: b.a_id',
|
|
' -> Seq Scan on b (cost=0.00..30.00 rows=2000 width=40)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Merge Join')
|
|
expect(result[0].children).toHaveLength(2)
|
|
expect(result[0].children[1].operation).toBe('Sort')
|
|
expect(result[0].children[1].details).toContain('Sort Key:')
|
|
})
|
|
|
|
test('parses Nested Loop', () => {
|
|
const input = toQueryPlanRows([
|
|
'Nested Loop (cost=0.29..16.60 rows=1 width=72)',
|
|
' -> Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=36)',
|
|
' -> Index Scan using orders_user_id_idx on orders (cost=0.00..8.28 rows=1 width=36)',
|
|
' Index Cond: (user_id = users.id)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Nested Loop')
|
|
expect(result[0].children).toHaveLength(2)
|
|
expect(result[0].children[1].details).toContain('Index Cond:')
|
|
})
|
|
})
|
|
|
|
describe('aggregate operations', () => {
|
|
test('parses Aggregate operation', () => {
|
|
const input = toQueryPlanRows([
|
|
'Aggregate (cost=12.50..12.51 rows=1 width=8)',
|
|
' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=0)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Aggregate')
|
|
expect(result[0].children).toHaveLength(1)
|
|
})
|
|
|
|
test('parses HashAggregate with Group Key', () => {
|
|
const input = toQueryPlanRows([
|
|
'HashAggregate (cost=15.00..17.00 rows=10 width=12)',
|
|
' Group Key: status',
|
|
' -> Seq Scan on orders (cost=0.00..12.00 rows=200 width=4)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('HashAggregate')
|
|
expect(result[0].details).toContain('Group Key: status')
|
|
expect(result[0].children).toHaveLength(1)
|
|
})
|
|
|
|
test('parses GroupAggregate with Sort', () => {
|
|
const input = toQueryPlanRows([
|
|
'GroupAggregate (cost=20.00..25.00 rows=10 width=12)',
|
|
' Group Key: category',
|
|
' -> Sort (cost=18.00..19.00 rows=200 width=8)',
|
|
' Sort Key: category',
|
|
' -> Seq Scan on products (cost=0.00..15.00 rows=200 width=8)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('GroupAggregate')
|
|
expect(result[0].children).toHaveLength(1)
|
|
expect(result[0].children[0].operation).toBe('Sort')
|
|
expect(result[0].children[0].children).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('sorting and limiting', () => {
|
|
test('parses Sort operation with Sort Key', () => {
|
|
const input = toQueryPlanRows([
|
|
'Sort (cost=25.00..27.50 rows=100 width=36)',
|
|
' Sort Key: created_at DESC',
|
|
' -> Seq Scan on events (cost=0.00..20.00 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Sort')
|
|
expect(result[0].details).toContain('Sort Key: created_at DESC')
|
|
expect(result[0].children).toHaveLength(1)
|
|
})
|
|
|
|
test('parses Sort with Sort Method in EXPLAIN ANALYZE', () => {
|
|
const input = toQueryPlanRows([
|
|
'Sort (cost=25.00..27.50 rows=100 width=36) (actual time=0.100..0.150 rows=100 loops=1)',
|
|
' Sort Key: created_at DESC',
|
|
' Sort Method: quicksort Memory: 32kB',
|
|
' -> Seq Scan on events (cost=0.00..20.00 rows=100 width=36) (actual time=0.010..0.050 rows=100 loops=1)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('Sort Key: created_at DESC')
|
|
expect(result[0].details).toContain('Sort Method: quicksort Memory: 32kB')
|
|
})
|
|
|
|
test('parses Limit operation', () => {
|
|
const input = toQueryPlanRows([
|
|
'Limit (cost=0.00..1.05 rows=10 width=36)',
|
|
' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Limit')
|
|
expect(result[0].children).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('filter conditions', () => {
|
|
test('parses Filter detail', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=50 width=36)',
|
|
" Filter: (status = 'active'::text)",
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain("Filter: (status = 'active'::text)")
|
|
})
|
|
|
|
test('parses Rows Removed by Filter', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=50 width=36) (actual time=0.010..0.100 rows=50 loops=1)',
|
|
" Filter: (status = 'active'::text)",
|
|
' Rows Removed by Filter: 50',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
parseNodeDetails(result[0])
|
|
|
|
expect(result[0].details).toContain('Rows Removed by Filter: 50')
|
|
expect(result[0].rowsRemovedByFilter).toBe(50)
|
|
})
|
|
|
|
test('parses Index Cond', () => {
|
|
const input = toQueryPlanRows([
|
|
'Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=48)',
|
|
' Index Cond: (id = 123)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('Index Cond: (id = 123)')
|
|
})
|
|
|
|
test('parses Recheck Cond for Bitmap scans', () => {
|
|
const input = toQueryPlanRows([
|
|
'Bitmap Heap Scan on users (cost=4.18..13.65 rows=3 width=36)',
|
|
" Recheck Cond: (status = 'active'::text)",
|
|
' -> Bitmap Index Scan on idx_users_status (cost=0.00..4.18 rows=3 width=0)',
|
|
" Index Cond: (status = 'active'::text)",
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result[0].details).toContain("Recheck Cond: (status = 'active'::text)")
|
|
expect(result[0].children[0].details).toContain("Index Cond: (status = 'active'::text)")
|
|
})
|
|
})
|
|
|
|
describe('subplans and CTEs', () => {
|
|
test('parses CTE Scan', () => {
|
|
const input = toQueryPlanRows([
|
|
'CTE Scan on recent_users (cost=10.50..12.50 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('CTE Scan')
|
|
expect(result[0].details).toBe('on recent_users')
|
|
})
|
|
|
|
test('parses SubPlan reference', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on orders (cost=0.00..25.00 rows=100 width=36)',
|
|
' Filter: (total > (SubPlan 1))',
|
|
' SubPlan 1',
|
|
' -> Aggregate (cost=10.50..10.51 rows=1 width=8)',
|
|
' -> Seq Scan on orders orders_1 (cost=0.00..10.50 rows=100 width=4)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('Filter: (total > (SubPlan 1))')
|
|
expect(result[0].details).toContain('SubPlan 1')
|
|
})
|
|
|
|
test('parses InitPlan', () => {
|
|
const input = toQueryPlanRows([
|
|
'Result (cost=10.51..10.52 rows=1 width=8)',
|
|
' InitPlan 1 (returns $0)',
|
|
' -> Aggregate (cost=10.50..10.51 rows=1 width=8)',
|
|
' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=0)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Result')
|
|
expect(result[0].details).toContain('InitPlan 1 (returns $0)')
|
|
})
|
|
})
|
|
|
|
describe('set operations', () => {
|
|
test('parses Append for UNION ALL', () => {
|
|
const input = toQueryPlanRows([
|
|
'Append (cost=0.00..21.00 rows=200 width=36)',
|
|
' -> Seq Scan on users_2023 (cost=0.00..10.50 rows=100 width=36)',
|
|
' -> Seq Scan on users_2024 (cost=0.00..10.50 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Append')
|
|
expect(result[0].children).toHaveLength(2)
|
|
})
|
|
|
|
test('parses Unique for UNION (distinct)', () => {
|
|
const input = toQueryPlanRows([
|
|
'Unique (cost=25.00..30.00 rows=150 width=36)',
|
|
' -> Sort (cost=25.00..26.00 rows=200 width=36)',
|
|
' Sort Key: id',
|
|
' -> Append (cost=0.00..21.00 rows=200 width=36)',
|
|
' -> Seq Scan on users_2023 (cost=0.00..10.50 rows=100 width=36)',
|
|
' -> Seq Scan on users_2024 (cost=0.00..10.50 rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Unique')
|
|
expect(result[0].children).toHaveLength(1)
|
|
expect(result[0].children[0].operation).toBe('Sort')
|
|
})
|
|
})
|
|
|
|
describe('parallel queries', () => {
|
|
test('parses Gather with parallel workers', () => {
|
|
const input = toQueryPlanRows([
|
|
'Gather (cost=1000.00..15000.00 rows=100000 width=36)',
|
|
' Workers Planned: 2',
|
|
' -> Parallel Seq Scan on large_table (cost=0.00..14000.00 rows=41667 width=36)',
|
|
" Filter: (status = 'active'::text)",
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Gather')
|
|
expect(result[0].children).toHaveLength(1)
|
|
expect(result[0].children[0].operation).toBe('Parallel Seq Scan')
|
|
})
|
|
|
|
test('parses Gather Merge', () => {
|
|
const input = toQueryPlanRows([
|
|
'Gather Merge (cost=5000.00..10000.00 rows=50000 width=36)',
|
|
' Workers Planned: 2',
|
|
' -> Sort (cost=4000.00..4125.00 rows=25000 width=36)',
|
|
' Sort Key: created_at DESC',
|
|
' -> Parallel Seq Scan on events (cost=0.00..3000.00 rows=25000 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Gather Merge')
|
|
})
|
|
})
|
|
|
|
describe('buffer information', () => {
|
|
test('parses Buffers information in EXPLAIN (ANALYZE, BUFFERS)', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.010..0.100 rows=100 loops=1)',
|
|
' Buffers: shared hit=5',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('Buffers: shared hit=5')
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
test('handles empty input', () => {
|
|
const result = parseExplainOutput([])
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
test('handles input with only empty strings', () => {
|
|
const input = toQueryPlanRows(['', ' ', ''])
|
|
const result = parseExplainOutput(input)
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
test('handles malformed metric strings gracefully', () => {
|
|
const input = toQueryPlanRows(['Seq Scan on users (malformed metrics)'])
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
expect(result[0].cost).toBeUndefined()
|
|
})
|
|
|
|
test('handles operation without metrics', () => {
|
|
const input = toQueryPlanRows(['Seq Scan on users'])
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
expect(result[0].details).toBe('on users')
|
|
expect(result[0].cost).toBeUndefined()
|
|
})
|
|
|
|
test('handles deeply nested query plans', () => {
|
|
const input = toQueryPlanRows([
|
|
'Limit (cost=100.00..100.10 rows=10 width=36)',
|
|
' -> Sort (cost=100.00..102.50 rows=1000 width=36)',
|
|
' Sort Key: total DESC',
|
|
' -> Hash Join (cost=50.00..80.00 rows=1000 width=36)',
|
|
' Hash Cond: (o.user_id = u.id)',
|
|
' -> Seq Scan on orders o (cost=0.00..20.00 rows=1000 width=20)',
|
|
' -> Hash (cost=40.00..40.00 rows=500 width=16)',
|
|
' -> Seq Scan on users u (cost=0.00..40.00 rows=500 width=16)',
|
|
' Filter: (active = true)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Limit')
|
|
expect(result[0].children[0].operation).toBe('Sort')
|
|
expect(result[0].children[0].children[0].operation).toBe('Hash Join')
|
|
expect(result[0].children[0].children[0].children).toHaveLength(2)
|
|
expect(result[0].children[0].children[0].children[1].children[0].operation).toBe('Seq Scan')
|
|
})
|
|
|
|
test('handles One-Time Filter', () => {
|
|
const input = toQueryPlanRows([
|
|
'Result (cost=0.00..0.01 rows=1 width=0)',
|
|
' One-Time Filter: false',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('One-Time Filter: false')
|
|
})
|
|
|
|
test('handles Output detail line', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
|
|
' Output: id, name, email',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].details).toContain('Output: id, name, email')
|
|
})
|
|
|
|
test('handles invalid cost values gracefully', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=invalid..notanumber rows=100 width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
// Should not parse invalid cost at all
|
|
expect(result[0].cost).toBeUndefined()
|
|
})
|
|
|
|
test('handles invalid rows value gracefully', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=notanumber width=36)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Seq Scan')
|
|
// Should not parse invalid rows at all (regex won't match)
|
|
expect(result[0].rows).toBeUndefined()
|
|
})
|
|
|
|
test('handles invalid actual time values gracefully', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=invalid..notanumber rows=85 loops=1)',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
// Should not parse invalid actual time at all
|
|
expect(result[0].actualTime).toBeUndefined()
|
|
})
|
|
|
|
test('handles invalid rowsRemovedByFilter value gracefully', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
|
|
" Filter: (status = 'active')",
|
|
' Rows Removed by Filter: notanumber',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
parseNodeDetails(result[0])
|
|
|
|
expect(result).toHaveLength(1)
|
|
// Should not parse invalid value at all (regex won't match)
|
|
expect(result[0].rowsRemovedByFilter).toBeUndefined()
|
|
})
|
|
|
|
test('parsed numeric fields are finite for valid input', () => {
|
|
const input = toQueryPlanRows([
|
|
'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
|
|
" Filter: (status = 'active')",
|
|
' Rows Removed by Filter: 15',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
parseNodeDetails(result[0])
|
|
|
|
expect(result).toHaveLength(1)
|
|
const node = result[0]
|
|
|
|
// Verify all numeric values are finite
|
|
if (node.cost) {
|
|
expect(Number.isFinite(node.cost.start)).toBe(true)
|
|
expect(Number.isFinite(node.cost.end)).toBe(true)
|
|
}
|
|
if (node.actualTime) {
|
|
expect(Number.isFinite(node.actualTime.start)).toBe(true)
|
|
expect(Number.isFinite(node.actualTime.end)).toBe(true)
|
|
}
|
|
if (node.rows !== undefined) {
|
|
expect(Number.isFinite(node.rows)).toBe(true)
|
|
}
|
|
if (node.actualRows !== undefined) {
|
|
expect(Number.isFinite(node.actualRows)).toBe(true)
|
|
}
|
|
if (node.width !== undefined) {
|
|
expect(Number.isFinite(node.width)).toBe(true)
|
|
}
|
|
if (node.rowsRemovedByFilter !== undefined) {
|
|
expect(Number.isFinite(node.rowsRemovedByFilter)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('complex real-world queries', () => {
|
|
test('parses a complex analytical query', () => {
|
|
const input = toQueryPlanRows([
|
|
'Limit (cost=1500.00..1500.05 rows=20 width=48) (actual time=15.234..15.240 rows=20 loops=1)',
|
|
' -> Sort (cost=1500.00..1525.00 rows=10000 width=48) (actual time=15.232..15.235 rows=20 loops=1)',
|
|
' Sort Key: (sum(o.total)) DESC',
|
|
' Sort Method: top-N heapsort Memory: 27kB',
|
|
' -> HashAggregate (cost=1200.00..1300.00 rows=10000 width=48) (actual time=12.456..14.789 rows=8543 loops=1)',
|
|
' Group Key: u.id',
|
|
' Batches: 1 Memory Usage: 1169kB',
|
|
' -> Hash Join (cost=125.00..950.00 rows=50000 width=20) (actual time=1.234..8.567 rows=50000 loops=1)',
|
|
' Hash Cond: (o.user_id = u.id)',
|
|
' -> Seq Scan on orders o (cost=0.00..750.00 rows=50000 width=12) (actual time=0.012..3.456 rows=50000 loops=1)',
|
|
' -> Hash (cost=100.00..100.00 rows=2000 width=8) (actual time=1.111..1.111 rows=2000 loops=1)',
|
|
' Buckets: 2048 Batches: 1 Memory Usage: 95kB',
|
|
' -> Seq Scan on users u (cost=0.00..100.00 rows=2000 width=8) (actual time=0.008..0.567 rows=2000 loops=1)',
|
|
' Filter: (active = true)',
|
|
' Rows Removed by Filter: 500',
|
|
])
|
|
|
|
const result = parseExplainOutput(input)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].operation).toBe('Limit')
|
|
expect(result[0].actualTime).toEqual({ start: 15.234, end: 15.24 })
|
|
expect(result[0].actualRows).toBe(20)
|
|
|
|
// Navigate to the deepest Seq Scan on users
|
|
const hashJoin = result[0].children[0].children[0].children[0]
|
|
expect(hashJoin.operation).toBe('Hash Join')
|
|
const hash = hashJoin.children[1]
|
|
expect(hash.operation).toBe('Hash')
|
|
const usersScan = hash.children[0]
|
|
expect(usersScan.operation).toBe('Seq Scan')
|
|
expect(usersScan.details).toContain('Filter: (active = true)')
|
|
expect(usersScan.details).toContain('Rows Removed by Filter: 500')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('parseNodeDetails', () => {
|
|
test('parses Rows Removed by Filter from node details', () => {
|
|
const node: ExplainNode = {
|
|
operation: 'Seq Scan',
|
|
details: "on users\nFilter: (status = 'active')\nRows Removed by Filter: 150",
|
|
cost: { start: 0, end: 10.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
}
|
|
|
|
parseNodeDetails(node)
|
|
|
|
expect(node.rowsRemovedByFilter).toBe(150)
|
|
})
|
|
|
|
test('handles node without Rows Removed by Filter', () => {
|
|
const node: ExplainNode = {
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 10.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
}
|
|
|
|
parseNodeDetails(node)
|
|
|
|
expect(node.rowsRemovedByFilter).toBeUndefined()
|
|
})
|
|
|
|
test('recursively parses details for children', () => {
|
|
const node: ExplainNode = {
|
|
operation: 'Hash Join',
|
|
details: '',
|
|
cost: { start: 0, end: 50 },
|
|
rows: 100,
|
|
width: 72,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on orders\nRows Removed by Filter: 200',
|
|
cost: { start: 0, end: 20 },
|
|
rows: 50,
|
|
width: 36,
|
|
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users\nRows Removed by Filter: 75',
|
|
cost: { start: 0, end: 15 },
|
|
rows: 25,
|
|
width: 36,
|
|
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
}
|
|
|
|
parseNodeDetails(node)
|
|
|
|
expect(node.children[0].rowsRemovedByFilter).toBe(200)
|
|
expect(node.children[1].rowsRemovedByFilter).toBe(75)
|
|
})
|
|
})
|
|
|
|
describe('calculateMaxCost', () => {
|
|
test('returns 0 for empty tree', () => {
|
|
const result = calculateMaxCost([])
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
test('returns cost.end for single node', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 25.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateMaxCost(tree)
|
|
expect(result).toBe(25.5)
|
|
})
|
|
|
|
test('prefers cost.end over actualTime.end (uses actualTime as fallback)', () => {
|
|
// When both cost and actualTime are present, cost takes precedence
|
|
const treeWithBoth: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 10.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
actualTime: { start: 0.01, end: 50.123 },
|
|
actualRows: 100,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
expect(calculateMaxCost(treeWithBoth)).toBe(10.5)
|
|
|
|
// When only actualTime is present, it's used as fallback
|
|
const treeOnlyActualTime: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
|
|
rows: 100,
|
|
width: 36,
|
|
actualTime: { start: 0.01, end: 50.123 },
|
|
actualRows: 100,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
expect(calculateMaxCost(treeOnlyActualTime)).toBe(50.123)
|
|
})
|
|
|
|
test('finds maximum across nested children', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Limit',
|
|
details: '',
|
|
cost: { start: 0, end: 100 },
|
|
rows: 10,
|
|
width: 36,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Sort',
|
|
details: '',
|
|
cost: { start: 0, end: 250 }, // This is the maximum
|
|
rows: 1000,
|
|
width: 36,
|
|
level: 1,
|
|
children: [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 150 },
|
|
rows: 1000,
|
|
width: 36,
|
|
level: 2,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateMaxCost(tree)
|
|
expect(result).toBe(250)
|
|
})
|
|
|
|
test('handles multiple root nodes', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 30 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on orders',
|
|
cost: { start: 0, end: 75 },
|
|
rows: 200,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateMaxCost(tree)
|
|
expect(result).toBe(75)
|
|
})
|
|
|
|
test('handles nodes without cost or actualTime', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Result',
|
|
details: '',
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateMaxCost(tree)
|
|
expect(result).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('calculateSummary', () => {
|
|
test('returns default values for empty tree', () => {
|
|
const result = calculateSummary([])
|
|
|
|
expect(result).toEqual({
|
|
totalTime: 0,
|
|
totalCost: 0,
|
|
maxCost: 0,
|
|
hasSeqScan: false,
|
|
seqScanTables: [],
|
|
hasIndexScan: false,
|
|
})
|
|
})
|
|
|
|
test('calculates totalCost from root node cost.end', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 45.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.totalCost).toBe(45.5)
|
|
})
|
|
|
|
test('calculates maxCost from maximum cost across all nodes', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Limit',
|
|
details: '',
|
|
cost: { start: 0, end: 100 },
|
|
rows: 10,
|
|
width: 36,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Sort',
|
|
details: '',
|
|
cost: { start: 0, end: 250 }, // This is the maximum
|
|
rows: 1000,
|
|
width: 36,
|
|
level: 1,
|
|
children: [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 150 },
|
|
rows: 1000,
|
|
width: 36,
|
|
level: 2,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.totalCost).toBe(100) // Root node cost
|
|
expect(result.maxCost).toBe(250) // Maximum across all nodes
|
|
})
|
|
|
|
test('calculates totalTime from actualTime.end', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 10.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
actualTime: { start: 0.01, end: 123.456 },
|
|
actualRows: 100,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.totalTime).toBe(123.456)
|
|
})
|
|
|
|
test('detects Seq Scan and extracts table name', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 10.5 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.hasSeqScan).toBe(true)
|
|
expect(result.seqScanTables).toEqual(['users'])
|
|
})
|
|
|
|
test('detects multiple Seq Scans on different tables', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Hash Join',
|
|
details: '',
|
|
cost: { start: 0, end: 50 },
|
|
rows: 100,
|
|
width: 72,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on orders',
|
|
cost: { start: 0, end: 20 },
|
|
rows: 100,
|
|
width: 36,
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on users',
|
|
cost: { start: 0, end: 15 },
|
|
rows: 50,
|
|
width: 36,
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.hasSeqScan).toBe(true)
|
|
expect(result.seqScanTables).toEqual(['orders', 'users'])
|
|
})
|
|
|
|
test('detects Index Scan', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Index Scan using users_pkey',
|
|
details: 'on users',
|
|
cost: { start: 0.29, end: 8.3 },
|
|
rows: 1,
|
|
width: 48,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.hasIndexScan).toBe(true)
|
|
expect(result.hasSeqScan).toBe(false)
|
|
})
|
|
|
|
test('detects Index Only Scan', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Index Only Scan',
|
|
details: 'using idx_users_email on users',
|
|
cost: { start: 0.15, end: 4.17 },
|
|
rows: 1,
|
|
width: 32,
|
|
level: 0,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.hasIndexScan).toBe(true)
|
|
})
|
|
|
|
test('detects Bitmap Index Scan', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Bitmap Heap Scan',
|
|
details: 'on users',
|
|
cost: { start: 4.18, end: 13.65 },
|
|
rows: 3,
|
|
width: 36,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Bitmap Index Scan',
|
|
details: 'on idx_users_status',
|
|
cost: { start: 0, end: 4.18 },
|
|
rows: 3,
|
|
width: 0,
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.hasIndexScan).toBe(true)
|
|
})
|
|
|
|
test('handles complex query with both seq and index scans', () => {
|
|
const tree: ExplainNode[] = [
|
|
{
|
|
operation: 'Hash Join',
|
|
details: '',
|
|
cost: { start: 10.5, end: 35.8 },
|
|
rows: 50,
|
|
width: 72,
|
|
actualTime: { start: 0.5, end: 2.345 },
|
|
actualRows: 48,
|
|
level: 0,
|
|
children: [
|
|
{
|
|
operation: 'Seq Scan',
|
|
details: 'on orders',
|
|
cost: { start: 0, end: 20 },
|
|
rows: 100,
|
|
width: 36,
|
|
actualTime: { start: 0.01, end: 0.5 },
|
|
actualRows: 95,
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
{
|
|
operation: 'Index Scan using users_pkey',
|
|
details: 'on users',
|
|
cost: { start: 0.29, end: 8.3 },
|
|
rows: 1,
|
|
width: 36,
|
|
actualTime: { start: 0.005, end: 0.015 },
|
|
actualRows: 1,
|
|
level: 1,
|
|
children: [],
|
|
raw: '',
|
|
},
|
|
],
|
|
raw: '',
|
|
},
|
|
]
|
|
|
|
const result = calculateSummary(tree)
|
|
expect(result.totalCost).toBe(35.8) // Root node cost
|
|
expect(result.maxCost).toBe(35.8) // Maximum cost across all nodes (root is highest)
|
|
expect(result.totalTime).toBe(2.345)
|
|
expect(result.hasSeqScan).toBe(true)
|
|
expect(result.hasIndexScan).toBe(true)
|
|
expect(result.seqScanTables).toEqual(['orders'])
|
|
})
|
|
})
|