pg_insights 0.3.1 → 0.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/pg_insights/application.js +91 -24
  3. data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
  4. data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
  5. data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
  6. data/app/assets/javascripts/pg_insights/results.js +231 -2
  7. data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
  8. data/app/assets/stylesheets/pg_insights/application.css +51 -1
  9. data/app/assets/stylesheets/pg_insights/results.css +12 -1
  10. data/app/controllers/pg_insights/insights_controller.rb +486 -9
  11. data/app/helpers/pg_insights/application_helper.rb +339 -0
  12. data/app/helpers/pg_insights/insights_helper.rb +567 -0
  13. data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
  14. data/app/models/pg_insights/query_execution.rb +198 -0
  15. data/app/services/pg_insights/query_analysis_service.rb +269 -0
  16. data/app/views/layouts/pg_insights/application.html.erb +8 -1
  17. data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
  18. data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
  19. data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
  20. data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
  21. data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
  22. data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
  23. data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
  24. data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
  25. data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
  26. data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
  27. data/app/views/pg_insights/insights/_result.html.erb +19 -4
  28. data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
  29. data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
  30. data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
  31. data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
  32. data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
  33. data/app/views/pg_insights/insights/index.html.erb +4 -1
  34. data/app/views/pg_insights/timeline/compare.html.erb +3 -3
  35. data/config/routes.rb +6 -0
  36. data/lib/generators/pg_insights/install_generator.rb +20 -14
  37. data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
  38. data/lib/pg_insights/version.rb +1 -1
  39. data/lib/pg_insights.rb +30 -2
  40. metadata +20 -2
@@ -383,6 +383,7 @@
383
383
  width: 100%;
384
384
  height: 100%;
385
385
  min-height: 200px;
386
+ max-height: 400px;
386
387
  padding: 12px;
387
388
  border: 2px solid #e5e7eb;
388
389
  border-radius: 6px;
@@ -392,6 +393,7 @@
392
393
  background: #fafbfc;
393
394
  color: #1f2937;
394
395
  resize: none;
396
+ overflow-y: auto;
395
397
  transition: border-color 0.2s ease;
396
398
 
397
399
  &:focus {
@@ -405,6 +407,24 @@
405
407
  color: #9ca3af;
406
408
  opacity: 0.8;
407
409
  }
410
+
411
+ &::-webkit-scrollbar {
412
+ width: 8px;
413
+ }
414
+
415
+ &::-webkit-scrollbar-track {
416
+ background: #f1f5f9;
417
+ border-radius: 4px;
418
+ }
419
+
420
+ &::-webkit-scrollbar-thumb {
421
+ background: #cbd5e1;
422
+ border-radius: 4px;
423
+
424
+ &:hover {
425
+ background: #94a3b8;
426
+ }
427
+ }
408
428
  }
409
429
 
410
430
  .form-actions {
@@ -460,6 +480,31 @@
460
480
  transform: translateY(-1px);
461
481
  }
462
482
  }
483
+
484
+ &.btn-info {
485
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
486
+ color: white;
487
+
488
+ &:hover {
489
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
490
+ transform: translateY(-1px);
491
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
492
+ }
493
+
494
+ &:disabled {
495
+ background: #9ca3af !important;
496
+ cursor: not-allowed !important;
497
+ transform: none !important;
498
+ box-shadow: none !important;
499
+ opacity: 0.6;
500
+
501
+ &:hover {
502
+ background: #9ca3af !important;
503
+ transform: none !important;
504
+ box-shadow: none !important;
505
+ }
506
+ }
507
+ }
463
508
  }
464
509
 
465
510
  /* Query Examples Section */
@@ -723,7 +768,8 @@
723
768
  }
724
769
 
725
770
  .sql-editor {
726
- font-size: 14px; /* Prevent zoom on iOS */
771
+ font-size: 14px;
772
+ max-height: 400px;
727
773
  }
728
774
 
729
775
  .form-actions {
@@ -747,4 +793,8 @@
747
793
  .results-panel {
748
794
  height: 30%;
749
795
  }
796
+
797
+ .sql-editor {
798
+ max-height: 400px;
799
+ }
750
800
  }
