rails_vitals 0.2.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/rails_vitals/application.css +180 -0
  6. data/app/controllers/rails_vitals/application_controller.rb +30 -0
  7. data/app/controllers/rails_vitals/associations_controller.rb +8 -0
  8. data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
  9. data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
  10. data/app/controllers/rails_vitals/models_controller.rb +65 -0
  11. data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
  12. data/app/controllers/rails_vitals/requests_controller.rb +44 -0
  13. data/app/helpers/rails_vitals/application_helper.rb +63 -0
  14. data/app/jobs/rails_vitals/application_job.rb +4 -0
  15. data/app/mailers/rails_vitals/application_mailer.rb +6 -0
  16. data/app/models/rails_vitals/application_record.rb +5 -0
  17. data/app/views/layouts/rails_vitals/application.html.erb +27 -0
  18. data/app/views/rails_vitals/associations/index.html.erb +370 -0
  19. data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
  20. data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
  21. data/app/views/rails_vitals/models/index.html.erb +117 -0
  22. data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
  23. data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
  24. data/app/views/rails_vitals/requests/index.html.erb +60 -0
  25. data/app/views/rails_vitals/requests/show.html.erb +396 -0
  26. data/config/routes.rb +9 -0
  27. data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
  28. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
  29. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
  30. data/lib/rails_vitals/collector.rb +78 -0
  31. data/lib/rails_vitals/configuration.rb +27 -0
  32. data/lib/rails_vitals/engine.rb +25 -0
  33. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
  34. data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
  35. data/lib/rails_vitals/notifications/subscriber.rb +59 -0
  36. data/lib/rails_vitals/panel_renderer.rb +233 -0
  37. data/lib/rails_vitals/request_record.rb +51 -0
  38. data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
  39. data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
  40. data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
  41. data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
  42. data/lib/rails_vitals/store.rb +34 -0
  43. data/lib/rails_vitals/version.rb +3 -0
  44. data/lib/rails_vitals.rb +33 -0
  45. data/lib/tasks/rails_vitals_tasks.rake +4 -0
  46. metadata +113 -0
