ExplainVisualizer.parser.test.ts 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142
  1. import { describe, expect, test } from 'vitest'
  2. import {
  3. calculateMaxCost,
  4. calculateSummary,
  5. parseExplainOutput,
  6. parseNodeDetails,
  7. } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
  8. import type {
  9. ExplainNode,
  10. QueryPlanRow,
  11. } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
  12. // Helper to create QueryPlanRow array from strings
  13. const toQueryPlanRows = (lines: string[]): QueryPlanRow[] =>
  14. lines.map((line) => ({ 'QUERY PLAN': line }))
  15. describe('parseExplainOutput', () => {
  16. describe('simple operations', () => {
  17. test('parses a simple Seq Scan', () => {
  18. const input = toQueryPlanRows(['Seq Scan on users (cost=0.00..10.50 rows=100 width=36)'])
  19. const result = parseExplainOutput(input)
  20. expect(result).toHaveLength(1)
  21. expect(result[0].operation).toBe('Seq Scan')
  22. expect(result[0].details).toBe('on users')
  23. expect(result[0].cost).toEqual({ start: 0, end: 10.5 })
  24. expect(result[0].rows).toBe(100)
  25. expect(result[0].width).toBe(36)
  26. expect(result[0].actualTime).toBeUndefined()
  27. expect(result[0].actualRows).toBeUndefined()
  28. expect(result[0].level).toBe(0)
  29. expect(result[0].children).toHaveLength(0)
  30. })
  31. test('parses an Index Scan', () => {
  32. const input = toQueryPlanRows([
  33. 'Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=48)',
  34. ])
  35. const result = parseExplainOutput(input)
  36. expect(result).toHaveLength(1)
  37. // Parser keeps "using indexname" as part of operation when "on tablename" is present
  38. expect(result[0].operation).toBe('Index Scan using users_pkey')
  39. expect(result[0].details).toBe('on users')
  40. expect(result[0].cost).toEqual({ start: 0.29, end: 8.3 })
  41. expect(result[0].rows).toBe(1)
  42. expect(result[0].width).toBe(48)
  43. })
  44. test('parses an Index Only Scan', () => {
  45. const input = toQueryPlanRows([
  46. 'Index Only Scan using idx_users_email on users (cost=0.15..4.17 rows=1 width=32)',
  47. ])
  48. const result = parseExplainOutput(input)
  49. expect(result).toHaveLength(1)
  50. // Parser keeps "using indexname" as part of operation when "on tablename" is present
  51. expect(result[0].operation).toBe('Index Only Scan using idx_users_email')
  52. expect(result[0].details).toBe('on users')
  53. })
  54. test('parses Bitmap Index Scan and Bitmap Heap Scan', () => {
  55. const input = toQueryPlanRows([
  56. 'Bitmap Heap Scan on users (cost=4.18..13.65 rows=3 width=36)',
  57. ' -> Bitmap Index Scan on idx_users_status (cost=0.00..4.18 rows=3 width=0)',
  58. ])
  59. const result = parseExplainOutput(input)
  60. expect(result).toHaveLength(1)
  61. expect(result[0].operation).toBe('Bitmap Heap Scan')
  62. expect(result[0].children).toHaveLength(1)
  63. expect(result[0].children[0].operation).toBe('Bitmap Index Scan')
  64. expect(result[0].children[0].details).toBe('on idx_users_status')
  65. })
  66. })
  67. describe('EXPLAIN ANALYZE output', () => {
  68. test('parses actual time and actual rows from EXPLAIN ANALYZE', () => {
  69. const input = toQueryPlanRows([
  70. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
  71. ])
  72. const result = parseExplainOutput(input)
  73. expect(result).toHaveLength(1)
  74. expect(result[0].cost).toEqual({ start: 0, end: 10.5 })
  75. expect(result[0].rows).toBe(100) // estimated rows
  76. expect(result[0].actualTime).toEqual({ start: 0.015, end: 0.123 })
  77. expect(result[0].actualRows).toBe(85) // actual rows
  78. })
  79. test('skips Planning Time and Execution Time lines', () => {
  80. const input = toQueryPlanRows([
  81. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
  82. 'Planning Time: 0.089 ms',
  83. 'Execution Time: 0.156 ms',
  84. ])
  85. const result = parseExplainOutput(input)
  86. expect(result).toHaveLength(1)
  87. expect(result[0].operation).toBe('Seq Scan')
  88. })
  89. })
  90. describe('nested operations', () => {
  91. test('parses nested Hash Join with children', () => {
  92. const input = toQueryPlanRows([
  93. 'Hash Join (cost=10.50..25.30 rows=50 width=72)',
  94. ' Hash Cond: (orders.user_id = users.id)',
  95. ' -> Seq Scan on orders (cost=0.00..12.00 rows=200 width=36)',
  96. ' -> Hash (cost=10.50..10.50 rows=100 width=36)',
  97. ' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
  98. ])
  99. const result = parseExplainOutput(input)
  100. expect(result).toHaveLength(1)
  101. expect(result[0].operation).toBe('Hash Join')
  102. expect(result[0].details).toContain('Hash Cond:')
  103. expect(result[0].children).toHaveLength(2)
  104. expect(result[0].children[0].operation).toBe('Seq Scan')
  105. expect(result[0].children[0].details).toBe('on orders')
  106. expect(result[0].children[1].operation).toBe('Hash')
  107. expect(result[0].children[1].children).toHaveLength(1)
  108. expect(result[0].children[1].children[0].operation).toBe('Seq Scan')
  109. })
  110. test('parses Merge Join', () => {
  111. const input = toQueryPlanRows([
  112. 'Merge Join (cost=200.00..350.00 rows=1000 width=80)',
  113. ' Merge Cond: (a.id = b.a_id)',
  114. ' -> Index Scan using a_pkey on a (cost=0.29..50.00 rows=500 width=40)',
  115. ' -> Sort (cost=150.00..155.00 rows=2000 width=40)',
  116. ' Sort Key: b.a_id',
  117. ' -> Seq Scan on b (cost=0.00..30.00 rows=2000 width=40)',
  118. ])
  119. const result = parseExplainOutput(input)
  120. expect(result).toHaveLength(1)
  121. expect(result[0].operation).toBe('Merge Join')
  122. expect(result[0].children).toHaveLength(2)
  123. expect(result[0].children[1].operation).toBe('Sort')
  124. expect(result[0].children[1].details).toContain('Sort Key:')
  125. })
  126. test('parses Nested Loop', () => {
  127. const input = toQueryPlanRows([
  128. 'Nested Loop (cost=0.29..16.60 rows=1 width=72)',
  129. ' -> Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=36)',
  130. ' -> Index Scan using orders_user_id_idx on orders (cost=0.00..8.28 rows=1 width=36)',
  131. ' Index Cond: (user_id = users.id)',
  132. ])
  133. const result = parseExplainOutput(input)
  134. expect(result).toHaveLength(1)
  135. expect(result[0].operation).toBe('Nested Loop')
  136. expect(result[0].children).toHaveLength(2)
  137. expect(result[0].children[1].details).toContain('Index Cond:')
  138. })
  139. })
  140. describe('aggregate operations', () => {
  141. test('parses Aggregate operation', () => {
  142. const input = toQueryPlanRows([
  143. 'Aggregate (cost=12.50..12.51 rows=1 width=8)',
  144. ' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=0)',
  145. ])
  146. const result = parseExplainOutput(input)
  147. expect(result).toHaveLength(1)
  148. expect(result[0].operation).toBe('Aggregate')
  149. expect(result[0].children).toHaveLength(1)
  150. })
  151. test('parses HashAggregate with Group Key', () => {
  152. const input = toQueryPlanRows([
  153. 'HashAggregate (cost=15.00..17.00 rows=10 width=12)',
  154. ' Group Key: status',
  155. ' -> Seq Scan on orders (cost=0.00..12.00 rows=200 width=4)',
  156. ])
  157. const result = parseExplainOutput(input)
  158. expect(result).toHaveLength(1)
  159. expect(result[0].operation).toBe('HashAggregate')
  160. expect(result[0].details).toContain('Group Key: status')
  161. expect(result[0].children).toHaveLength(1)
  162. })
  163. test('parses GroupAggregate with Sort', () => {
  164. const input = toQueryPlanRows([
  165. 'GroupAggregate (cost=20.00..25.00 rows=10 width=12)',
  166. ' Group Key: category',
  167. ' -> Sort (cost=18.00..19.00 rows=200 width=8)',
  168. ' Sort Key: category',
  169. ' -> Seq Scan on products (cost=0.00..15.00 rows=200 width=8)',
  170. ])
  171. const result = parseExplainOutput(input)
  172. expect(result).toHaveLength(1)
  173. expect(result[0].operation).toBe('GroupAggregate')
  174. expect(result[0].children).toHaveLength(1)
  175. expect(result[0].children[0].operation).toBe('Sort')
  176. expect(result[0].children[0].children).toHaveLength(1)
  177. })
  178. })
  179. describe('sorting and limiting', () => {
  180. test('parses Sort operation with Sort Key', () => {
  181. const input = toQueryPlanRows([
  182. 'Sort (cost=25.00..27.50 rows=100 width=36)',
  183. ' Sort Key: created_at DESC',
  184. ' -> Seq Scan on events (cost=0.00..20.00 rows=100 width=36)',
  185. ])
  186. const result = parseExplainOutput(input)
  187. expect(result).toHaveLength(1)
  188. expect(result[0].operation).toBe('Sort')
  189. expect(result[0].details).toContain('Sort Key: created_at DESC')
  190. expect(result[0].children).toHaveLength(1)
  191. })
  192. test('parses Sort with Sort Method in EXPLAIN ANALYZE', () => {
  193. const input = toQueryPlanRows([
  194. 'Sort (cost=25.00..27.50 rows=100 width=36) (actual time=0.100..0.150 rows=100 loops=1)',
  195. ' Sort Key: created_at DESC',
  196. ' Sort Method: quicksort Memory: 32kB',
  197. ' -> Seq Scan on events (cost=0.00..20.00 rows=100 width=36) (actual time=0.010..0.050 rows=100 loops=1)',
  198. ])
  199. const result = parseExplainOutput(input)
  200. expect(result).toHaveLength(1)
  201. expect(result[0].details).toContain('Sort Key: created_at DESC')
  202. expect(result[0].details).toContain('Sort Method: quicksort Memory: 32kB')
  203. })
  204. test('parses Limit operation', () => {
  205. const input = toQueryPlanRows([
  206. 'Limit (cost=0.00..1.05 rows=10 width=36)',
  207. ' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
  208. ])
  209. const result = parseExplainOutput(input)
  210. expect(result).toHaveLength(1)
  211. expect(result[0].operation).toBe('Limit')
  212. expect(result[0].children).toHaveLength(1)
  213. })
  214. })
  215. describe('filter conditions', () => {
  216. test('parses Filter detail', () => {
  217. const input = toQueryPlanRows([
  218. 'Seq Scan on users (cost=0.00..10.50 rows=50 width=36)',
  219. " Filter: (status = 'active'::text)",
  220. ])
  221. const result = parseExplainOutput(input)
  222. expect(result).toHaveLength(1)
  223. expect(result[0].details).toContain("Filter: (status = 'active'::text)")
  224. })
  225. test('parses Rows Removed by Filter', () => {
  226. const input = toQueryPlanRows([
  227. 'Seq Scan on users (cost=0.00..10.50 rows=50 width=36) (actual time=0.010..0.100 rows=50 loops=1)',
  228. " Filter: (status = 'active'::text)",
  229. ' Rows Removed by Filter: 50',
  230. ])
  231. const result = parseExplainOutput(input)
  232. parseNodeDetails(result[0])
  233. expect(result[0].details).toContain('Rows Removed by Filter: 50')
  234. expect(result[0].rowsRemovedByFilter).toBe(50)
  235. })
  236. test('parses Index Cond', () => {
  237. const input = toQueryPlanRows([
  238. 'Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=48)',
  239. ' Index Cond: (id = 123)',
  240. ])
  241. const result = parseExplainOutput(input)
  242. expect(result).toHaveLength(1)
  243. expect(result[0].details).toContain('Index Cond: (id = 123)')
  244. })
  245. test('parses Recheck Cond for Bitmap scans', () => {
  246. const input = toQueryPlanRows([
  247. 'Bitmap Heap Scan on users (cost=4.18..13.65 rows=3 width=36)',
  248. " Recheck Cond: (status = 'active'::text)",
  249. ' -> Bitmap Index Scan on idx_users_status (cost=0.00..4.18 rows=3 width=0)',
  250. " Index Cond: (status = 'active'::text)",
  251. ])
  252. const result = parseExplainOutput(input)
  253. expect(result[0].details).toContain("Recheck Cond: (status = 'active'::text)")
  254. expect(result[0].children[0].details).toContain("Index Cond: (status = 'active'::text)")
  255. })
  256. })
  257. describe('subplans and CTEs', () => {
  258. test('parses CTE Scan', () => {
  259. const input = toQueryPlanRows([
  260. 'CTE Scan on recent_users (cost=10.50..12.50 rows=100 width=36)',
  261. ])
  262. const result = parseExplainOutput(input)
  263. expect(result).toHaveLength(1)
  264. expect(result[0].operation).toBe('CTE Scan')
  265. expect(result[0].details).toBe('on recent_users')
  266. })
  267. test('parses SubPlan reference', () => {
  268. const input = toQueryPlanRows([
  269. 'Seq Scan on orders (cost=0.00..25.00 rows=100 width=36)',
  270. ' Filter: (total > (SubPlan 1))',
  271. ' SubPlan 1',
  272. ' -> Aggregate (cost=10.50..10.51 rows=1 width=8)',
  273. ' -> Seq Scan on orders orders_1 (cost=0.00..10.50 rows=100 width=4)',
  274. ])
  275. const result = parseExplainOutput(input)
  276. expect(result).toHaveLength(1)
  277. expect(result[0].details).toContain('Filter: (total > (SubPlan 1))')
  278. expect(result[0].details).toContain('SubPlan 1')
  279. })
  280. test('parses InitPlan', () => {
  281. const input = toQueryPlanRows([
  282. 'Result (cost=10.51..10.52 rows=1 width=8)',
  283. ' InitPlan 1 (returns $0)',
  284. ' -> Aggregate (cost=10.50..10.51 rows=1 width=8)',
  285. ' -> Seq Scan on users (cost=0.00..10.50 rows=100 width=0)',
  286. ])
  287. const result = parseExplainOutput(input)
  288. expect(result).toHaveLength(1)
  289. expect(result[0].operation).toBe('Result')
  290. expect(result[0].details).toContain('InitPlan 1 (returns $0)')
  291. })
  292. })
  293. describe('set operations', () => {
  294. test('parses Append for UNION ALL', () => {
  295. const input = toQueryPlanRows([
  296. 'Append (cost=0.00..21.00 rows=200 width=36)',
  297. ' -> Seq Scan on users_2023 (cost=0.00..10.50 rows=100 width=36)',
  298. ' -> Seq Scan on users_2024 (cost=0.00..10.50 rows=100 width=36)',
  299. ])
  300. const result = parseExplainOutput(input)
  301. expect(result).toHaveLength(1)
  302. expect(result[0].operation).toBe('Append')
  303. expect(result[0].children).toHaveLength(2)
  304. })
  305. test('parses Unique for UNION (distinct)', () => {
  306. const input = toQueryPlanRows([
  307. 'Unique (cost=25.00..30.00 rows=150 width=36)',
  308. ' -> Sort (cost=25.00..26.00 rows=200 width=36)',
  309. ' Sort Key: id',
  310. ' -> Append (cost=0.00..21.00 rows=200 width=36)',
  311. ' -> Seq Scan on users_2023 (cost=0.00..10.50 rows=100 width=36)',
  312. ' -> Seq Scan on users_2024 (cost=0.00..10.50 rows=100 width=36)',
  313. ])
  314. const result = parseExplainOutput(input)
  315. expect(result).toHaveLength(1)
  316. expect(result[0].operation).toBe('Unique')
  317. expect(result[0].children).toHaveLength(1)
  318. expect(result[0].children[0].operation).toBe('Sort')
  319. })
  320. })
  321. describe('parallel queries', () => {
  322. test('parses Gather with parallel workers', () => {
  323. const input = toQueryPlanRows([
  324. 'Gather (cost=1000.00..15000.00 rows=100000 width=36)',
  325. ' Workers Planned: 2',
  326. ' -> Parallel Seq Scan on large_table (cost=0.00..14000.00 rows=41667 width=36)',
  327. " Filter: (status = 'active'::text)",
  328. ])
  329. const result = parseExplainOutput(input)
  330. expect(result).toHaveLength(1)
  331. expect(result[0].operation).toBe('Gather')
  332. expect(result[0].children).toHaveLength(1)
  333. expect(result[0].children[0].operation).toBe('Parallel Seq Scan')
  334. })
  335. test('parses Gather Merge', () => {
  336. const input = toQueryPlanRows([
  337. 'Gather Merge (cost=5000.00..10000.00 rows=50000 width=36)',
  338. ' Workers Planned: 2',
  339. ' -> Sort (cost=4000.00..4125.00 rows=25000 width=36)',
  340. ' Sort Key: created_at DESC',
  341. ' -> Parallel Seq Scan on events (cost=0.00..3000.00 rows=25000 width=36)',
  342. ])
  343. const result = parseExplainOutput(input)
  344. expect(result).toHaveLength(1)
  345. expect(result[0].operation).toBe('Gather Merge')
  346. })
  347. })
  348. describe('buffer information', () => {
  349. test('parses Buffers information in EXPLAIN (ANALYZE, BUFFERS)', () => {
  350. const input = toQueryPlanRows([
  351. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.010..0.100 rows=100 loops=1)',
  352. ' Buffers: shared hit=5',
  353. ])
  354. const result = parseExplainOutput(input)
  355. expect(result).toHaveLength(1)
  356. expect(result[0].details).toContain('Buffers: shared hit=5')
  357. })
  358. })
  359. describe('edge cases', () => {
  360. test('handles empty input', () => {
  361. const result = parseExplainOutput([])
  362. expect(result).toHaveLength(0)
  363. })
  364. test('handles input with only empty strings', () => {
  365. const input = toQueryPlanRows(['', ' ', ''])
  366. const result = parseExplainOutput(input)
  367. expect(result).toHaveLength(0)
  368. })
  369. test('handles malformed metric strings gracefully', () => {
  370. const input = toQueryPlanRows(['Seq Scan on users (malformed metrics)'])
  371. const result = parseExplainOutput(input)
  372. expect(result).toHaveLength(1)
  373. expect(result[0].operation).toBe('Seq Scan')
  374. expect(result[0].cost).toBeUndefined()
  375. })
  376. test('handles operation without metrics', () => {
  377. const input = toQueryPlanRows(['Seq Scan on users'])
  378. const result = parseExplainOutput(input)
  379. expect(result).toHaveLength(1)
  380. expect(result[0].operation).toBe('Seq Scan')
  381. expect(result[0].details).toBe('on users')
  382. expect(result[0].cost).toBeUndefined()
  383. })
  384. test('handles deeply nested query plans', () => {
  385. const input = toQueryPlanRows([
  386. 'Limit (cost=100.00..100.10 rows=10 width=36)',
  387. ' -> Sort (cost=100.00..102.50 rows=1000 width=36)',
  388. ' Sort Key: total DESC',
  389. ' -> Hash Join (cost=50.00..80.00 rows=1000 width=36)',
  390. ' Hash Cond: (o.user_id = u.id)',
  391. ' -> Seq Scan on orders o (cost=0.00..20.00 rows=1000 width=20)',
  392. ' -> Hash (cost=40.00..40.00 rows=500 width=16)',
  393. ' -> Seq Scan on users u (cost=0.00..40.00 rows=500 width=16)',
  394. ' Filter: (active = true)',
  395. ])
  396. const result = parseExplainOutput(input)
  397. expect(result).toHaveLength(1)
  398. expect(result[0].operation).toBe('Limit')
  399. expect(result[0].children[0].operation).toBe('Sort')
  400. expect(result[0].children[0].children[0].operation).toBe('Hash Join')
  401. expect(result[0].children[0].children[0].children).toHaveLength(2)
  402. expect(result[0].children[0].children[0].children[1].children[0].operation).toBe('Seq Scan')
  403. })
  404. test('handles One-Time Filter', () => {
  405. const input = toQueryPlanRows([
  406. 'Result (cost=0.00..0.01 rows=1 width=0)',
  407. ' One-Time Filter: false',
  408. ])
  409. const result = parseExplainOutput(input)
  410. expect(result).toHaveLength(1)
  411. expect(result[0].details).toContain('One-Time Filter: false')
  412. })
  413. test('handles Output detail line', () => {
  414. const input = toQueryPlanRows([
  415. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
  416. ' Output: id, name, email',
  417. ])
  418. const result = parseExplainOutput(input)
  419. expect(result).toHaveLength(1)
  420. expect(result[0].details).toContain('Output: id, name, email')
  421. })
  422. test('handles invalid cost values gracefully', () => {
  423. const input = toQueryPlanRows([
  424. 'Seq Scan on users (cost=invalid..notanumber rows=100 width=36)',
  425. ])
  426. const result = parseExplainOutput(input)
  427. expect(result).toHaveLength(1)
  428. expect(result[0].operation).toBe('Seq Scan')
  429. // Should not parse invalid cost at all
  430. expect(result[0].cost).toBeUndefined()
  431. })
  432. test('handles invalid rows value gracefully', () => {
  433. const input = toQueryPlanRows([
  434. 'Seq Scan on users (cost=0.00..10.50 rows=notanumber width=36)',
  435. ])
  436. const result = parseExplainOutput(input)
  437. expect(result).toHaveLength(1)
  438. expect(result[0].operation).toBe('Seq Scan')
  439. // Should not parse invalid rows at all (regex won't match)
  440. expect(result[0].rows).toBeUndefined()
  441. })
  442. test('handles invalid actual time values gracefully', () => {
  443. const input = toQueryPlanRows([
  444. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=invalid..notanumber rows=85 loops=1)',
  445. ])
  446. const result = parseExplainOutput(input)
  447. expect(result).toHaveLength(1)
  448. // Should not parse invalid actual time at all
  449. expect(result[0].actualTime).toBeUndefined()
  450. })
  451. test('handles invalid rowsRemovedByFilter value gracefully', () => {
  452. const input = toQueryPlanRows([
  453. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36)',
  454. " Filter: (status = 'active')",
  455. ' Rows Removed by Filter: notanumber',
  456. ])
  457. const result = parseExplainOutput(input)
  458. parseNodeDetails(result[0])
  459. expect(result).toHaveLength(1)
  460. // Should not parse invalid value at all (regex won't match)
  461. expect(result[0].rowsRemovedByFilter).toBeUndefined()
  462. })
  463. test('parsed numeric fields are finite for valid input', () => {
  464. const input = toQueryPlanRows([
  465. 'Seq Scan on users (cost=0.00..10.50 rows=100 width=36) (actual time=0.015..0.123 rows=85 loops=1)',
  466. " Filter: (status = 'active')",
  467. ' Rows Removed by Filter: 15',
  468. ])
  469. const result = parseExplainOutput(input)
  470. parseNodeDetails(result[0])
  471. expect(result).toHaveLength(1)
  472. const node = result[0]
  473. // Verify all numeric values are finite
  474. if (node.cost) {
  475. expect(Number.isFinite(node.cost.start)).toBe(true)
  476. expect(Number.isFinite(node.cost.end)).toBe(true)
  477. }
  478. if (node.actualTime) {
  479. expect(Number.isFinite(node.actualTime.start)).toBe(true)
  480. expect(Number.isFinite(node.actualTime.end)).toBe(true)
  481. }
  482. if (node.rows !== undefined) {
  483. expect(Number.isFinite(node.rows)).toBe(true)
  484. }
  485. if (node.actualRows !== undefined) {
  486. expect(Number.isFinite(node.actualRows)).toBe(true)
  487. }
  488. if (node.width !== undefined) {
  489. expect(Number.isFinite(node.width)).toBe(true)
  490. }
  491. if (node.rowsRemovedByFilter !== undefined) {
  492. expect(Number.isFinite(node.rowsRemovedByFilter)).toBe(true)
  493. }
  494. })
  495. })
  496. describe('complex real-world queries', () => {
  497. test('parses a complex analytical query', () => {
  498. const input = toQueryPlanRows([
  499. 'Limit (cost=1500.00..1500.05 rows=20 width=48) (actual time=15.234..15.240 rows=20 loops=1)',
  500. ' -> Sort (cost=1500.00..1525.00 rows=10000 width=48) (actual time=15.232..15.235 rows=20 loops=1)',
  501. ' Sort Key: (sum(o.total)) DESC',
  502. ' Sort Method: top-N heapsort Memory: 27kB',
  503. ' -> HashAggregate (cost=1200.00..1300.00 rows=10000 width=48) (actual time=12.456..14.789 rows=8543 loops=1)',
  504. ' Group Key: u.id',
  505. ' Batches: 1 Memory Usage: 1169kB',
  506. ' -> Hash Join (cost=125.00..950.00 rows=50000 width=20) (actual time=1.234..8.567 rows=50000 loops=1)',
  507. ' Hash Cond: (o.user_id = u.id)',
  508. ' -> Seq Scan on orders o (cost=0.00..750.00 rows=50000 width=12) (actual time=0.012..3.456 rows=50000 loops=1)',
  509. ' -> Hash (cost=100.00..100.00 rows=2000 width=8) (actual time=1.111..1.111 rows=2000 loops=1)',
  510. ' Buckets: 2048 Batches: 1 Memory Usage: 95kB',
  511. ' -> Seq Scan on users u (cost=0.00..100.00 rows=2000 width=8) (actual time=0.008..0.567 rows=2000 loops=1)',
  512. ' Filter: (active = true)',
  513. ' Rows Removed by Filter: 500',
  514. ])
  515. const result = parseExplainOutput(input)
  516. expect(result).toHaveLength(1)
  517. expect(result[0].operation).toBe('Limit')
  518. expect(result[0].actualTime).toEqual({ start: 15.234, end: 15.24 })
  519. expect(result[0].actualRows).toBe(20)
  520. // Navigate to the deepest Seq Scan on users
  521. const hashJoin = result[0].children[0].children[0].children[0]
  522. expect(hashJoin.operation).toBe('Hash Join')
  523. const hash = hashJoin.children[1]
  524. expect(hash.operation).toBe('Hash')
  525. const usersScan = hash.children[0]
  526. expect(usersScan.operation).toBe('Seq Scan')
  527. expect(usersScan.details).toContain('Filter: (active = true)')
  528. expect(usersScan.details).toContain('Rows Removed by Filter: 500')
  529. })
  530. })
  531. })
  532. describe('parseNodeDetails', () => {
  533. test('parses Rows Removed by Filter from node details', () => {
  534. const node: ExplainNode = {
  535. operation: 'Seq Scan',
  536. details: "on users\nFilter: (status = 'active')\nRows Removed by Filter: 150",
  537. cost: { start: 0, end: 10.5 },
  538. rows: 100,
  539. width: 36,
  540. level: 0,
  541. children: [],
  542. raw: '',
  543. }
  544. parseNodeDetails(node)
  545. expect(node.rowsRemovedByFilter).toBe(150)
  546. })
  547. test('handles node without Rows Removed by Filter', () => {
  548. const node: ExplainNode = {
  549. operation: 'Seq Scan',
  550. details: 'on users',
  551. cost: { start: 0, end: 10.5 },
  552. rows: 100,
  553. width: 36,
  554. level: 0,
  555. children: [],
  556. raw: '',
  557. }
  558. parseNodeDetails(node)
  559. expect(node.rowsRemovedByFilter).toBeUndefined()
  560. })
  561. test('recursively parses details for children', () => {
  562. const node: ExplainNode = {
  563. operation: 'Hash Join',
  564. details: '',
  565. cost: { start: 0, end: 50 },
  566. rows: 100,
  567. width: 72,
  568. level: 0,
  569. children: [
  570. {
  571. operation: 'Seq Scan',
  572. details: 'on orders\nRows Removed by Filter: 200',
  573. cost: { start: 0, end: 20 },
  574. rows: 50,
  575. width: 36,
  576. level: 1,
  577. children: [],
  578. raw: '',
  579. },
  580. {
  581. operation: 'Seq Scan',
  582. details: 'on users\nRows Removed by Filter: 75',
  583. cost: { start: 0, end: 15 },
  584. rows: 25,
  585. width: 36,
  586. level: 1,
  587. children: [],
  588. raw: '',
  589. },
  590. ],
  591. raw: '',
  592. }
  593. parseNodeDetails(node)
  594. expect(node.children[0].rowsRemovedByFilter).toBe(200)
  595. expect(node.children[1].rowsRemovedByFilter).toBe(75)
  596. })
  597. })
  598. describe('calculateMaxCost', () => {
  599. test('returns 0 for empty tree', () => {
  600. const result = calculateMaxCost([])
  601. expect(result).toBe(0)
  602. })
  603. test('returns cost.end for single node', () => {
  604. const tree: ExplainNode[] = [
  605. {
  606. operation: 'Seq Scan',
  607. details: 'on users',
  608. cost: { start: 0, end: 25.5 },
  609. rows: 100,
  610. width: 36,
  611. level: 0,
  612. children: [],
  613. raw: '',
  614. },
  615. ]
  616. const result = calculateMaxCost(tree)
  617. expect(result).toBe(25.5)
  618. })
  619. test('prefers cost.end over actualTime.end (uses actualTime as fallback)', () => {
  620. // When both cost and actualTime are present, cost takes precedence
  621. const treeWithBoth: ExplainNode[] = [
  622. {
  623. operation: 'Seq Scan',
  624. details: 'on users',
  625. cost: { start: 0, end: 10.5 },
  626. rows: 100,
  627. width: 36,
  628. actualTime: { start: 0.01, end: 50.123 },
  629. actualRows: 100,
  630. level: 0,
  631. children: [],
  632. raw: '',
  633. },
  634. ]
  635. expect(calculateMaxCost(treeWithBoth)).toBe(10.5)
  636. // When only actualTime is present, it's used as fallback
  637. const treeOnlyActualTime: ExplainNode[] = [
  638. {
  639. operation: 'Seq Scan',
  640. details: 'on users',
  641. rows: 100,
  642. width: 36,
  643. actualTime: { start: 0.01, end: 50.123 },
  644. actualRows: 100,
  645. level: 0,
  646. children: [],
  647. raw: '',
  648. },
  649. ]
  650. expect(calculateMaxCost(treeOnlyActualTime)).toBe(50.123)
  651. })
  652. test('finds maximum across nested children', () => {
  653. const tree: ExplainNode[] = [
  654. {
  655. operation: 'Limit',
  656. details: '',
  657. cost: { start: 0, end: 100 },
  658. rows: 10,
  659. width: 36,
  660. level: 0,
  661. children: [
  662. {
  663. operation: 'Sort',
  664. details: '',
  665. cost: { start: 0, end: 250 }, // This is the maximum
  666. rows: 1000,
  667. width: 36,
  668. level: 1,
  669. children: [
  670. {
  671. operation: 'Seq Scan',
  672. details: 'on users',
  673. cost: { start: 0, end: 150 },
  674. rows: 1000,
  675. width: 36,
  676. level: 2,
  677. children: [],
  678. raw: '',
  679. },
  680. ],
  681. raw: '',
  682. },
  683. ],
  684. raw: '',
  685. },
  686. ]
  687. const result = calculateMaxCost(tree)
  688. expect(result).toBe(250)
  689. })
  690. test('handles multiple root nodes', () => {
  691. const tree: ExplainNode[] = [
  692. {
  693. operation: 'Seq Scan',
  694. details: 'on users',
  695. cost: { start: 0, end: 30 },
  696. rows: 100,
  697. width: 36,
  698. level: 0,
  699. children: [],
  700. raw: '',
  701. },
  702. {
  703. operation: 'Seq Scan',
  704. details: 'on orders',
  705. cost: { start: 0, end: 75 },
  706. rows: 200,
  707. width: 36,
  708. level: 0,
  709. children: [],
  710. raw: '',
  711. },
  712. ]
  713. const result = calculateMaxCost(tree)
  714. expect(result).toBe(75)
  715. })
  716. test('handles nodes without cost or actualTime', () => {
  717. const tree: ExplainNode[] = [
  718. {
  719. operation: 'Result',
  720. details: '',
  721. level: 0,
  722. children: [],
  723. raw: '',
  724. },
  725. ]
  726. const result = calculateMaxCost(tree)
  727. expect(result).toBe(0)
  728. })
  729. })
  730. describe('calculateSummary', () => {
  731. test('returns default values for empty tree', () => {
  732. const result = calculateSummary([])
  733. expect(result).toEqual({
  734. totalTime: 0,
  735. totalCost: 0,
  736. maxCost: 0,
  737. hasSeqScan: false,
  738. seqScanTables: [],
  739. hasIndexScan: false,
  740. })
  741. })
  742. test('calculates totalCost from root node cost.end', () => {
  743. const tree: ExplainNode[] = [
  744. {
  745. operation: 'Seq Scan',
  746. details: 'on users',
  747. cost: { start: 0, end: 45.5 },
  748. rows: 100,
  749. width: 36,
  750. level: 0,
  751. children: [],
  752. raw: '',
  753. },
  754. ]
  755. const result = calculateSummary(tree)
  756. expect(result.totalCost).toBe(45.5)
  757. })
  758. test('calculates maxCost from maximum cost across all nodes', () => {
  759. const tree: ExplainNode[] = [
  760. {
  761. operation: 'Limit',
  762. details: '',
  763. cost: { start: 0, end: 100 },
  764. rows: 10,
  765. width: 36,
  766. level: 0,
  767. children: [
  768. {
  769. operation: 'Sort',
  770. details: '',
  771. cost: { start: 0, end: 250 }, // This is the maximum
  772. rows: 1000,
  773. width: 36,
  774. level: 1,
  775. children: [
  776. {
  777. operation: 'Seq Scan',
  778. details: 'on users',
  779. cost: { start: 0, end: 150 },
  780. rows: 1000,
  781. width: 36,
  782. level: 2,
  783. children: [],
  784. raw: '',
  785. },
  786. ],
  787. raw: '',
  788. },
  789. ],
  790. raw: '',
  791. },
  792. ]
  793. const result = calculateSummary(tree)
  794. expect(result.totalCost).toBe(100) // Root node cost
  795. expect(result.maxCost).toBe(250) // Maximum across all nodes
  796. })
  797. test('calculates totalTime from actualTime.end', () => {
  798. const tree: ExplainNode[] = [
  799. {
  800. operation: 'Seq Scan',
  801. details: 'on users',
  802. cost: { start: 0, end: 10.5 },
  803. rows: 100,
  804. width: 36,
  805. actualTime: { start: 0.01, end: 123.456 },
  806. actualRows: 100,
  807. level: 0,
  808. children: [],
  809. raw: '',
  810. },
  811. ]
  812. const result = calculateSummary(tree)
  813. expect(result.totalTime).toBe(123.456)
  814. })
  815. test('detects Seq Scan and extracts table name', () => {
  816. const tree: ExplainNode[] = [
  817. {
  818. operation: 'Seq Scan',
  819. details: 'on users',
  820. cost: { start: 0, end: 10.5 },
  821. rows: 100,
  822. width: 36,
  823. level: 0,
  824. children: [],
  825. raw: '',
  826. },
  827. ]
  828. const result = calculateSummary(tree)
  829. expect(result.hasSeqScan).toBe(true)
  830. expect(result.seqScanTables).toEqual(['users'])
  831. })
  832. test('detects multiple Seq Scans on different tables', () => {
  833. const tree: ExplainNode[] = [
  834. {
  835. operation: 'Hash Join',
  836. details: '',
  837. cost: { start: 0, end: 50 },
  838. rows: 100,
  839. width: 72,
  840. level: 0,
  841. children: [
  842. {
  843. operation: 'Seq Scan',
  844. details: 'on orders',
  845. cost: { start: 0, end: 20 },
  846. rows: 100,
  847. width: 36,
  848. level: 1,
  849. children: [],
  850. raw: '',
  851. },
  852. {
  853. operation: 'Seq Scan',
  854. details: 'on users',
  855. cost: { start: 0, end: 15 },
  856. rows: 50,
  857. width: 36,
  858. level: 1,
  859. children: [],
  860. raw: '',
  861. },
  862. ],
  863. raw: '',
  864. },
  865. ]
  866. const result = calculateSummary(tree)
  867. expect(result.hasSeqScan).toBe(true)
  868. expect(result.seqScanTables).toEqual(['orders', 'users'])
  869. })
  870. test('detects Index Scan', () => {
  871. const tree: ExplainNode[] = [
  872. {
  873. operation: 'Index Scan using users_pkey',
  874. details: 'on users',
  875. cost: { start: 0.29, end: 8.3 },
  876. rows: 1,
  877. width: 48,
  878. level: 0,
  879. children: [],
  880. raw: '',
  881. },
  882. ]
  883. const result = calculateSummary(tree)
  884. expect(result.hasIndexScan).toBe(true)
  885. expect(result.hasSeqScan).toBe(false)
  886. })
  887. test('detects Index Only Scan', () => {
  888. const tree: ExplainNode[] = [
  889. {
  890. operation: 'Index Only Scan',
  891. details: 'using idx_users_email on users',
  892. cost: { start: 0.15, end: 4.17 },
  893. rows: 1,
  894. width: 32,
  895. level: 0,
  896. children: [],
  897. raw: '',
  898. },
  899. ]
  900. const result = calculateSummary(tree)
  901. expect(result.hasIndexScan).toBe(true)
  902. })
  903. test('detects Bitmap Index Scan', () => {
  904. const tree: ExplainNode[] = [
  905. {
  906. operation: 'Bitmap Heap Scan',
  907. details: 'on users',
  908. cost: { start: 4.18, end: 13.65 },
  909. rows: 3,
  910. width: 36,
  911. level: 0,
  912. children: [
  913. {
  914. operation: 'Bitmap Index Scan',
  915. details: 'on idx_users_status',
  916. cost: { start: 0, end: 4.18 },
  917. rows: 3,
  918. width: 0,
  919. level: 1,
  920. children: [],
  921. raw: '',
  922. },
  923. ],
  924. raw: '',
  925. },
  926. ]
  927. const result = calculateSummary(tree)
  928. expect(result.hasIndexScan).toBe(true)
  929. })
  930. test('handles complex query with both seq and index scans', () => {
  931. const tree: ExplainNode[] = [
  932. {
  933. operation: 'Hash Join',
  934. details: '',
  935. cost: { start: 10.5, end: 35.8 },
  936. rows: 50,
  937. width: 72,
  938. actualTime: { start: 0.5, end: 2.345 },
  939. actualRows: 48,
  940. level: 0,
  941. children: [
  942. {
  943. operation: 'Seq Scan',
  944. details: 'on orders',
  945. cost: { start: 0, end: 20 },
  946. rows: 100,
  947. width: 36,
  948. actualTime: { start: 0.01, end: 0.5 },
  949. actualRows: 95,
  950. level: 1,
  951. children: [],
  952. raw: '',
  953. },
  954. {
  955. operation: 'Index Scan using users_pkey',
  956. details: 'on users',
  957. cost: { start: 0.29, end: 8.3 },
  958. rows: 1,
  959. width: 36,
  960. actualTime: { start: 0.005, end: 0.015 },
  961. actualRows: 1,
  962. level: 1,
  963. children: [],
  964. raw: '',
  965. },
  966. ],
  967. raw: '',
  968. },
  969. ]
  970. const result = calculateSummary(tree)
  971. expect(result.totalCost).toBe(35.8) // Root node cost
  972. expect(result.maxCost).toBe(35.8) // Maximum cost across all nodes (root is highest)
  973. expect(result.totalTime).toBe(2.345)
  974. expect(result.hasSeqScan).toBe(true)
  975. expect(result.hasIndexScan).toBe(true)
  976. expect(result.seqScanTables).toEqual(['orders'])
  977. })
  978. })