@@ -50,11 +50,22 @@
50
50
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
51
51
  }
52
52
 
53
- .toggle-btn:hover:not(.active) {
53
+ .toggle-btn:hover:not(.active):not(.disabled) {
54
54
  background: rgba(255,255,255,0.5);
55
55
  color: #475569;
56
56
  }
57
57
 
58
+ .toggle-btn.disabled {
59
+ opacity: 0.6;
60
+ cursor: help;
61
+ pointer-events: auto;
62
+ }
63
+
64
+ .toggle-btn.disabled:hover {
65
+ background: transparent;
66
+ color: #64748b;
67
+ }
68
+
58
69
  .toggle-icon {
59
70
  font-size: 14px;
60
71
  }
@@ -6,7 +6,6 @@ module PgInsights
6
6
  protect_from_forgery with: :exception
7
7
 
8
8
  MAX_ROWS = 1_000
9
- TIMEOUT = 5_000
10
9
 
11
10
  # GET /pg_insights
12
11
  # POST /pg_insights
@@ -27,6 +26,9 @@ module PgInsights
27
26
  @insight_queries = built_in_queries + saved_queries
28
27
 
29
28
  return unless request.post?
29
+
30
+ # Determine execution type from button clicked
31
+ execution_type = determine_execution_type
30
32
  sql = params.require(:sql)
31
33
 
32
34
  unless read_only?(sql)
@@ -34,19 +36,57 @@ module PgInsights
34
36
  return render :index, status: :unprocessable_entity
35
37
  end
36
38
 
37
- sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
39
+ # Handle different execution types
40
+ case execution_type
41
+ when "execute"
42
+ handle_execute_only(sql)
43
+ when "analyze"
44
+ handle_analyze_only(sql)
45
+ when "both"
46
+ handle_execute_and_analyze(sql)
47
+ else
48
+ # Fallback to original behavior for backward compatibility
49
+ handle_execute_only(sql)
50
+ end
51
+
52
+ render :index
53
+ end
54
+
55
+ # POST /pg_insights/analyze
56
+ def analyze
57
+ sql = params.require(:sql)
58
+ execution_type = params.fetch(:execution_type, "analyze")
59
+ async = params.fetch(:async, false)
60
+
61
+ unless read_only?(sql)
62
+ render json: { error: "Only single SELECT statements are allowed" }, status: :unprocessable_entity
63
+ return
64
+ end
38
65
 
39
66
  begin
40
- ActiveRecord::Base.connection.transaction do
41
- ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{TIMEOUT}")
42
- @result = ActiveRecord::Base.connection.exec_query(sql)
67
+ if async.to_s == "true"
68
+ execution = QueryAnalysisService.analyze_query_async(sql, execution_type: execution_type)
69
+ render json: {
70
+ execution_id: execution.id,
71
+ status: execution.status,
72
+ message: "Analysis started"
73
+ }
74
+ else
75
+ execution = QueryAnalysisService.execute_query(sql, execution_type: execution_type)
76
+ render json: format_execution_response(execution)
43
77
  end
44
- rescue ActiveRecord::StatementInvalid, PG::Error => e
45
- flash.now[:alert] = "Query Error: #{e.message}"
46
- return render :index, status: :unprocessable_entity
78
+ rescue => e
79
+ Rails.logger.error "Analysis failed: #{e.message}"
80
+ render json: { error: e.message }, status: :internal_server_error
47
81
  end
82
+ end
48
83
 
49
- render :index
84
+ # GET /pg_insights/execution/:id
85
+ def execution_status
86
+ execution = QueryExecution.find(params[:id])
87
+ render json: format_execution_response(execution)
88
+ rescue ActiveRecord::RecordNotFound
89
+ render json: { error: "Execution not found" }, status: :not_found
50
90
  end
51
91
 
52
92
  # GET /pg_insights/table_names
@@ -60,8 +100,171 @@ module PgInsights
60
100
  render json: { tables: [] }
61
101
  end
62
102
 
103
+ def query_history
104
+ @executions = QueryExecution.recent_history(10)
105
+
106
+ executions_data = @executions.map do |execution|
107
+ {
108
+ id: execution.id,
109
+ title: execution.display_title,
110
+ summary: execution.display_summary,
111
+ performance_class: execution.performance_class,
112
+ created_at: execution.created_at.strftime("%m/%d %H:%M"),
113
+ total_time_ms: execution.total_time_ms,
114
+ query_cost: execution.query_cost,
115
+ sql_text: execution.sql_text
116
+ }
117
+ end
118
+
119
+ respond_to do |format|
120
+ format.json { render json: executions_data }
121
+ format.html { redirect_to root_path }
122
+ end
123
+ end
124
+
125
+ def compare
126
+ # Handle nested parameters from form submission
127
+ execution_ids = params[:execution_ids] || params.dig(:insight, :execution_ids)
128
+
129
+ if execution_ids.blank? || execution_ids.size != 2
130
+ error_response = { error: "Please select exactly 2 queries to compare" }
131
+ respond_to do |format|
132
+ format.json { render json: error_response, status: :bad_request }
133
+ format.html { redirect_to root_path, alert: error_response[:error] }
134
+ end
135
+ return
136
+ end
137
+
138
+ begin
139
+ @execution_a = QueryExecution.find(execution_ids[0])
140
+ @execution_b = QueryExecution.find(execution_ids[1])
141
+
142
+ # Performance logging
143
+ Rails.logger.info "PgInsights: Comparing query executions #{@execution_a.id} vs #{@execution_b.id}"
144
+
145
+ start_time = Time.current
146
+ @comparison_data = generate_comparison_data(@execution_a, @execution_b)
147
+ comparison_duration = ((Time.current - start_time) * 1000).round(2)
148
+
149
+ Rails.logger.info "PgInsights: Comparison completed in #{comparison_duration}ms"
150
+
151
+ respond_to do |format|
152
+ format.json { render json: @comparison_data }
153
+ format.html { redirect_to root_path, notice: "Comparison completed" }
154
+ end
155
+ rescue ActiveRecord::RecordNotFound => e
156
+ error_response = { error: "One or both query executions not found" }
157
+ respond_to do |format|
158
+ format.json { render json: error_response, status: :not_found }
159
+ format.html { redirect_to root_path, alert: error_response[:error] }
160
+ end
161
+ rescue => e
162
+ Rails.logger.error "Comparison failed: #{e.message}"
163
+ Rails.logger.error e.backtrace.join("\n")
164
+
165
+ error_response = { error: "Comparison failed: #{e.message}" }
166
+ respond_to do |format|
167
+ format.json { render json: error_response, status: :internal_server_error }
168
+ format.html { redirect_to root_path, alert: error_response[:error] }
169
+ end
170
+ end
171
+ end
172
+
63
173
  private
64
174
 
175
+ def determine_execution_type
176
+ return "analyze" if params[:analyze_button].present?
177
+ return "both" if params[:both_button].present?
178
+ "execute" # Default
179
+ end
180
+
181
+ def handle_execute_only(sql)
182
+ sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
183
+
184
+ begin
185
+ ActiveRecord::Base.connection.transaction do
186
+ ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{PgInsights.query_execution_timeout_ms}")
187
+ @result = ActiveRecord::Base.connection.exec_query(sql)
188
+ end
189
+ @execution_type = "execute"
190
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
191
+ flash.now[:alert] = "Query Error: #{e.message}"
192
+ nil
193
+ end
194
+ end
195
+
196
+ def handle_analyze_only(sql)
197
+ begin
198
+ @query_execution = QueryAnalysisService.execute_query(sql, execution_type: "analyze")
199
+ @execution_type = "analyze"
200
+ rescue => e
201
+ flash.now[:alert] = "Analysis Error: #{e.message}"
202
+ nil
203
+ end
204
+ end
205
+
206
+ def handle_execute_and_analyze(sql)
207
+ sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
208
+
209
+ begin
210
+ @query_execution = QueryAnalysisService.execute_query(sql, execution_type: "both")
211
+ @execution_type = "both"
212
+
213
+ # Extract regular result for backward compatibility
214
+ if @query_execution.success? && @query_execution.result_data
215
+ result_data = @query_execution.result_data
216
+ @result = ActiveRecord::Result.new(
217
+ result_data["columns"],
218
+ result_data["rows"],
219
+ result_data["column_types"] || {}
220
+ )
221
+ end
222
+ rescue => e
223
+ flash.now[:alert] = "Error: #{e.message}"
224
+ nil
225
+ end
226
+ end
227
+
228
+ def format_execution_response(execution)
229
+ response = {
230
+ id: execution.id,
231
+ status: execution.status,
232
+ execution_type: execution.execution_type
233
+ }
234
+
235
+ if execution.success?
236
+ # Include rich metrics for enhanced views
237
+ response[:metrics] = extract_metrics(execution)
238
+
239
+ if execution.has_result_data?
240
+ response[:result] = {
241
+ rows: execution.result_data["rows"],
242
+ columns: execution.result_data["columns"],
243
+ summary: execution.result_summary
244
+ }
245
+ end
246
+
247
+ if execution.has_plan_data?
248
+ response[:analysis] = {
249
+ execution_plan: execution.execution_plan,
250
+ plan_summary: execution.plan_summary,
251
+ performance_insights: execution.performance_insights,
252
+ timing: {
253
+ planning_time_ms: execution.planning_time_ms,
254
+ execution_time_ms: execution.execution_time_ms,
255
+ total_time_ms: execution.total_time_ms
256
+ },
257
+ cost: execution.query_cost,
258
+ stats: execution.execution_stats
259
+ }
260
+ end
261
+ elsif execution.failed?
262
+ response[:error] = execution.error_message
263
+ end
264
+
265
+ response
266
+ end
267
+
65
268
  def read_only?(sql)
66
269
  sql.strip!
67
270
  # Check for a single SELECT statement
@@ -72,6 +275,280 @@ module PgInsights
72
275
 
73
276
  def append_limit(sql, n)
74
277
  "#{sql.strip} LIMIT #{n}"
278
+ end
279
+
280
+ def generate_comparison_data(exec_a, exec_b)
281
+ begin
282
+ {
283
+ executions: {
284
+ a: {
285
+ id: exec_a.id,
286
+ title: exec_a.respond_to?(:display_title) ? exec_a.display_title : "Query ##{exec_a.id}",
287
+ summary: exec_a.respond_to?(:display_summary) ? exec_a.display_summary : "",
288
+ sql_text: exec_a.sql_text,
289
+ metrics: extract_metrics(exec_a)
290
+ },
291
+ b: {
292
+ id: exec_b.id,
293
+ title: exec_b.respond_to?(:display_title) ? exec_b.display_title : "Query ##{exec_b.id}",
294
+ summary: exec_b.respond_to?(:display_summary) ? exec_b.display_summary : "",
295
+ sql_text: exec_b.sql_text,
296
+ metrics: extract_metrics(exec_b)
297
+ }
298
+ },
299
+ comparison: {
300
+ performance: calculate_performance_diff(exec_a, exec_b),
301
+ winner: determine_winner(exec_a, exec_b),
302
+ insights: generate_insights(exec_a, exec_b)
303
+ }
304
+ }
305
+ rescue => e
306
+ Rails.logger.error "Error in generate_comparison_data: #{e.message}"
307
+ Rails.logger.error "exec_a class: #{exec_a.class.name}, exec_b class: #{exec_b.class.name}"
308
+ Rails.logger.error e.backtrace.join("\n")
309
+ raise e
310
+ end
311
+ end
312
+
313
+ def extract_metrics(execution)
314
+ base_metrics = {
315
+ total_time_ms: execution.total_time_ms,
316
+ planning_time_ms: execution.planning_time_ms,
317
+ execution_time_ms: execution.execution_time_ms,
318
+ query_cost: execution.query_cost
319
+ }
320
+
321
+ # For queries with execution plans (EXPLAIN ANALYZE), extract from plan
322
+ if execution.execution_plan.present?
323
+ plan_metrics = extract_plan_metrics(execution)
324
+ base_metrics.merge(plan_metrics)
325
+ else
326
+ # For regular SELECT queries, use result data
327
+ base_metrics.merge(
328
+ rows_returned: execution.result_rows_count,
329
+ rows_scanned: nil
330
+ )
331
+ end
332
+ end
333
+
334
+ def calculate_performance_diff(exec_a, exec_b)
335
+ return {} unless exec_a.total_time_ms && exec_b.total_time_ms
336
+
337
+ time_diff_pct = ((exec_a.total_time_ms - exec_b.total_time_ms) / exec_a.total_time_ms * 100).round(1)
338
+ cost_diff_pct = if exec_a.query_cost && exec_b.query_cost
339
+ ((exec_a.query_cost - exec_b.query_cost) / exec_a.query_cost * 100).round(1)
340
+ else
341
+ nil
342
+ end
343
+
344
+ {
345
+ time_difference_pct: time_diff_pct,
346
+ cost_difference_pct: cost_diff_pct,
347
+ time_faster: time_diff_pct > 0 ? "b" : "a",
348
+ cost_cheaper: cost_diff_pct && cost_diff_pct > 0 ? "b" : "a"
349
+ }
350
+ end
351
+
352
+ def determine_winner(exec_a, exec_b)
353
+ return "unknown" unless exec_a.total_time_ms && exec_b.total_time_ms
354
+ exec_a.total_time_ms < exec_b.total_time_ms ? "a" : "b"
355
+ end
356
+
357
+ def generate_insights(exec_a, exec_b)
358
+ insights = []
359
+
360
+ if exec_a.total_time_ms && exec_b.total_time_ms
361
+ time_diff_pct = ((exec_a.total_time_ms - exec_b.total_time_ms).abs / [ exec_a.total_time_ms, exec_b.total_time_ms ].max * 100).round(1)
362
+
363
+ if time_diff_pct > 20
364
+ faster_query = exec_a.total_time_ms < exec_b.total_time_ms ? "Query A" : "Query B"
365
+ insights << "#{faster_query} is #{time_diff_pct}% faster in execution time"
366
+ end
367
+ end
368
+
369
+ # Add plan structure insights
370
+ if has_sequential_scan?(exec_a) && !has_sequential_scan?(exec_b)
371
+ insights << "Query B uses index scans while Query A uses sequential scans"
372
+ elsif has_sequential_scan?(exec_b) && !has_sequential_scan?(exec_a)
373
+ insights << "Query A uses index scans while Query B uses sequential scans"
374
+ end
375
+
376
+ insights
377
+ end
378
+
379
+ def extract_rows_scanned(execution)
380
+ return nil unless execution.execution_plan.present?
381
+
382
+ plan = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
383
+ extract_total_rows_from_plan(plan&.dig("Plan"))
384
+ end
385
+
386
+ def extract_total_rows_from_plan(node)
387
+ return 0 unless node
388
+
389
+ current_rows = node["Actual Rows"] || 0
390
+ child_rows = 0
391
+
392
+ if node["Plans"]&.any?
393
+ child_rows = node["Plans"].sum { |child| extract_total_rows_from_plan(child) }
394
+ end
395
+
396
+ current_rows + child_rows
397
+ end
398
+
399
+ def has_sequential_scan?(execution)
400
+ return false unless execution.execution_plan.present?
401
+
402
+ plan = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
403
+ check_for_seq_scan(plan&.dig("Plan"))
404
+ end
405
+
406
+ def extract_plan_metrics(execution)
407
+ plan_data = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
408
+ return {} unless plan_data && plan_data["Plan"]
409
+
410
+ root_plan = plan_data["Plan"]
411
+
412
+ {
413
+ rows_returned: root_plan["Actual Rows"],
414
+ rows_scanned: extract_total_rows_scanned(root_plan),
415
+ workers_planned: extract_workers_info(root_plan)[:planned],
416
+ workers_launched: extract_workers_info(root_plan)[:launched],
417
+ memory_usage_kb: extract_peak_memory_usage(root_plan),
418
+ sort_methods: extract_sort_methods(root_plan),
419
+ index_usage: extract_index_usage(root_plan),
420
+ node_count: count_plan_nodes(root_plan),
421
+ join_types: extract_join_types(root_plan),
422
+ scan_types: extract_scan_types(root_plan)
423
+ }
424
+ end
425
+
426
+ def extract_workers_info(node, workers_info = { planned: 0, launched: 0 })
427
+ return workers_info unless node
428
+
429
+ if node["Workers Planned"]
430
+ workers_info[:planned] = [ workers_info[:planned], node["Workers Planned"] ].max
431
+ end
432
+
433
+ if node["Workers Launched"]
434
+ workers_info[:launched] = [ workers_info[:launched], node["Workers Launched"] ].max
435
+ end
436
+
437
+ if node["Plans"]&.any?
438
+ node["Plans"].each { |child| extract_workers_info(child, workers_info) }
439
+ end
440
+
441
+ workers_info
442
+ end
443
+
444
+ def extract_peak_memory_usage(node, max_memory = 0)
445
+ return max_memory unless node
446
+
447
+ if node["Peak Memory Usage"]
448
+ max_memory = [ max_memory, node["Peak Memory Usage"] ].max
449
+ end
450
+
451
+ if node["Plans"]&.any?
452
+ node["Plans"].each { |child| max_memory = extract_peak_memory_usage(child, max_memory) }
453
+ end
454
+
455
+ max_memory
456
+ end
457
+
458
+ def extract_sort_methods(node, methods = Set.new)
459
+ return methods.to_a unless node
460
+
461
+ if node["Sort Method"]
462
+ methods.add(node["Sort Method"])
463
+ end
464
+
465
+ if node["Plans"]&.any?
466
+ node["Plans"].each { |child| extract_sort_methods(child, methods) }
467
+ end
468
+
469
+ methods.to_a
470
+ end
471
+
472
+ def extract_index_usage(node, indexes = Set.new)
473
+ return indexes.to_a unless node
474
+
475
+ if node["Index Name"]
476
+ indexes.add(node["Index Name"])
477
+ end
478
+
479
+ if node["Plans"]&.any?
480
+ node["Plans"].each { |child| extract_index_usage(child, indexes) }
481
+ end
482
+
483
+ indexes.to_a
484
+ end
485
+
486
+ def count_plan_nodes(node)
487
+ return 0 unless node
488
+
489
+ count = 1
490
+ if node["Plans"]&.any?
491
+ count += node["Plans"].sum { |child| count_plan_nodes(child) }
492
+ end
493
+ count
494
+ end
495
+
496
+ def extract_join_types(node, types = Set.new)
497
+ return types.to_a unless node
498
+
499
+ if node["Node Type"]&.include?("Join")
500
+ join_type = node["Join Type"] ? "#{node['Join Type']} #{node['Node Type']}" : node["Node Type"]
501
+ types.add(join_type)
502
+ end
503
+
504
+ if node["Plans"]&.any?
505
+ node["Plans"].each { |child| extract_join_types(child, types) }
506
+ end
507
+
508
+ types.to_a
509
+ end
510
+
511
+ def extract_scan_types(node, types = Set.new)
512
+ return types.to_a unless node
513
+
514
+ if node["Node Type"]&.include?("Scan")
515
+ types.add(node["Node Type"])
516
+ end
517
+
518
+ if node["Plans"]&.any?
519
+ node["Plans"].each { |child| extract_scan_types(child, types) }
520
+ end
521
+
522
+ types.to_a
523
+ end
524
+
525
+ def extract_total_rows_scanned(node)
526
+ return 0 unless node
527
+
528
+ scanned_rows = 0
529
+
530
+ # Count rows from scan operations
531
+ if node["Node Type"]&.include?("Scan") && node["Actual Rows"]
532
+ scanned_rows += node["Actual Rows"] || 0
533
+ end
534
+
535
+ if node["Plans"]&.any?
536
+ scanned_rows += node["Plans"].sum { |child| extract_total_rows_scanned(child) }
537
+ end
538
+
539
+ scanned_rows
540
+ end
541
+
542
+ def check_for_seq_scan(node)
543
+ return false unless node
544
+
545
+ return true if node["Node Type"]&.include?("Seq Scan")
546
+
547
+ if node["Plans"]&.any?
548
+ return node["Plans"].any? { |child| check_for_seq_scan(child) }
549
+ end
550
+
551
+ false
75
552
  end
76
553
  end
77
554
  end