@@ -0,0 +1,396 @@
1
+ <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
2
+ <%= link_to "← Back", rails_vitals.requests_path, style: "color:#a0aec0;font-size:12px;" %>
3
+ <div class="page-title" style="margin:0;">Request Detail</div>
4
+ </div>
5
+
6
+ <%# Header %>
7
+ <div class="card" style="display:flex;align-items:center;justify-content:space-between;">
8
+ <div>
9
+ <div style="font-size:42px;font-weight:bold;color:<%= score_color(@record.color) %>;">
10
+ <%= @record.score %>
11
+ <span style="font-size:16px;color:#a0aec0;">/ 100</span>
12
+ </div>
13
+ </div>
14
+ <div style="text-align:right;">
15
+ <span class="badge badge-<%= @record.color %>" style="font-size:13px;padding:4px 14px;">
16
+ <%= @record.label %>
17
+ </span>
18
+ <div style="color:#a0aec0;font-size:11px;margin-top:6px;"><%= @record.recorded_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
19
+ </div>
20
+ </div>
21
+
22
+ <%# Request Info %>
23
+ <div class="card">
24
+ <div class="card-title">Request Info</div>
25
+ <table>
26
+ <tbody>
27
+ <tr><td style="color:#a0aec0;width:140px;">Endpoint</td><td><%= @record.endpoint %></td></tr>
28
+ <tr><td style="color:#a0aec0;">Method</td> <td><%= @record.http_method %></td></tr>
29
+ <tr><td style="color:#a0aec0;">Status</td> <td><%= @record.response_status %></td></tr>
30
+ <tr><td style="color:#a0aec0;">Duration</td><td><%= @record.duration_ms&.round(1) %>ms</td></tr>
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+
35
+ <%# Query Summary %>
36
+ <div class="card">
37
+ <div class="card-title">Query Summary</div>
38
+ <table>
39
+ <tbody>
40
+ <tr><td style="color:#a0aec0;width:140px;">Total Queries</td><td><%= @record.total_query_count %></td></tr>
41
+ <tr><td style="color:#a0aec0;">DB Time</td> <td><%= @record.total_db_time_ms.round(1) %>ms</td></tr>
42
+ <tr>
43
+ <td style="color:#a0aec0;">N+1 Patterns</td>
44
+ <td>
45
+ <% if @record.n_plus_one_patterns.any? %>
46
+ <span class="n1-badge"><%= @record.n_plus_one_patterns.size %> detected</span>
47
+ <% else %>
48
+ <span style="color:#68d391;">None detected</span>
49
+ <% end %>
50
+ </td>
51
+ </tr>
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+
56
+ <%# N+1 Patterns %>
57
+ <% if @record.n_plus_one_patterns.any? %>
58
+ <div class="card">
59
+ <div class="card-title">N+1 Patterns</div>
60
+ <table>
61
+ <thead>
62
+ <tr>
63
+ <th>Query Pattern</th>
64
+ <th>Repeated</th>
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ <% @record.n_plus_one_patterns.each do |pattern, count| %>
69
+ <tr>
70
+ <td class="sql"><%= pattern %></td>
71
+ <td><span class="n1-badge"><%= count %>x</span></td>
72
+ </tr>
73
+ <% end %>
74
+ </tbody>
75
+ </table>
76
+ </div>
77
+ <% end %>
78
+
79
+ <%# All Queries %>
80
+ <div class="card">
81
+ <div
82
+ class="card-title"
83
+ onclick="toggleCard('all_queries_table', 'all_queries_chevron_simple')"
84
+ style="cursor:pointer;"
85
+ >
86
+ <div style="display:flex;justify-content:space-between;align-items:center;">
87
+ <span>All Queries (<%= @record.queries.size %>)</span>
88
+ <span style="font-size:14px;" id="all_queries_chevron_simple">▼</span>
89
+ </div>
90
+ </div>
91
+ <table id="all_queries_table">
92
+ <thead>
93
+ <tr>
94
+ <th>SQL</th>
95
+ <th>Time</th>
96
+ </tr>
97
+ </thead>
98
+ <tbody>
99
+ <% @record.queries.sort_by { |q| -q[:duration_ms] }.each do |q| %>
100
+ <tr>
101
+ <td class="sql"><%= q[:sql] %></td>
102
+ <td style="white-space:nowrap;"><%= q[:duration_ms].round(1) %>ms</td>
103
+ </tr>
104
+ <% end %>
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+
109
+ <%# Callback Map %>
110
+ <% if @record.callbacks.any? %>
111
+ <div class="card">
112
+ <div class="card-title">
113
+ Callback Map
114
+ <span style="color:#a0aec0;font-weight:normal;margin-left:8px;">
115
+ <%= @record.callbacks.size %> callbacks —
116
+ <%= @record.total_callback_time_ms.round(1) %>ms total
117
+ </span>
118
+ </div>
119
+ <% @record.callbacks.group_by { |c| c[:model] }.each do |model, callbacks| %>
120
+ <div style="margin-bottom:16px;">
121
+ <div style="
122
+ color:#90cdf4;
123
+ font-size:12px;
124
+ font-weight:bold;
125
+ margin-bottom:6px;
126
+ padding-bottom:4px;
127
+ border-bottom:1px solid #2d3748;
128
+ ">
129
+ <%= model %>
130
+ <span style="color:#a0aec0;font-weight:normal;">
131
+ (<%= callbacks.size %> callbacks,
132
+ <%= callbacks.sum { |c| c[:duration_ms] }.round(1) %>ms)
133
+ </span>
134
+ </div>
135
+ <table>
136
+ <thead>
137
+ <tr>
138
+ <th>Callback</th>
139
+ <th>Time</th>
140
+ <th>% of Total</th>
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ <% callbacks.sort_by { |c| -c[:duration_ms] }.each do |cb| %>
145
+ <tr>
146
+ <td>
147
+ <span style="
148
+ background:<%= callback_color(cb[:kind]) %>;
149
+ color:#fff;
150
+ padding:1px 6px;
151
+ border-radius:3px;
152
+ font-size:11px;
153
+ ">
154
+ <%= cb[:kind] %>
155
+ </span>
156
+ </td>
157
+ <td><%= cb[:duration_ms] %>ms</td>
158
+ <td style="color:#a0aec0;">
159
+ <% pct = @record.total_callback_time_ms > 0 ?
160
+ ((cb[:duration_ms] / @record.total_callback_time_ms) * 100).round(1) : 0 %>
161
+ <%= pct %>%
162
+ </td>
163
+ </tr>
164
+ <% end %>
165
+ </tbody>
166
+ </table>
167
+ </div>
168
+ <% end %>
169
+ </div>
170
+ <% end %>
171
+
172
+ <%# All Queries with DNA %>
173
+ <div class="card">
174
+ <div
175
+ class="card-title"
176
+ onclick="toggleCard('all_queries_table_dna', 'all_queries_chevron_dna')"
177
+ style="cursor:pointer;"
178
+ >
179
+ <div style="display:flex;justify-content:space-between;align-items:center;">
180
+ <p>
181
+ All Queries (<%= @record.queries.size %>)
182
+ <span class="card-title-description">
183
+ click any query to expand DNA
184
+ </span>
185
+ </p>
186
+ <span style="font-size:14px;" id="all_queries_chevron_dna">▼</span>
187
+ </div>
188
+ </div>
189
+ <table id="all_queries_table_dna">
190
+ <thead>
191
+ <tr>
192
+ <th>#</th>
193
+ <th>SQL</th>
194
+ <th>Time</th>
195
+ <th>Complexity</th>
196
+ <th>Risk</th>
197
+ </tr>
198
+ </thead>
199
+ <tbody>
200
+ <% @query_dna.each_with_index do |item, i| %>
201
+ <% q = item[:query] %>
202
+ <% dna = item[:dna] %>
203
+ <% dna_id = "dna_#{i}" %>
204
+
205
+ <%# Query row — clickable %>
206
+ <tr
207
+ onclick="toggleDna('<%= dna_id %>')"
208
+ style="cursor:pointer;"
209
+ >
210
+ <td style="color:#a0aec0;"><%= i + 1 %></td>
211
+ <td style="font-family:monospace;font-size:11px;color:#e2e8f0;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
212
+ <%= q[:sql] %>
213
+ </td>
214
+ <td style="color:<%= time_heat_color(q[:duration_ms]) %>;">
215
+ <%= q[:duration_ms].round(2) %>ms
216
+ </td>
217
+ <td>
218
+ <span style="color:<%= dna.complexity_label[:color] %>;font-size:12px;">
219
+ <%= dna.complexity_label[:label] %>
220
+ <span style="color:#a0aec0;font-size:10px;">(<%= dna.complexity %>/10)</span>
221
+ </span>
222
+ </td>
223
+ <td>
224
+ <% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
225
+ <span style="color:<%= risk_colors[dna.risk] %>;font-size:12px;text-transform:capitalize;">
226
+ <%= dna.risk %>
227
+ </span>
228
+ </td>
229
+ </tr>
230
+
231
+ <%# DNA panel — hidden by default %>
232
+ <tr id="<%= dna_id %>" style="display:none;">
233
+ <td colspan="5" style="padding:0;">
234
+ <div style="
235
+ background:#1a202c;
236
+ border-left:3px solid #4299e1;
237
+ padding:16px;
238
+ margin:4px 0;
239
+ ">
240
+
241
+ <%# Full SQL %>
242
+ <div style="margin-bottom:16px;">
243
+ <div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
244
+ Full SQL
245
+ </div>
246
+ <pre style="color:#e2e8f0;font-size:11px;white-space:pre-wrap;word-break:break-all;margin:0;"><%= q[:sql] %></pre>
247
+ </div>
248
+
249
+ <%# Token strip %>
250
+ <div style="margin-bottom:16px;">
251
+ <div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;">
252
+ Query DNA
253
+ </div>
254
+ <div style="display:flex;flex-wrap:wrap;gap:6px;">
255
+ <% dna.tokens.each do |token| %>
256
+ <span
257
+ onclick="toggleCard('<%= "card_#{dna_id}_#{token[:type]}" %>'); event.stopPropagation();"
258
+ style="
259
+ background:<%= token[:color] %>22;
260
+ color:<%= token[:color] %>;
261
+ border:1px solid <%= token[:color] %>66;
262
+ padding:3px 10px;
263
+ border-radius:4px;
264
+ font-size:12px;
265
+ font-family:monospace;
266
+ cursor:pointer;
267
+ "
268
+ >
269
+ <%= token[:label] %>
270
+ </span>
271
+ <% end %>
272
+ </div>
273
+ </div>
274
+
275
+ <%# Repetition bar %>
276
+ <% if dna.repetition_bar.is_a?(Hash) && dna.repetition_count > 1 %>
277
+ <div style="margin-bottom:16px;">
278
+ <div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
279
+ Repetition — <%= dna.repetition_count %>x in this request
280
+ </div>
281
+ <div style="display:flex;align-items:center;gap:8px;">
282
+ <div style="font-family:monospace;font-size:13px;">
283
+ <span style="color:#fc8181;">
284
+ <%= "█" * dna.repetition_bar[:filled] %>
285
+ </span>
286
+ <span style="color:#2d3748;">
287
+ <%= "█" * dna.repetition_bar[:empty] %>
288
+ </span>
289
+ </div>
290
+ <span style="color:#fc8181;font-size:12px;font-weight:bold;">
291
+ <%= dna.repetition_count %>x — N+1 pattern
292
+ </span>
293
+ </div>
294
+ </div>
295
+ <% end %>
296
+
297
+ <%# Complexity + metadata row %>
298
+ <div style="display:flex;gap:24px;margin-bottom:16px;font-size:12px;">
299
+ <div>
300
+ <span style="color:#a0aec0;">Complexity</span>
301
+ <span style="color:<%= dna.complexity_label[:color] %>;margin-left:6px;font-weight:bold;">
302
+ <%= dna.complexity_label[:label] %> (<%= dna.complexity %>/10)
303
+ </span>
304
+ </div>
305
+ <div>
306
+ <span style="color:#a0aec0;">Risk</span>
307
+ <% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
308
+ <span style="color:<%= risk_colors[dna.risk] %>;margin-left:6px;font-weight:bold;text-transform:capitalize;">
309
+ <%= dna.risk %>
310
+ </span>
311
+ </div>
312
+ <div>
313
+ <span style="color:#a0aec0;">Duration</span>
314
+ <span style="color:<%= time_heat_color(q[:duration_ms]) %>;margin-left:6px;font-weight:bold;">
315
+ <%= q[:duration_ms].round(2) %>ms
316
+ </span>
317
+ </div>
318
+ </div>
319
+
320
+ <%# Education cards — one per token, hidden by default %>
321
+ <% dna.tokens.each do |token| %>
322
+ <div
323
+ id="card_<%= dna_id %>_<%= token[:type] %>"
324
+ style="display:none;margin-bottom:8px;"
325
+ >
326
+ <div style="
327
+ background:#2d3748;
328
+ border-left:3px solid <%= token[:color] %>;
329
+ border-radius:4px;
330
+ padding:12px 16px;
331
+ ">
332
+ <div style="
333
+ color:<%= token[:color] %>;
334
+ font-size:12px;
335
+ font-weight:bold;
336
+ font-family:monospace;
337
+ margin-bottom:6px;
338
+ ">
339
+ 💡 <%= token[:label] %>
340
+ <span style="
341
+ background:<%= token[:color] %>33;
342
+ color:<%= token[:color] %>;
343
+ font-size:10px;
344
+ padding:1px 6px;
345
+ border-radius:3px;
346
+ margin-left:6px;
347
+ text-transform:uppercase;
348
+ font-family:Arial,sans-serif;
349
+ ">
350
+ <%= token[:risk] %>
351
+ </span>
352
+ </div>
353
+ <div style="color:#e2e8f0;font-size:13px;line-height:1.6;">
354
+ <%= token[:explanation] %>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ <% end %>
359
+
360
+ </div>
361
+ </td>
362
+ </tr>
363
+
364
+ <% end %>
365
+ </tbody>
366
+ </table>
367
+ </div>
368
+
369
+ <%# Inline JS for toggle behavior %>
370
+ <script>
371
+ function toggleDna(id) {
372
+ var row = document.getElementById(id);
373
+ if (row) {
374
+ row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
375
+ }
376
+ }
377
+
378
+ function toggleCard(id, chevronId) {
379
+ var card = document.getElementById(id);
380
+ if (card) {
381
+ if (card.style.display === 'none') {
382
+ card.style.display = card.tagName === 'TABLE' ? 'table' : 'block';
383
+ } else {
384
+ card.style.display = 'none';
385
+ }
386
+ }
387
+
388
+ // Toggle chevron icon if provided
389
+ if (chevronId) {
390
+ var chevron = document.getElementById(chevronId);
391
+ if (chevron) {
392
+ chevron.textContent = chevron.textContent === '▼' ? '▶' : '▼';
393
+ }
394
+ }
395
+ }
396
+ </script>
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ RailsVitals::Engine.routes.draw do
2
+ root to: "dashboard#index"
3
+
4
+ resources :requests, only: [ :index, :show ]
5
+ resources :models, only: [ :index ]
6
+ resources :n_plus_ones, only: [ :index, :show ]
7
+ resources :associations, only: [ :index ]
8
+ get "heatmap", to: "heatmap#index", as: :heatmap
9
+ end
@@ -0,0 +1,121 @@
1
+ module RailsVitals
2
+ module Analyzers
3
+ class AssociationMapper
4
+ ModelNode = Struct.new(
5
+ :name, :table, :depth, :position,
6
+ :associations, :query_count, :avg_query_time_ms,
7
+ :has_n1, :n1_patterns,
8
+ keyword_init: true
9
+ )
10
+
11
+ AssociationEdge = Struct.new(
12
+ :from_model, :to_model, :macro,
13
+ :foreign_key, :indexed, :has_n1,
14
+ keyword_init: true
15
+ )
16
+
17
+ def self.build(store)
18
+ records = store.all
19
+ models = discover_models
20
+ n1_data = NPlusOneAggregator.aggregate(records)
21
+ n1_tables = n1_data.map { |p| p[:table] }.compact.uniq
22
+
23
+ nodes = models.map do |model|
24
+ queries = queries_for_model(model, records)
25
+ avg_time = queries.empty? ? 0 : (queries.sum { |q| q[:duration_ms] } / queries.size).round(2)
26
+
27
+ ModelNode.new(
28
+ name: model.name,
29
+ table: model.table_name,
30
+ depth: association_depth(model, models),
31
+ position: nil,
32
+ associations: build_edges(model, n1_tables),
33
+ query_count: queries.size,
34
+ avg_query_time_ms: avg_time,
35
+ has_n1: n1_tables.include?(model.table_name),
36
+ n1_patterns: n1_data.select { |p| p[:table] == model.table_name }
37
+ )
38
+ end
39
+
40
+ nodes, canvas_h = assign_positions(nodes)
41
+ [ nodes, canvas_h ]
42
+ end
43
+
44
+ def self.discover_models
45
+ ActiveRecord::Base.descendants
46
+ .reject(&:abstract_class?)
47
+ .reject { |m| m.name&.start_with?("RailsVitals") }
48
+ .select { |m| m.table_exists? rescue false }
49
+ .sort_by(&:name)
50
+ end
51
+
52
+ # Depth = how many belongs_to hops from root
53
+ def self.association_depth(model, all_models)
54
+ belongs_to_targets = model.reflect_on_all_associations(:belongs_to)
55
+ .map { |r| r.klass rescue nil }
56
+ .compact
57
+
58
+ return 0 if belongs_to_targets.empty?
59
+
60
+ belongs_to_targets.map { |target|
61
+ target == model ? 0 : association_depth(target, all_models) + 1
62
+ }.min
63
+ rescue
64
+ 0
65
+ end
66
+
67
+ def self.build_edges(model, n1_tables)
68
+ model.reflect_on_all_associations.map do |assoc|
69
+ target = assoc.klass rescue next
70
+ fk = assoc.foreign_key.to_s
71
+ table = assoc.macro == :belongs_to ? model.table_name : target.table_name
72
+
73
+ indexed = begin
74
+ ActiveRecord::Base.connection
75
+ .indexes(table)
76
+ .any? { |i| i.columns.first == fk }
77
+ rescue
78
+ false
79
+ end
80
+
81
+ AssociationEdge.new(
82
+ from_model: model.name,
83
+ to_model: target.name,
84
+ macro: assoc.macro,
85
+ foreign_key: fk,
86
+ indexed: indexed,
87
+ has_n1: n1_tables.include?(target.table_name)
88
+ )
89
+ end.compact
90
+ end
91
+
92
+ def self.queries_for_model(model, records)
93
+ records.flat_map { |r| r.queries }
94
+ .select { |q|
95
+ q[:sql].match?(/FROM\s+"?#{model.table_name}"?/i) ||
96
+ q[:sql].match?(/UPDATE\s+"?#{model.table_name}"?/i)
97
+ }
98
+ end
99
+
100
+ # Assign x/y positions by depth layer
101
+ def self.assign_positions(nodes)
102
+ by_depth = nodes.group_by(&:depth)
103
+ canvas_w = 900
104
+ canvas_h = 120 + (by_depth.keys.max || 0) * 160
105
+
106
+ by_depth.each do |depth, layer_nodes|
107
+ count = layer_nodes.size
108
+ x_step = canvas_w / (count + 1)
109
+ layer_nodes.each_with_index do |node, i|
110
+ node.position = {
111
+ x: x_step * (i + 1),
112
+ y: 60 + depth * 160
113
+ }
114
+ end
115
+ end
116
+
117
+ [ nodes, canvas_h ]
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,116 @@
1
+ module RailsVitals
2
+ module Analyzers
3
+ class NPlusOneAggregator
4
+ def self.aggregate(records)
5
+ pattern_data = Hash.new do |h, k|
6
+ h[k] = {
7
+ pattern: k,
8
+ occurrences: 0,
9
+ endpoints: Hash.new(0),
10
+ table: nil,
11
+ foreign_key: nil
12
+ }
13
+ end
14
+
15
+ records.each do |record|
16
+ next if record.n_plus_one_patterns.empty?
17
+
18
+ record.n_plus_one_patterns.each do |sql, count|
19
+ normalized = normalize(sql)
20
+ Rails.logger.debug "Processing SQL: #{sql} → normalized: #{normalized}"
21
+
22
+ pattern_data[normalized][:occurrences] += count
23
+ pattern_data[normalized][:endpoints][record.endpoint] += 1
24
+ pattern_data[normalized][:table] ||= extract_table(sql)
25
+ pattern_data[normalized][:foreign_key] ||= extract_foreign_key(sql)
26
+ end
27
+ end
28
+
29
+ pattern_data
30
+ .values
31
+ .sort_by { |p| -p[:occurrences] }
32
+ .map do |p|
33
+ p[:endpoints] = p[:endpoints].to_h
34
+ p.merge(fix_suggestion: build_suggestion(p))
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def self.normalize(sql)
41
+ sql
42
+ .gsub('\\"', '"') # unescape stored escaped quotes
43
+ .gsub(/\b\d+\b/, "?")
44
+ .gsub(/'[^']*'/, "?")
45
+ .gsub(/\s+/, " ")
46
+ .strip
47
+ end
48
+
49
+ def self.extract_table(sql)
50
+ clean = sql.gsub('\\"', '"')
51
+ clean.match(/FROM\s+"?(\w+)"?/i)&.captures&.first
52
+ end
53
+
54
+ def self.extract_foreign_key(sql)
55
+ clean = sql.gsub('\\"', '"')
56
+ clean.match(/WHERE\s+"?\w+"?\."?(\w+_id)"\s*=/i)&.captures&.first
57
+ end
58
+
59
+ def self.build_suggestion(pattern)
60
+ table = pattern[:table]
61
+ foreign_key = pattern[:foreign_key]
62
+
63
+ return generic_suggestion(table) unless table && foreign_key
64
+
65
+ # Map foreign_key back to association
66
+ owner_model = infer_owner_model(foreign_key)
67
+ assoc_name = infer_association(table, foreign_key)
68
+
69
+ if owner_model && assoc_name
70
+ {
71
+ code: "#{owner_model}.includes(:#{assoc_name})",
72
+ description: "Eager load :#{assoc_name} on #{owner_model} to eliminate this N+1",
73
+ owner: owner_model,
74
+ association: assoc_name
75
+ }
76
+ else
77
+ generic_suggestion(table)
78
+ end
79
+ end
80
+
81
+ def self.infer_owner_model(foreign_key)
82
+ # foreign_key = "user_id" → owner is "User"
83
+ foreign_key.sub(/_id$/, "").classify
84
+ end
85
+
86
+ def self.infer_association(table, foreign_key)
87
+ # table = "posts", foreign_key = "user_id"
88
+ # → association :posts on User
89
+ owner_class_name = foreign_key.sub(/_id$/, "").classify
90
+
91
+ begin
92
+ owner_class = owner_class_name.constantize
93
+ return nil unless owner_class < ActiveRecord::Base
94
+
95
+ # Find association on owner that points to this table
96
+ assoc = owner_class.reflect_on_all_associations.find do |r|
97
+ r.klass.table_name == table rescue false
98
+ end
99
+
100
+ assoc&.name
101
+ rescue NameError
102
+ nil
103
+ end
104
+ end
105
+
106
+ def self.generic_suggestion(table)
107
+ {
108
+ code: "includes(:#{table&.singularize})",
109
+ description: "Use includes(), eager_load(), or preload() to batch this query",
110
+ owner: nil,
111
+ association: table&.singularize
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end