| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142 |
- 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'])
- })
- })
|