Files
supabase/apps/studio/tests/features/explain-visualizer/ExplainVisualizer.parser.test.ts
Joshen Lim f0fbcbd2a3 Add preflight EXPLAIN check to table editor rows (#42321)
## Context

Part of an investigation to see how we can make the dashboard more
resilient for large databases by ensuring that the dashboard never
becomes the reason for taking down the database accidentally.

Am proposing that for interfaces that rely heavily on queries to the
database for data to render, we add preflight checks to ensure that we
never run queries that exceed a certain cost threshold (and also have UI
handlers to communicate this) - this can be done by running an EXPLAIN
query before running the actual query, and if the cost from the EXPLAIN
exceeds a specified threshold, the UI throws an error then and skips
calling the actual query.

## Demo
Am piloting this with the Table Editor, and got an example here in which
my table has 500K+ rows, and I'm trying to sort on an unindexed column:


https://github.com/user-attachments/assets/ccad2ea9-d62c-4106-8295-2a6df5941474

With this UX, the pros are that
- It's relatively seamless and not too invasive, most users won't notice
this unless they run into this specific scenario
- We can incrementally apply this to other parts of the dashboard, next
will probably be Auth Users for example

However there are some considerations:
- The additional EXPLAIN query adds a bit more latency to the query
since its a separate API request to the query endpoint
- ^ On a similar note, it will hammer the API a bit more, which may
result in higher probability of 429s
- However, I reckon that the preflight checks are meant to be used
sparingly and only for certain parts of the dashboard that we believe
may cause high load.
- e.g for the Table Editor, reckon we only need this for fetching rows?
The count query is largely optimized already (although we could just add
a preflight check there too)
- It's just meant to be a safeguard to prevent running heavy queries on
the database



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Query preflight with cost checks and a user-facing high-cost dialog
showing cost details and remediation suggestions.
* Grid exposes an explicit error flag and surfaces richer error
metadata.

* **Bug Fixes**
* Standardized error handling and more consistent error displays across
the app.
* Explain analysis now reports an additional max-cost metric for
queries.

* **UI**
* Tweaked empty-state interaction/layout and slightly wider header
delete control.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-02-03 17:55:54 +08:00

1142 lines
36 KiB
TypeScript

import {
calculateMaxCost,
calculateSummary,
parseExplainOutput,
parseNodeDetails,
} from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
import type {
ExplainNode,
QueryPlanRow,
} from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
import { describe, expect, test } from 'vitest'
// 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'])
})
})