rails_db_inspector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +232 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
  6. data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
  7. data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
  8. data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
  9. data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
  10. data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
  11. data/app/jobs/rails_db_inspector/application_job.rb +4 -0
  12. data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
  13. data/app/models/rails_db_inspector/application_record.rb +5 -0
  14. data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
  15. data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
  16. data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
  17. data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
  18. data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
  19. data/config/routes.rb +17 -0
  20. data/lib/rails_db_inspector/configuration.rb +17 -0
  21. data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
  22. data/lib/rails_db_inspector/engine.rb +22 -0
  23. data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
  24. data/lib/rails_db_inspector/explain/postgres.rb +32 -0
  25. data/lib/rails_db_inspector/explain.rb +27 -0
  26. data/lib/rails_db_inspector/query_store.rb +89 -0
  27. data/lib/rails_db_inspector/schema_inspector.rb +222 -0
  28. data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
  29. data/lib/rails_db_inspector/version.rb +3 -0
  30. data/lib/rails_db_inspector.rb +25 -0
  31. data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
  32. metadata +91 -0
@@ -0,0 +1,887 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "erb"
5
+
6
+ module RailsDbInspector
7
+ module ApplicationHelper
8
+ class PostgresPlanRenderer
9
+ def initialize(plan_data)
10
+ @plan_data = plan_data
11
+ @plan = plan_data[:plan]
12
+ @analyze = plan_data[:analyze]
13
+ end
14
+
15
+ def render_summary
16
+ return "" unless @plan && @plan.is_a?(Array) && @plan.first
17
+
18
+ root_plan = @plan.first
19
+ execution_time = root_plan["Execution Time"]
20
+ planning_time = root_plan["Planning Time"]
21
+ total_cost = root_plan["Plan"]["Total Cost"]
22
+ actual_rows = root_plan["Plan"]["Actual Rows"] if @analyze
23
+
24
+ hotspots = find_hotspots(root_plan["Plan"]) if @analyze
25
+ index_analysis = analyze_index_usage(root_plan["Plan"])
26
+ buffer_stats = collect_buffer_stats(root_plan["Plan"]) if @analyze
27
+ recommendations = generate_recommendations(root_plan, index_analysis, buffer_stats)
28
+
29
+ summary_html = <<~HTML
30
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
31
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Execution Summary</h3>
32
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
33
+ HTML
34
+
35
+ if execution_time
36
+ summary_html += <<~HTML
37
+ <div class="bg-white p-4 rounded-md border-l-4 border-blue-400">
38
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Execution Time</div>
39
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{execution_time}ms</div>
40
+ <div class="text-xs text-gray-400 mt-1">Actual wall-clock time to execute the query.</div>
41
+ </div>
42
+ HTML
43
+ end
44
+
45
+ if planning_time
46
+ summary_html += <<~HTML
47
+ <div class="bg-white p-4 rounded-md border-l-4 border-green-400">
48
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Planning Time</div>
49
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{planning_time}ms</div>
50
+ <div class="text-xs text-gray-400 mt-1">Time spent choosing the best execution strategy.</div>
51
+ </div>
52
+ HTML
53
+ end
54
+
55
+ if total_cost
56
+ summary_html += <<~HTML
57
+ <div class="bg-white p-4 rounded-md border-l-4 border-purple-400">
58
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Cost</div>
59
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{total_cost}</div>
60
+ <div class="text-xs text-gray-400 mt-1">Arbitrary units representing estimated I/O and CPU work. Lower is better.</div>
61
+ </div>
62
+ HTML
63
+ end
64
+
65
+ if actual_rows
66
+ summary_html += <<~HTML
67
+ <div class="bg-white p-4 rounded-md border-l-4 border-yellow-400">
68
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Rows Returned</div>
69
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{number_with_delimiter(actual_rows)}</div>
70
+ <div class="text-xs text-gray-400 mt-1">Number of rows the query actually produced.</div>
71
+ </div>
72
+ HTML
73
+ end
74
+
75
+ # Add index usage summary
76
+ summary_html += <<~HTML
77
+ <div class="bg-white p-4 rounded-md border-l-4 border-indigo-400">
78
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Index Usage</div>
79
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{index_analysis[:index_scans]} / #{index_analysis[:total_scans]} scans</div>
80
+ <div class="text-xs text-gray-400 mt-1">How many data lookups used an index vs scanning the whole table. Higher is better.</div>
81
+ </div>
82
+ HTML
83
+
84
+ # Add cache hit ratio if we have buffer stats
85
+ if buffer_stats && buffer_stats[:total_blocks] > 0
86
+ hit_ratio = ((buffer_stats[:hit_blocks].to_f / buffer_stats[:total_blocks]) * 100).round(1)
87
+ hit_color = hit_ratio >= 99 ? "border-green-400" : (hit_ratio >= 90 ? "border-yellow-400" : "border-red-400")
88
+ summary_html += <<~HTML
89
+ <div class="bg-white p-4 rounded-md border-l-4 #{hit_color}">
90
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Cache Hit Ratio</div>
91
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{hit_ratio}%</div>
92
+ <div class="text-xs text-gray-400 mt-1">Percentage of data pages found in memory. Below 99% may indicate insufficient shared_buffers.</div>
93
+ </div>
94
+ HTML
95
+ end
96
+
97
+ summary_html += "</div>"
98
+
99
+ # Index analysis section
100
+ if index_analysis[:warnings].any? || index_analysis[:indexes_used].any?
101
+ summary_html += <<~HTML
102
+ <div class="mt-6">
103
+ <h4 class="text-md font-medium text-gray-900 mb-3">Index Analysis</h4>
104
+ HTML
105
+
106
+ if index_analysis[:indexes_used].any?
107
+ summary_html += <<~HTML
108
+ <div class="bg-green-50 border border-green-200 rounded-md p-3 mb-3">
109
+ <div class="flex">
110
+ <div class="flex-shrink-0">
111
+ <svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
112
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
113
+ </svg>
114
+ </div>
115
+ <div class="ml-3">
116
+ <p class="text-sm font-medium text-green-800">
117
+ <strong>Indexes Used:</strong> #{ERB::Util.html_escape(index_analysis[:indexes_used].join(", "))}
118
+ </p>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ HTML
123
+ end
124
+
125
+ if index_analysis[:warnings].any?
126
+ index_analysis[:warnings].each do |warning|
127
+ summary_html += <<~HTML
128
+ <div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 mb-2">
129
+ <div class="flex">
130
+ <div class="flex-shrink-0">
131
+ <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
132
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
133
+ </svg>
134
+ </div>
135
+ <div class="ml-3">
136
+ <p class="text-sm text-yellow-700">#{ERB::Util.html_escape(warning)}</p>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ HTML
141
+ end
142
+ end
143
+
144
+ summary_html += "</div>"
145
+ end
146
+
147
+ if hotspots && hotspots.any?
148
+ summary_html += <<~HTML
149
+ <div class="mt-6">
150
+ <h4 class="text-md font-medium text-gray-900 mb-3">Performance Hotspots</h4>
151
+ HTML
152
+
153
+ hotspots.first(3).each do |hotspot|
154
+ summary_html += <<~HTML
155
+ <div class="bg-red-50 border border-red-200 rounded-md p-3 mb-2">
156
+ <p class="text-sm font-mono text-red-700">#{ERB::Util.html_escape(hotspot)}</p>
157
+ </div>
158
+ HTML
159
+ end
160
+
161
+ summary_html += "</div>"
162
+ end
163
+
164
+ # Recommendations section
165
+ if recommendations.any?
166
+ summary_html += <<~HTML
167
+ <div class="mt-6">
168
+ <h4 class="text-md font-medium text-gray-900 mb-3">💡 Recommendations</h4>
169
+ HTML
170
+
171
+ recommendations.each do |rec|
172
+ icon = case rec[:severity]
173
+ when :critical then "🔴"
174
+ when :warning then "🟡"
175
+ when :info then "🔵"
176
+ else "💡"
177
+ end
178
+
179
+ border_class = case rec[:severity]
180
+ when :critical then "border-red-300 bg-red-50"
181
+ when :warning then "border-yellow-300 bg-yellow-50"
182
+ when :info then "border-blue-300 bg-blue-50"
183
+ else "border-gray-300 bg-gray-50"
184
+ end
185
+
186
+ text_class = case rec[:severity]
187
+ when :critical then "text-red-800"
188
+ when :warning then "text-yellow-800"
189
+ when :info then "text-blue-800"
190
+ else "text-gray-800"
191
+ end
192
+
193
+ summary_html += <<~HTML
194
+ <div class="border #{border_class} rounded-md p-4 mb-3">
195
+ <div class="flex items-start">
196
+ <span class="text-lg mr-3 flex-shrink-0">#{icon}</span>
197
+ <div class="flex-1">
198
+ <p class="text-sm font-semibold #{text_class}">#{ERB::Util.html_escape(rec[:title])}</p>
199
+ <p class="text-sm #{text_class} mt-1">#{ERB::Util.html_escape(rec[:description])}</p>
200
+ HTML
201
+
202
+ if rec[:action]
203
+ summary_html += <<~HTML
204
+ <div class="mt-2 bg-white bg-opacity-60 rounded p-2">
205
+ <p class="text-xs font-medium text-gray-600">Suggested action:</p>
206
+ <code class="text-xs font-mono text-gray-800 break-all">#{ERB::Util.html_escape(rec[:action])}</code>
207
+ </div>
208
+ HTML
209
+ end
210
+
211
+ summary_html += <<~HTML
212
+ </div>
213
+ </div>
214
+ </div>
215
+ HTML
216
+ end
217
+
218
+ summary_html += "</div>"
219
+ end
220
+
221
+ summary_html += "</div>"
222
+ summary_html
223
+ end
224
+
225
+ def render_tree
226
+ return "" unless @plan && @plan.is_a?(Array) && @plan.first
227
+
228
+ root_plan = @plan.first["Plan"]
229
+ tree_html = '<div class="font-mono text-sm space-y-2">'
230
+ tree_html += render_node(root_plan, 0)
231
+ tree_html += "</div>"
232
+ tree_html
233
+ end
234
+
235
+ private
236
+
237
+ def render_node(node, depth)
238
+ node_id = "node_#{SecureRandom.hex(6)}"
239
+ warnings = detect_warnings(node)
240
+
241
+ html = <<~HTML
242
+ <div class="border border-gray-200 rounded-lg bg-white shadow-sm">
243
+ <div class="p-3 cursor-pointer select-none relative bg-gray-50 border-b border-gray-200 rounded-t-lg hover:bg-gray-100" onclick="toggleNode('#{node_id}')">
244
+ HTML
245
+
246
+ if has_children?(node)
247
+ html += '<button class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 border-none bg-blue-600 text-white rounded-sm text-xs font-bold cursor-pointer hover:bg-blue-700">−</button>'
248
+ end
249
+
250
+ html += '<div class="'
251
+ html += has_children?(node) ? "ml-8" : "ml-3"
252
+ html += ' flex flex-wrap items-center gap-2">'
253
+ html += render_node_title(node, warnings)
254
+ html += "</div></div>"
255
+
256
+ html += '<div class="plan-node-body" id="' + node_id + '">'
257
+ html += render_node_details(node)
258
+
259
+ if has_children?(node)
260
+ html += '<div class="pl-6 space-y-2">'
261
+ children = node["Plans"] || []
262
+ children.each do |child|
263
+ html += render_node(child, depth + 1)
264
+ end
265
+ html += "</div>"
266
+ end
267
+
268
+ html += "</div></div>"
269
+ html
270
+ end
271
+
272
+ def render_node_title(node, warnings)
273
+ node_type = node["Node Type"]
274
+ title_html = "<span class=\"font-bold text-gray-700"
275
+
276
+ # Add special styling for index operations
277
+ if node_type.include?("Index") || node_type == "Bitmap Index Scan"
278
+ title_html += " text-green-700 bg-green-100 px-2 py-1 rounded"
279
+ elsif node_type == "Seq Scan"
280
+ title_html += " text-red-700 bg-red-100 px-2 py-1 rounded"
281
+ end
282
+
283
+ title_html += "\">#{ERB::Util.html_escape(node_type)}</span>"
284
+
285
+ # Add relation name
286
+ if node["Relation Name"]
287
+ relation_name = node["Relation Name"]
288
+ relation_name += ".#{node["Schema"]}" if node["Schema"] && node["Schema"] != "public"
289
+ title_html += " <span class=\"text-blue-600 font-semibold\">#{ERB::Util.html_escape(relation_name)}</span>"
290
+ end
291
+
292
+ # Add index name with emphasis
293
+ if node["Index Name"]
294
+ title_html += " <span class=\"text-green-700 font-bold bg-green-100 px-2 py-1 rounded inline-flex items-center\">📊 #{ERB::Util.html_escape(node["Index Name"])}</span>"
295
+ end
296
+
297
+ # Add timing info for ANALYZE
298
+ if @analyze && node["Actual Total Time"]
299
+ time_class = node["Actual Total Time"] > 100 ? "text-red-600 bg-red-100" : "text-green-600 bg-green-100"
300
+ title_html += " <span class=\"#{time_class} px-2 py-1 rounded font-semibold\">#{node["Actual Total Time"]}ms</span>"
301
+ end
302
+
303
+ # Add row count info with better ratio analysis
304
+ if @analyze && node["Actual Rows"] && node["Plan Rows"]
305
+ actual = node["Actual Rows"]
306
+ estimated = node["Plan Rows"]
307
+ abs_diff = (actual - estimated).abs
308
+ ratio = estimated > 0 ? (actual.to_f / estimated).round(2) : (actual == 0 ? 1.0 : "∞")
309
+
310
+ # Only flag as problematic when the absolute difference is large enough
311
+ # to actually affect plan choice. Small diffs (e.g. 0 vs 1) are harmless.
312
+ if abs_diff <= 100
313
+ # Small absolute difference — never a real problem
314
+ ratio_class = "text-blue-600 bg-blue-100"
315
+ elsif ratio.is_a?(Numeric)
316
+ if ratio < 0.1 || ratio > 10
317
+ # 10x+ mismatch with >100 row diff — likely a bad plan
318
+ ratio_class = "text-red-600 bg-red-100 font-bold"
319
+ elsif ratio < 0.5 || ratio > 2
320
+ # 2x–10x mismatch — planner estimate is noticeably off
321
+ ratio_class = "text-orange-600 bg-orange-100"
322
+ else
323
+ # Within 2x — estimate is fine
324
+ ratio_class = "text-blue-600 bg-blue-100"
325
+ end
326
+ else
327
+ ratio_class = "text-red-600 bg-red-100 font-bold"
328
+ end
329
+
330
+ title_html += " <span class=\"#{ratio_class} px-2 py-1 rounded\">#{number_with_delimiter(actual)} rows"
331
+ if ratio_class.include?("red")
332
+ title_html += " ⚠️ (est: #{number_with_delimiter(estimated)})"
333
+ elsif ratio_class.include?("orange")
334
+ title_html += " ⚠ (est: #{number_with_delimiter(estimated)})"
335
+ else
336
+ title_html += " (est: #{number_with_delimiter(estimated)})"
337
+ end
338
+ title_html += "</span>"
339
+ elsif node["Plan Rows"]
340
+ title_html += " <span class=\"text-blue-600 bg-blue-100 px-2 py-1 rounded\">#{number_with_delimiter(node["Plan Rows"])} rows</span>"
341
+ end
342
+
343
+ # Add warning badges
344
+ warnings.each do |warning|
345
+ case warning[:type]
346
+ when "seq-scan"
347
+ badge_class = "bg-red-100 text-red-800"
348
+ when "sort"
349
+ badge_class = "bg-yellow-100 text-yellow-800"
350
+ when "nested-loop"
351
+ badge_class = "bg-green-100 text-green-800"
352
+ when "fanout"
353
+ badge_class = "bg-blue-100 text-blue-800"
354
+ else
355
+ badge_class = "bg-gray-100 text-gray-800"
356
+ end
357
+
358
+ title_html += " <span class=\"inline-flex items-center px-2 py-1 rounded text-xs font-medium #{badge_class}\">#{ERB::Util.html_escape(warning[:text])}</span>"
359
+ end
360
+
361
+ title_html
362
+ end
363
+
364
+ def render_node_details(node)
365
+ details_html = '<div class="p-4 bg-gray-50 border-t border-gray-200"><div class="grid grid-cols-1 md:grid-cols-2 gap-4">'
366
+
367
+ # Show key plan details
368
+ details = []
369
+
370
+ if node["Startup Cost"] && node["Total Cost"]
371
+ details << [ "Cost", "#{node["Startup Cost"]}..#{node["Total Cost"]}", "Startup cost (before first row) to total cost (all rows). Arbitrary units — compare relative to other nodes." ]
372
+ end
373
+
374
+ if @analyze
375
+ if node["Actual Startup Time"] && node["Actual Total Time"]
376
+ details << [ "Actual Time", "#{node["Actual Startup Time"]}..#{node["Actual Total Time"]} ms", "Real time: from start until all rows returned for this node." ]
377
+ end
378
+
379
+ if node["Actual Loops"] && node["Actual Loops"] > 1
380
+ details << [ "Loops", node["Actual Loops"].to_s, "Number of times this operation was repeated (e.g. once per row from a parent join)." ]
381
+ end
382
+
383
+ if node["Shared Hit Blocks"]
384
+ buffers = []
385
+ buffers << "hit=#{node["Shared Hit Blocks"]}" if node["Shared Hit Blocks"] > 0
386
+ buffers << "read=#{node["Shared Read Blocks"]}" if node["Shared Read Blocks"] && node["Shared Read Blocks"] > 0
387
+ buffers << "written=#{node["Shared Written Blocks"]}" if node["Shared Written Blocks"] && node["Shared Written Blocks"] > 0
388
+ details << [ "Buffers", buffers.join(", "), "Shared memory pages accessed. 'hit' = cached in RAM, 'read' = fetched from disk." ] if buffers.any?
389
+ end
390
+ end
391
+
392
+ # Index-specific details
393
+ if node["Index Cond"]
394
+ details << [ "Index Condition", node["Index Cond"], "The WHERE clause condition evaluated using the index for fast lookup." ]
395
+ end
396
+
397
+ if node["Recheck Cond"]
398
+ details << [ "Recheck Condition", node["Recheck Cond"], "Condition re-verified against actual rows after a bitmap scan." ]
399
+ end
400
+
401
+ if node["Filter"]
402
+ details << [ "Filter", node["Filter"], "Rows matching the scan are then filtered by this condition. Rows that don't match are discarded." ]
403
+ end
404
+
405
+ if node["Rows Removed by Filter"] && @analyze
406
+ removed = node["Rows Removed by Filter"]
407
+ actual = node["Actual Rows"] || 0
408
+ total = actual + removed
409
+ if removed > 0
410
+ efficiency = total > 0 ? ((actual.to_f / total) * 100).round(1) : 0
411
+ details << [ "Filter Efficiency", "#{efficiency}% (#{number_with_delimiter(removed)} rows filtered out)", "Percentage of scanned rows that matched the filter. Low efficiency may indicate a missing or suboptimal index." ]
412
+ end
413
+ end
414
+
415
+ if node["Join Type"]
416
+ details << [ "Join Type", node["Join Type"], "How two result sets are combined (e.g. Hash, Nested Loop, Merge)." ]
417
+ end
418
+
419
+ if node["Hash Cond"]
420
+ details << [ "Hash Condition", node["Hash Cond"], "The equality condition used to match rows in a hash join." ]
421
+ end
422
+
423
+ if node["Sort Key"]
424
+ sort_keys = node["Sort Key"].is_a?(Array) ? node["Sort Key"].join(", ") : node["Sort Key"]
425
+ details << [ "Sort Key", sort_keys, "Column(s) used to order the result set." ]
426
+ end
427
+
428
+ if node["Sort Method"] && @analyze
429
+ sort_info = node["Sort Method"]
430
+ if node["Sort Space Used"]
431
+ sort_info += " (#{node["Sort Space Used"]}kB"
432
+ sort_info += node["Sort Space Type"] == "Disk" ? " on disk" : " in memory"
433
+ sort_info += ")"
434
+ end
435
+ details << [ "Sort Method", sort_info, "Algorithm used for sorting. In-memory is fast; disk-based sorting indicates insufficient work_mem." ]
436
+ end
437
+
438
+ # Split details into two columns
439
+ left_details = details.first((details.length + 1) / 2)
440
+ right_details = details.drop(left_details.length)
441
+
442
+ [ left_details, right_details ].each do |column_details|
443
+ details_html += '<div class="space-y-2">'
444
+ column_details.each do |label, value, explanation|
445
+ details_html += <<~HTML
446
+ <div class="text-xs">
447
+ <dt class="font-medium text-gray-600">#{ERB::Util.html_escape(label)}:</dt>
448
+ <dd class="mt-1 text-gray-900 font-mono break-words">#{ERB::Util.html_escape(value)}</dd>
449
+ HTML
450
+ if explanation
451
+ details_html += <<~HTML
452
+ <dd class="mt-0.5 text-gray-400 font-sans italic">#{ERB::Util.html_escape(explanation)}</dd>
453
+ HTML
454
+ end
455
+ details_html += "</div>"
456
+ end
457
+ details_html += "</div>"
458
+ end
459
+
460
+ details_html += "</div></div>"
461
+ details_html
462
+ end
463
+
464
+ def detect_warnings(node)
465
+ warnings = []
466
+
467
+ # Enhanced Seq Scan warning with table name
468
+ if node["Node Type"] == "Seq Scan"
469
+ table_name = node["Relation Name"] || "table"
470
+ row_count = node["Plan Rows"] || 0
471
+
472
+ if row_count > 10000
473
+ warnings << { type: "seq-scan", text: "Large Seq Scan (#{number_with_delimiter(row_count)} rows on #{table_name})" }
474
+ elsif row_count > 1000
475
+ warnings << { type: "seq-scan", text: "Seq Scan (#{table_name})" }
476
+ end
477
+ end
478
+
479
+ # Bitmap Heap Scan warnings
480
+ if node["Node Type"] == "Bitmap Heap Scan" && node["Plan Rows"] && node["Plan Rows"] > 10000
481
+ warnings << { type: "sort", text: "Large Bitmap Scan" }
482
+ end
483
+
484
+ # Large sort warning
485
+ if node["Node Type"] == "Sort" && node["Plan Rows"] && node["Plan Rows"] > 10000
486
+ warnings << { type: "sort", text: "Large Sort (#{number_with_delimiter(node["Plan Rows"])} rows)" }
487
+ end
488
+
489
+ # Nested loop with large inner
490
+ if node["Node Type"] == "Nested Loop" && node["Plans"]
491
+ inner_rows = node["Plans"].map { |p| p["Plan Rows"] || 0 }.max
492
+ if inner_rows && inner_rows > 1000
493
+ warnings << { type: "nested-loop", text: "Large Nested Loop (#{number_with_delimiter(inner_rows)} inner rows)" }
494
+ end
495
+ end
496
+
497
+ # Row explosion (fanout)
498
+ if @analyze && node["Actual Rows"] && node["Plan Rows"]
499
+ actual = node["Actual Rows"]
500
+ estimated = node["Plan Rows"]
501
+ if estimated > 0 && actual > estimated * 10
502
+ ratio = (actual.to_f / estimated).round(1)
503
+ warnings << { type: "fanout", text: "Row Explosion (#{ratio}x estimate)" }
504
+ end
505
+ end
506
+
507
+ # Index usage analysis
508
+ if node["Node Type"].include?("Index") && node["Index Name"]
509
+ # This is good - using an index
510
+ # Could add positive feedback here in the future
511
+ elsif node["Node Type"] == "Seq Scan" && node["Filter"]
512
+ # Sequential scan with filter suggests missing index opportunity
513
+ warnings << { type: "seq-scan", text: "Filtered Seq Scan (missing index?)" }
514
+ end
515
+
516
+ warnings
517
+ end
518
+
519
+ def has_children?(node)
520
+ node["Plans"] && node["Plans"].any?
521
+ end
522
+
523
+ def find_hotspots(plan_node, hotspots = [])
524
+ return hotspots unless @analyze
525
+
526
+ # Add this node if it's slow
527
+ if plan_node["Actual Total Time"] && plan_node["Actual Total Time"] > 10
528
+ node_desc = "#{plan_node["Node Type"]}"
529
+ node_desc += " on #{plan_node["Relation Name"]}" if plan_node["Relation Name"]
530
+ node_desc += " (#{plan_node["Actual Total Time"]}ms)"
531
+ hotspots << node_desc
532
+ end
533
+
534
+ # Recurse into children
535
+ if plan_node["Plans"]
536
+ plan_node["Plans"].each do |child|
537
+ find_hotspots(child, hotspots)
538
+ end
539
+ end
540
+
541
+ # Sort by time descending
542
+ hotspots.sort_by! do |desc|
543
+ match = desc.match(/\((\d+\.?\d*)ms\)/)
544
+ match ? -match[1].to_f : 0
545
+ end
546
+
547
+ hotspots
548
+ end
549
+
550
+ def number_with_delimiter(number)
551
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
552
+ end
553
+
554
+ def analyze_index_usage(plan_node, analysis = { index_scans: 0, total_scans: 0, indexes_used: [], warnings: [], seq_scans: [] })
555
+ node_type = plan_node["Node Type"]
556
+
557
+ # Count scan operations
558
+ if node_type.include?("Scan") || node_type.include?("Seek")
559
+ analysis[:total_scans] += 1
560
+
561
+ if node_type.include?("Index") || node_type == "Bitmap Index Scan"
562
+ analysis[:index_scans] += 1
563
+
564
+ # Track which indexes are being used
565
+ if plan_node["Index Name"]
566
+ index_name = plan_node["Index Name"]
567
+ relation = plan_node["Relation Name"]
568
+ full_name = relation ? "#{relation}.#{index_name}" : index_name
569
+ analysis[:indexes_used] << full_name unless analysis[:indexes_used].include?(full_name)
570
+ end
571
+ elsif node_type == "Seq Scan"
572
+ table_name = plan_node["Relation Name"] || "unknown table"
573
+ row_count = plan_node["Plan Rows"] || 0
574
+
575
+ # Collect columns from filter conditions for index suggestions
576
+ filter_cols = []
577
+ filter_cols += extract_columns_from_condition(plan_node["Filter"]) if plan_node["Filter"]
578
+
579
+ analysis[:seq_scans] << {
580
+ table: table_name,
581
+ rows: row_count,
582
+ filter: plan_node["Filter"],
583
+ columns: filter_cols
584
+ }
585
+
586
+ if row_count > 10000
587
+ analysis[:warnings] << "Large sequential scan on #{table_name} (#{number_with_delimiter(row_count)} rows)"
588
+ elsif row_count > 1000 && plan_node["Filter"]
589
+ analysis[:warnings] << "Sequential scan with filter on #{table_name} - consider adding index"
590
+ end
591
+ end
592
+ end
593
+
594
+ # Recurse into child nodes
595
+ if plan_node["Plans"]
596
+ plan_node["Plans"].each do |child|
597
+ analyze_index_usage(child, analysis)
598
+ end
599
+ end
600
+
601
+ analysis
602
+ end
603
+
604
+ def collect_buffer_stats(node, stats = { hit_blocks: 0, read_blocks: 0, written_blocks: 0, total_blocks: 0 })
605
+ if node["Shared Hit Blocks"]
606
+ stats[:hit_blocks] += node["Shared Hit Blocks"].to_i
607
+ stats[:total_blocks] += node["Shared Hit Blocks"].to_i
608
+ end
609
+ if node["Shared Read Blocks"]
610
+ stats[:read_blocks] += node["Shared Read Blocks"].to_i
611
+ stats[:total_blocks] += node["Shared Read Blocks"].to_i
612
+ end
613
+ if node["Shared Written Blocks"]
614
+ stats[:written_blocks] += node["Shared Written Blocks"].to_i
615
+ end
616
+
617
+ if node["Plans"]
618
+ node["Plans"].each { |child| collect_buffer_stats(child, stats) }
619
+ end
620
+
621
+ stats
622
+ end
623
+
624
+ def generate_recommendations(root_plan, index_analysis, buffer_stats)
625
+ recs = []
626
+ plan = root_plan["Plan"]
627
+ execution_time = root_plan["Execution Time"]
628
+ planning_time = root_plan["Planning Time"]
629
+
630
+ # Walk the entire plan tree collecting issues
631
+ walk_plan_for_recommendations(plan, recs)
632
+
633
+ # Planning time vs execution time
634
+ if planning_time && execution_time && planning_time > 0 && execution_time > 0
635
+ if planning_time > execution_time * 2 && planning_time > 5
636
+ recs << {
637
+ severity: :info,
638
+ title: "Planning time exceeds execution time",
639
+ description: "The query planner spent #{planning_time}ms planning but only #{execution_time}ms executing. For frequently-run queries this overhead adds up.",
640
+ action: "Consider using prepared statements to skip repeated planning: connection.prepare('my_query', sql)"
641
+ }
642
+ end
643
+ end
644
+
645
+ # Cache hit ratio
646
+ if buffer_stats && buffer_stats[:total_blocks] > 0
647
+ hit_ratio = (buffer_stats[:hit_blocks].to_f / buffer_stats[:total_blocks]) * 100
648
+ if hit_ratio < 90 && buffer_stats[:read_blocks] > 10
649
+ recs << {
650
+ severity: :warning,
651
+ title: "Low cache hit ratio (#{hit_ratio.round(1)}%)",
652
+ description: "#{buffer_stats[:read_blocks]} pages were read from disk instead of cache. This slows queries significantly, especially under load.",
653
+ action: "Increase shared_buffers in postgresql.conf, or run the query again (it may now be cached)."
654
+ }
655
+ end
656
+ end
657
+
658
+ # Sequential scan warnings with specific index suggestions
659
+ if index_analysis[:total_scans] > 0 && index_analysis[:index_scans] == 0
660
+ seq_scans = index_analysis[:seq_scans] || []
661
+ index_suggestions = seq_scans.select { |s| s[:columns].any? }.map do |scan|
662
+ cols = scan[:columns].join(", ")
663
+ "CREATE INDEX idx_#{scan[:table]}_on_#{scan[:columns].first} ON #{scan[:table]} (#{cols});"
664
+ end
665
+
666
+ if index_suggestions.any?
667
+ recs << {
668
+ severity: :warning,
669
+ title: "No indexes used",
670
+ description: "All #{index_analysis[:total_scans]} scan(s) in this query are sequential scans. This means PostgreSQL is reading entire tables to find matching rows.",
671
+ action: index_suggestions.join("\n")
672
+ }
673
+ else
674
+ # Couldn't extract columns — list the tables at least
675
+ tables = seq_scans.map { |s| s[:table] }.uniq
676
+ recs << {
677
+ severity: :warning,
678
+ title: "No indexes used",
679
+ description: "All #{index_analysis[:total_scans]} scan(s) in this query are sequential scans on #{tables.join(', ')}. This means PostgreSQL is reading entire tables to find matching rows.",
680
+ action: "Add indexes on the columns used in WHERE, JOIN, and ORDER BY clauses for: #{tables.join(', ')}"
681
+ }
682
+ end
683
+ end
684
+
685
+ # Slow query overall
686
+ if execution_time && execution_time > 1000
687
+ recs << {
688
+ severity: :critical,
689
+ title: "Slow query (#{execution_time}ms)",
690
+ description: "This query took over 1 second to execute. Users will notice this delay. Consider optimizing the query or adding caching.",
691
+ action: nil
692
+ }
693
+ elsif execution_time && execution_time > 100
694
+ recs << {
695
+ severity: :warning,
696
+ title: "Moderately slow query (#{execution_time}ms)",
697
+ description: "This query took over 100ms. It's acceptable for background jobs but may be too slow for web requests where < 50ms is ideal.",
698
+ action: nil
699
+ }
700
+ end
701
+
702
+ recs
703
+ end
704
+
705
+ def walk_plan_for_recommendations(node, recs)
706
+ node_type = node["Node Type"]
707
+
708
+ # Seq Scan with filter on a table
709
+ if node_type == "Seq Scan" && node["Filter"]
710
+ table = node["Relation Name"] || "table"
711
+ rows = @analyze ? (node["Actual Rows"] || node["Plan Rows"] || 0) : (node["Plan Rows"] || 0)
712
+ removed = node["Rows Removed by Filter"] || 0
713
+
714
+ if rows + removed > 1000
715
+ filter_cols = extract_columns_from_condition(node["Filter"])
716
+ col_suggestion = filter_cols.any? ? filter_cols.join(", ") : "the filtered column(s)"
717
+
718
+ recs << {
719
+ severity: :critical,
720
+ title: "Sequential scan with filter on '#{table}'",
721
+ description: "PostgreSQL scanned #{number_with_delimiter(rows + removed)} rows but only kept #{number_with_delimiter(rows)} (#{removed > 0 ? ((rows.to_f / (rows + removed)) * 100).round(1) : 100}% selectivity). A targeted index would avoid scanning irrelevant rows.",
722
+ action: "CREATE INDEX idx_#{table}_on_#{filter_cols.first || 'column'} ON #{table} (#{col_suggestion});"
723
+ }
724
+ end
725
+ end
726
+
727
+ # Disk-based sort
728
+ if node_type == "Sort" && @analyze && node["Sort Space Type"] == "Disk"
729
+ space = node["Sort Space Used"] || 0
730
+ recs << {
731
+ severity: :warning,
732
+ title: "Sort spilled to disk (#{space}kB)",
733
+ description: "The sort couldn't fit in memory and used disk, which is much slower. This happens when work_mem is too small for the data being sorted.",
734
+ action: "SET work_mem = '#{[ (space * 2 / 1024.0).ceil, 4 ].max}MB'; -- or increase work_mem in postgresql.conf"
735
+ }
736
+ end
737
+
738
+ # Large in-memory sort
739
+ if node_type == "Sort" && @analyze && node["Sort Space Type"] == "Memory" && (node["Sort Space Used"] || 0) > 10000
740
+ recs << {
741
+ severity: :info,
742
+ title: "Large in-memory sort (#{node["Sort Space Used"]}kB)",
743
+ description: "The sort fits in memory but uses significant space. If this query runs concurrently, total memory usage could be high.",
744
+ action: nil
745
+ }
746
+ end
747
+
748
+ # Nested loop with many iterations
749
+ if node_type == "Nested Loop" && @analyze && node["Actual Loops"] && node["Actual Loops"] > 100
750
+ recs << {
751
+ severity: :warning,
752
+ title: "Nested loop with #{number_with_delimiter(node["Actual Loops"])} iterations",
753
+ description: "The inner side of this join is executed #{number_with_delimiter(node["Actual Loops"])} times. If the inner operation is not an index lookup, this can be extremely slow.",
754
+ action: "Consider restructuring the query to allow a hash join, or ensure the inner table has appropriate indexes."
755
+ }
756
+ end
757
+
758
+ # Large row estimate mismatch
759
+ if @analyze && node["Actual Rows"] && node["Plan Rows"]
760
+ actual = node["Actual Rows"]
761
+ estimated = node["Plan Rows"]
762
+ abs_diff = (actual - estimated).abs
763
+ ratio = estimated > 0 ? (actual.to_f / estimated) : 0
764
+
765
+ if abs_diff > 1000 && (ratio > 10 || ratio < 0.1)
766
+ table = node["Relation Name"] || "the involved table"
767
+ recs << {
768
+ severity: :warning,
769
+ title: "Row estimate off by #{ratio > 1 ? "#{ratio.round(0)}x" : "#{(1.0 / ratio).round(0)}x"} on #{node_type}",
770
+ description: "PostgreSQL estimated #{number_with_delimiter(estimated)} rows but got #{number_with_delimiter(actual)}. Bad estimates lead to suboptimal plan choices (wrong join type, wrong scan method).",
771
+ action: "ANALYZE #{table}; -- updates table statistics so the planner makes better estimates"
772
+ }
773
+ end
774
+ end
775
+
776
+ # Hash join with large buckets
777
+ if node_type == "Hash" && @analyze
778
+ if node["Peak Memory Usage"] && node["Peak Memory Usage"] > 100_000
779
+ recs << {
780
+ severity: :info,
781
+ title: "Large hash table (#{(node["Peak Memory Usage"] / 1024.0).round(1)}MB)",
782
+ description: "Building the hash table for this join used significant memory. Under concurrent load, this could cause memory pressure.",
783
+ action: "SET work_mem = '#{[ (node["Peak Memory Usage"] / 512.0).ceil, 4 ].max}MB'; -- ensure enough memory for the hash"
784
+ }
785
+ end
786
+ end
787
+
788
+ # Bitmap Heap Scan with many recheck rows
789
+ if node_type == "Bitmap Heap Scan" && @analyze && node["Rows Removed by Index Recheck"] && node["Rows Removed by Index Recheck"] > 1000
790
+ recs << {
791
+ severity: :info,
792
+ title: "Bitmap scan with heavy recheck",
793
+ description: "#{number_with_delimiter(node["Rows Removed by Index Recheck"])} rows were rechecked after the bitmap index scan. This happens when the bitmap becomes lossy (too many results).",
794
+ action: "Increase work_mem to keep more exact page references, or add a more selective index."
795
+ }
796
+ end
797
+
798
+ # Correlated subquery (SubPlan node)
799
+ if node_type == "SubPlan" || node_type.start_with?("SubPlan")
800
+ loops = node["Actual Loops"] || node["Plan Rows"] || 0
801
+ recs << {
802
+ severity: loops > 100 ? :critical : :warning,
803
+ title: "Correlated subquery detected",
804
+ description: "A subquery is being executed once per row from the outer query#{loops > 1 ? " (#{number_with_delimiter(loops)} times)" : ""}. This is one of the most common causes of slow queries.",
805
+ action: "Rewrite the correlated subquery as a JOIN or use a lateral join. Example: SELECT ... FROM outer_table LEFT JOIN (subquery) ON ... instead of SELECT ..., (SELECT ...) FROM outer_table"
806
+ }
807
+ end
808
+
809
+ # Also catch SubPlan via parent node's Plans having subplan-type entries
810
+ if node["Subplan Name"] || (node["Parent Relationship"] == "SubPlan")
811
+ loops = @analyze ? (node["Actual Loops"] || 0) : (node["Plan Rows"] || 0)
812
+ unless recs.any? { |r| r[:title] == "Correlated subquery detected" }
813
+ recs << {
814
+ severity: loops > 100 ? :critical : :warning,
815
+ title: "Correlated subquery detected",
816
+ description: "A subquery (#{node["Subplan Name"] || node["Node Type"]}) runs for each row of the outer query#{loops > 1 ? " (#{number_with_delimiter(loops)} executions)" : ""}. This pattern scales poorly with table size.",
817
+ action: "Rewrite as a JOIN: replace WHERE col IN (SELECT ...) with an INNER JOIN, or WHERE EXISTS (SELECT ...) with a semi-join."
818
+ }
819
+ end
820
+ end
821
+
822
+ # CTE materialization warning
823
+ if node_type == "CTE Scan"
824
+ cte_name = node["CTE Name"] || "the CTE"
825
+ recs << {
826
+ severity: :info,
827
+ title: "Materialized CTE: #{cte_name}",
828
+ description: "The CTE '#{cte_name}' is materialized into a temporary result set before being scanned. If the CTE result is large or the outer query filters most rows, this can be wasteful.",
829
+ action: "WITH #{cte_name} AS NOT MATERIALIZED (SELECT ...) -- allows PostgreSQL to inline the CTE and apply outer filters (requires PostgreSQL 12+)"
830
+ }
831
+ end
832
+
833
+ # Index Only Scan with high heap fetches
834
+ if node_type == "Index Only Scan" && @analyze && node["Heap Fetches"]
835
+ heap_fetches = node["Heap Fetches"]
836
+ actual_rows = node["Actual Rows"] || 1
837
+ if heap_fetches > 0 && actual_rows > 0
838
+ fetch_ratio = (heap_fetches.to_f / actual_rows * 100).round(1)
839
+ if fetch_ratio > 50
840
+ table = node["Relation Name"] || "the table"
841
+ recs << {
842
+ severity: fetch_ratio > 90 ? :warning : :info,
843
+ title: "Index Only Scan falling back to heap (#{fetch_ratio}%)",
844
+ description: "#{number_with_delimiter(heap_fetches)} of #{number_with_delimiter(actual_rows)} rows required a heap fetch because the visibility map is out of date. This negates most of the benefit of an index-only scan.",
845
+ action: "VACUUM #{table}; -- refreshes the visibility map so future index-only scans can skip heap fetches"
846
+ }
847
+ end
848
+ end
849
+ end
850
+
851
+ # Recurse
852
+ if node["Plans"]
853
+ node["Plans"].each { |child| walk_plan_for_recommendations(child, recs) }
854
+ end
855
+ end
856
+
857
+ def extract_columns_from_condition(condition)
858
+ return [] unless condition.is_a?(String)
859
+
860
+ # PostgreSQL EXPLAIN outputs conditions like:
861
+ # (status = 'active'::text)
862
+ # ((scheduled_at >= '2026-01-01'::timestamp) AND (scheduled_at <= '2026-12-31'::timestamp))
863
+ # (users.email = 'test@example.com'::text)
864
+ # ((role)::text = 'admin'::text)
865
+
866
+ columns = []
867
+
868
+ # Match table.column references (e.g., users.email)
869
+ columns += condition.scan(/(\w+)\.(\w+)/).map { |_table, col| col }
870
+
871
+ # Match standalone column in parens before operator: (column_name = ...) or (column_name >= ...)
872
+ columns += condition.scan(/\((\w+)\s*[=<>!]/).map(&:first)
873
+
874
+ # Match ((column)::type ...) cast pattern
875
+ columns += condition.scan(/\(\((\w+)\)::/).map(&:first)
876
+
877
+ # Match column BETWEEN, column IN, column IS patterns
878
+ columns += condition.scan(/\((\w+)\s+(?:BETWEEN|IN|IS)\b/i).map(&:first)
879
+
880
+ # Filter out common noise
881
+ noise = %w[true false null text integer bigint timestamp date boolean numeric float double].map(&:downcase)
882
+ columns = columns.uniq.reject { |c| noise.include?(c.downcase) || c.length < 2 }
883
+ columns.first(5)
884
+ end
885
+ end
886
+ end
887
+ end