rails_pulse 0.1.2 → 0.1.3

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  4. data/app/assets/images/rails_pulse/request.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  7. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  8. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  9. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  10. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  11. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  12. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  13. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  14. data/app/controllers/rails_pulse/queries_controller.rb +46 -1
  15. data/app/controllers/rails_pulse/requests_controller.rb +14 -1
  16. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  17. data/app/helpers/rails_pulse/chart_helper.rb +15 -7
  18. data/app/javascript/rails_pulse/application.js +34 -3
  19. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  20. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  21. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  22. data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
  23. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  24. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  25. data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
  28. data/app/models/rails_pulse/query.rb +46 -0
  29. data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
  30. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
  31. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
  32. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
  33. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  34. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  35. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  36. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  37. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  38. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  39. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  40. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  41. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  42. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  43. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  44. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  45. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  46. data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
  47. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  48. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  49. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  50. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  51. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  52. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  53. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  54. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  55. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  56. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  57. data/app/views/rails_pulse/queries/_table.html.erb +1 -1
  58. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  59. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  60. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  61. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  62. data/app/views/rails_pulse/requests/index.html.erb +48 -51
  63. data/app/views/rails_pulse/routes/_table.html.erb +1 -1
  64. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  65. data/app/views/rails_pulse/routes/show.html.erb +4 -4
  66. data/config/routes.rb +5 -1
  67. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  68. data/db/rails_pulse_schema.rb +9 -0
  69. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  70. data/lib/generators/rails_pulse/install_generator.rb +71 -18
  71. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  72. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  73. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  74. data/lib/rails_pulse/version.rb +1 -1
  75. data/lib/tasks/rails_pulse.rake +27 -8
  76. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  77. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  78. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  79. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  80. metadata +23 -5
  81. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  82. data/app/assets/images/rails_pulse/routes.png +0 -0
  83. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
@@ -0,0 +1,326 @@
1
+ # Recommends database indexes to improve query performance.
2
+ # Analyzes WHERE clauses, JOINs, ORDER BY, and identifies opportunities for single-column, composite, and covering indexes.
3
+ module RailsPulse
4
+ module Analysis
5
+ class IndexRecommendationEngine < BaseAnalyzer
6
+ def analyze
7
+ return [] unless sql.present?
8
+
9
+ recommendations = []
10
+
11
+ # Analyze WHERE clause for single column indexes
12
+ recommendations.concat(analyze_where_clause_indexes)
13
+
14
+ # Analyze JOIN conditions for indexes
15
+ recommendations.concat(analyze_join_indexes)
16
+
17
+ # Analyze ORDER BY clauses for indexes
18
+ recommendations.concat(analyze_order_by_indexes)
19
+
20
+ # Analyze composite index opportunities
21
+ recommendations.concat(analyze_composite_index_opportunities)
22
+
23
+ # Check for covering index opportunities
24
+ recommendations.concat(analyze_covering_index_opportunities)
25
+
26
+ # Prioritize recommendations based on query frequency and complexity
27
+ prioritize_recommendations(recommendations)
28
+ end
29
+
30
+ private
31
+
32
+ def analyze_where_clause_indexes
33
+ recommendations = []
34
+ table_name = extract_main_table
35
+ return recommendations unless table_name
36
+
37
+ where_clause = extract_where_clause
38
+ return recommendations unless where_clause
39
+
40
+ # Find equality conditions (best for indexes)
41
+ equality_conditions = where_clause.scan(/(\w+)\s*=\s*[?'"\d]/)
42
+ equality_conditions.each do |column_match|
43
+ column = column_match[0]
44
+ next if reserved_word?(column)
45
+
46
+ recommendations << build_index_recommendation(
47
+ table_name, [ column ], "single_column", "high",
48
+ "Equality condition in WHERE clause",
49
+ "Fast lookups for #{column} = value queries"
50
+ )
51
+ end
52
+
53
+ # Find range conditions
54
+ range_conditions = where_clause.scan(/(\w+)\s*(?:>|<|>=|<=|BETWEEN)/i)
55
+ range_conditions.each do |column_match|
56
+ column = column_match[0]
57
+ next if reserved_word?(column)
58
+
59
+ recommendations << build_index_recommendation(
60
+ table_name, [ column ], "single_column", "medium",
61
+ "Range condition in WHERE clause",
62
+ "Efficient range scans for #{column}"
63
+ )
64
+ end
65
+
66
+ # Find LIKE patterns that could benefit from indexes
67
+ analyze_like_conditions(table_name, where_clause, recommendations)
68
+
69
+ recommendations
70
+ end
71
+
72
+ def analyze_like_conditions(table_name, where_clause, recommendations)
73
+ like_conditions = where_clause.scan(/(\w+)\s*LIKE\s*'([^']+)'/i)
74
+ like_conditions.each do |column, pattern|
75
+ next if reserved_word?(column)
76
+
77
+ if pattern.start_with?("%")
78
+ # Leading wildcard - suggest full-text search instead
79
+ recommendations << build_fulltext_recommendation(table_name, column)
80
+ else
81
+ # Prefix match can use regular index
82
+ recommendations << build_index_recommendation(
83
+ table_name, [ column ], "single_column", "medium",
84
+ "LIKE with prefix pattern",
85
+ "Prefix matching for #{column}"
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ def analyze_join_indexes
92
+ recommendations = []
93
+
94
+ # Extract JOIN conditions
95
+ join_matches = sql.scan(/JOIN\s+(\w+)\s+.*?ON\s+(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)/i)
96
+
97
+ join_matches.each do |join_table, table1, col1, table2, col2|
98
+ # Recommend indexes on join columns
99
+ recommendations << build_index_recommendation(
100
+ join_table, [ col2 ], "single_column", "high",
101
+ "JOIN condition", "Fast JOIN execution"
102
+ )
103
+
104
+ # Also check the other side of the join if it's not the main table
105
+ main_table = extract_main_table
106
+ if table1 != main_table
107
+ recommendations << build_index_recommendation(
108
+ table1, [ col1 ], "single_column", "high",
109
+ "JOIN condition", "Fast JOIN execution"
110
+ )
111
+ end
112
+ end
113
+
114
+ recommendations
115
+ end
116
+
117
+ def analyze_order_by_indexes
118
+ recommendations = []
119
+ table_name = extract_main_table
120
+ return recommendations unless table_name
121
+
122
+ order_columns = extract_order_columns
123
+ return recommendations if order_columns.empty?
124
+
125
+ if order_columns.length == 1
126
+ column = order_columns.first
127
+ recommendations << build_index_recommendation(
128
+ table_name, [ column ], "single_column", "medium",
129
+ "ORDER BY clause", "Avoid sorting for ORDER BY #{column}"
130
+ )
131
+ elsif order_columns.length > 1
132
+ recommendations << build_index_recommendation(
133
+ table_name, order_columns, "composite", "medium",
134
+ "Multi-column ORDER BY", "Avoid sorting for complex ORDER BY"
135
+ )
136
+ end
137
+
138
+ recommendations
139
+ end
140
+
141
+ def analyze_composite_index_opportunities
142
+ recommendations = []
143
+ table_name = extract_main_table
144
+ return recommendations unless table_name
145
+
146
+ where_columns = extract_where_columns
147
+ order_columns = extract_order_columns
148
+
149
+ # Look for WHERE + ORDER BY combinations
150
+ if where_columns.any? && order_columns.any?
151
+ composite_columns = where_columns + order_columns
152
+
153
+ recommendations << build_index_recommendation(
154
+ table_name, composite_columns, "composite", "high",
155
+ "WHERE + ORDER BY optimization",
156
+ "Single index for filtering and sorting"
157
+ )
158
+ end
159
+
160
+ # Look for multiple WHERE conditions
161
+ if where_columns.length > 1
162
+ recommendations << build_index_recommendation(
163
+ table_name, where_columns, "composite", "high",
164
+ "Multiple WHERE conditions",
165
+ "Efficient multi-column filtering"
166
+ )
167
+ end
168
+
169
+ recommendations
170
+ end
171
+
172
+ def analyze_covering_index_opportunities
173
+ recommendations = []
174
+ table_name = extract_main_table
175
+ return recommendations unless table_name
176
+
177
+ # Extract selected columns
178
+ select_match = sql.match(/SELECT\s+(.+?)\s+FROM/i)
179
+ return recommendations if !select_match || select_match[1].include?("*")
180
+
181
+ selected_columns = select_match[1].split(",").map(&:strip)
182
+ where_columns = extract_where_columns
183
+
184
+ if where_columns.any? && selected_columns.length <= 5
185
+ covering_columns = (where_columns + selected_columns).uniq
186
+
187
+ recommendations << {
188
+ type: "covering",
189
+ table: table_name,
190
+ columns: covering_columns,
191
+ reason: "Covering index opportunity",
192
+ priority: "medium",
193
+ migration_code: generate_covering_migration_code(table_name, where_columns, selected_columns),
194
+ estimated_benefit: "Index-only scan without table access",
195
+ priority_score: 60,
196
+ execution_context: execution_context
197
+ }
198
+ end
199
+
200
+ recommendations
201
+ end
202
+
203
+ def extract_where_columns
204
+ where_clause = extract_where_clause
205
+ return [] unless where_clause
206
+
207
+ columns = []
208
+
209
+ # Extract columns from equality conditions
210
+ columns.concat(where_clause.scan(/(\w+)\s*=/).flatten)
211
+ # Extract columns from range conditions
212
+ columns.concat(where_clause.scan(/(\w+)\s*(?:>|<|>=|<=|BETWEEN)/).flatten)
213
+ # Extract columns from LIKE conditions
214
+ columns.concat(where_clause.scan(/(\w+)\s*LIKE/).flatten)
215
+
216
+ columns.reject { |col| reserved_word?(col) }.uniq
217
+ end
218
+
219
+ def extract_order_columns
220
+ order_match = sql.match(/ORDER\s+BY\s+(.+?)(?:\s+LIMIT|\s*$)/i)
221
+ return [] unless order_match
222
+
223
+ order_clause = order_match[1]
224
+ order_clause.split(",").map do |col|
225
+ col.strip.gsub(/\s+(ASC|DESC)\s*$/i, "").strip
226
+ end
227
+ end
228
+
229
+ def build_index_recommendation(table, columns, type, priority, reason, benefit)
230
+ {
231
+ type: type,
232
+ table: table,
233
+ columns: Array(columns),
234
+ reason: reason,
235
+ priority: priority,
236
+ migration_code: generate_migration_code(table, columns),
237
+ estimated_benefit: benefit,
238
+ priority_score: calculate_priority_score(priority),
239
+ execution_context: execution_context
240
+ }
241
+ end
242
+
243
+ def build_fulltext_recommendation(table, column)
244
+ {
245
+ type: "full_text",
246
+ table: table,
247
+ columns: [ column ],
248
+ reason: "LIKE with leading wildcard",
249
+ priority: "low",
250
+ migration_code: generate_fulltext_migration_code(table, [ column ]),
251
+ estimated_benefit: "Full-text search instead of slow LIKE queries",
252
+ priority_score: 30,
253
+ execution_context: execution_context
254
+ }
255
+ end
256
+
257
+ def prioritize_recommendations(recommendations)
258
+ execution_frequency = operations.count
259
+
260
+ recommendations.each do |rec|
261
+ # Boost score based on execution frequency
262
+ frequency_boost = [ execution_frequency * 2, 50 ].min
263
+ rec[:priority_score] += frequency_boost
264
+ end
265
+
266
+ # Remove duplicates and sort by priority
267
+ unique_recommendations = recommendations.uniq { |r| [ r[:table], r[:columns].sort ] }
268
+ unique_recommendations.sort_by { |r| -r[:priority_score] }
269
+ end
270
+
271
+ def calculate_priority_score(priority)
272
+ case priority
273
+ when "high" then 100
274
+ when "medium" then 60
275
+ when "low" then 30
276
+ else 50
277
+ end
278
+ end
279
+
280
+ def execution_context
281
+ @execution_context ||= {
282
+ frequency: operations.count,
283
+ frequency_description: describe_frequency(operations.count)
284
+ }
285
+ end
286
+
287
+ def generate_migration_code(table_name, columns)
288
+ columns = Array(columns)
289
+ if columns.length == 1
290
+ "add_index :#{table_name}, :#{columns.first}"
291
+ else
292
+ "add_index :#{table_name}, #{columns.inspect}"
293
+ end
294
+ end
295
+
296
+ def generate_covering_migration_code(table_name, where_columns, select_columns)
297
+ all_columns = (where_columns + select_columns).uniq
298
+ "add_index :#{table_name}, #{all_columns.inspect}, name: 'covering_idx_#{table_name}_#{where_columns.join('_')}'"
299
+ end
300
+
301
+ def generate_fulltext_migration_code(table_name, columns)
302
+ case database_adapter
303
+ when "postgresql"
304
+ "add_index :#{table_name}, :#{columns.first}, using: 'gin', opclass: 'gin_trgm_ops'"
305
+ when "mysql", "mysql2"
306
+ "add_index :#{table_name}, :#{columns.first}, type: 'fulltext'"
307
+ else
308
+ "# Full-text search not supported for #{database_adapter}"
309
+ end
310
+ end
311
+
312
+ def describe_frequency(count)
313
+ case count
314
+ when 0..10 then "Low frequency"
315
+ when 11..50 then "Medium frequency"
316
+ when 51..100 then "High frequency"
317
+ else "Very high frequency"
318
+ end
319
+ end
320
+
321
+ def reserved_word?(word)
322
+ word.upcase.in?([ "AND", "OR", "NOT", "NULL", "TRUE", "FALSE" ])
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,241 @@
1
+ # Detects N+1 query patterns by analyzing query repetition within request cycles.
2
+ # Groups operations by time windows and identifies repetitive single-record lookups that suggest missing eager loading.
3
+ module RailsPulse
4
+ module Analysis
5
+ class NPlusOneDetector < BaseAnalyzer
6
+ REQUEST_GROUPING_WINDOW = 0.1.seconds
7
+ REPETITION_THRESHOLD = 3
8
+
9
+ def analyze
10
+ return default_result if operations.empty?
11
+
12
+ analysis = {
13
+ is_likely_n_plus_one: false,
14
+ confidence_score: 0,
15
+ evidence: [],
16
+ suggested_fixes: [],
17
+ execution_patterns: {}
18
+ }
19
+
20
+ # Group operations by request cycles
21
+ request_groups = group_operations_by_request
22
+
23
+ # Analyze patterns within each request
24
+ request_groups.each do |group|
25
+ pattern_analysis = analyze_request_pattern(group)
26
+
27
+ if pattern_analysis[:repetitive_queries]
28
+ analysis[:is_likely_n_plus_one] = true
29
+ analysis[:confidence_score] += pattern_analysis[:confidence]
30
+ analysis[:evidence].concat(pattern_analysis[:evidence])
31
+ analysis[:suggested_fixes].concat(pattern_analysis[:fixes])
32
+ end
33
+ end
34
+
35
+ # Normalize confidence score
36
+ analysis[:confidence_score] = [ analysis[:confidence_score], 100 ].min
37
+
38
+ # Add execution pattern analysis
39
+ analysis[:execution_patterns] = analyze_execution_patterns
40
+
41
+ # Generate ActiveRecord-specific suggestions
42
+ if analysis[:is_likely_n_plus_one]
43
+ analysis[:suggested_fixes].concat(generate_activerecord_fixes)
44
+ end
45
+
46
+ analysis
47
+ end
48
+
49
+ private
50
+
51
+ def default_result
52
+ {
53
+ is_likely_n_plus_one: false,
54
+ confidence_score: 0,
55
+ evidence: [],
56
+ suggested_fixes: [],
57
+ execution_patterns: {}
58
+ }
59
+ end
60
+
61
+ def group_operations_by_request
62
+ groups = []
63
+ current_group = []
64
+
65
+ sorted_operations = operations.sort_by(&:occurred_at)
66
+
67
+ sorted_operations.each do |operation|
68
+ if current_group.empty? || time_within_window?(operation, current_group.last)
69
+ current_group << operation
70
+ else
71
+ groups << current_group if current_group.size > 1
72
+ current_group = [ operation ]
73
+ end
74
+ end
75
+
76
+ groups << current_group if current_group.size > 1
77
+ groups
78
+ end
79
+
80
+ def time_within_window?(operation, last_operation)
81
+ (operation.occurred_at - last_operation.occurred_at) < REQUEST_GROUPING_WINDOW
82
+ end
83
+
84
+ def analyze_request_pattern(operations_group)
85
+ analysis = {
86
+ repetitive_queries: false,
87
+ confidence: 0,
88
+ evidence: [],
89
+ fixes: []
90
+ }
91
+
92
+ # Look for repeated similar queries
93
+ normalized_queries = operations_group.map { |op| normalize_sql_for_pattern_detection(op.label) }
94
+ query_counts = normalized_queries.tally
95
+
96
+ query_counts.each do |normalized_query, count|
97
+ next unless count >= REPETITION_THRESHOLD
98
+
99
+ analysis[:repetitive_queries] = true
100
+ analysis[:confidence] += count * 10 # Higher repetition = higher confidence
101
+
102
+ analysis[:evidence] << {
103
+ type: "repetitive_query",
104
+ description: "Query executed #{count} times in single request",
105
+ query_pattern: normalized_query,
106
+ occurrences: count
107
+ }
108
+
109
+ # Detect specific N+1 patterns
110
+ if single_record_lookup_pattern?(normalized_query)
111
+ analysis[:evidence] << {
112
+ type: "single_record_lookup",
113
+ description: "Single record lookup pattern suggests missing eager loading",
114
+ query_pattern: normalized_query
115
+ }
116
+
117
+ analysis[:fixes] << {
118
+ type: "eager_loading",
119
+ description: "Use includes() or preload() to load associated records",
120
+ code_example: detect_association_from_query(normalized_query)
121
+ }
122
+ end
123
+ end
124
+
125
+ analysis
126
+ end
127
+
128
+ def single_record_lookup_pattern?(normalized_query)
129
+ normalized_query.match?(/SELECT.*FROM.*WHERE.*=\s*\?/i)
130
+ end
131
+
132
+ def analyze_execution_patterns
133
+ return {} if operations.empty?
134
+
135
+ {
136
+ total_executions: operations.count,
137
+ time_span_minutes: time_span_in_minutes,
138
+ executions_per_minute: calculate_executions_per_minute,
139
+ peak_execution_periods: find_peak_execution_periods,
140
+ common_execution_contexts: extract_execution_contexts
141
+ }
142
+ end
143
+
144
+ def time_span_in_minutes
145
+ return 0 if operations.count < 2
146
+ ((operations.last.occurred_at - operations.first.occurred_at) / 1.minute).round(2)
147
+ end
148
+
149
+ def calculate_executions_per_minute
150
+ return 0 if operations.count < 2
151
+
152
+ time_span = operations.last.occurred_at - operations.first.occurred_at
153
+ return operations.count if time_span <= 0
154
+
155
+ (operations.count / (time_span / 1.minute)).round(2)
156
+ end
157
+
158
+ def find_peak_execution_periods
159
+ # Group by 5-minute windows and find peaks
160
+ windows = operations.group_by { |op| (op.occurred_at.to_i / 300) * 300 }
161
+ return [] if windows.empty?
162
+
163
+ avg_per_window = windows.values.sum(&:count).to_f / windows.count
164
+
165
+ windows.select { |_, ops| ops.count > avg_per_window * 1.5 }.map do |timestamp, ops|
166
+ {
167
+ period: Time.at(timestamp).strftime("%Y-%m-%d %H:%M"),
168
+ executions: ops.count,
169
+ above_average_by: ((ops.count - avg_per_window) / avg_per_window * 100).round(1)
170
+ }
171
+ end
172
+ end
173
+
174
+ def extract_execution_contexts
175
+ contexts = operations.filter_map(&:codebase_location).compact
176
+ return {} if contexts.empty?
177
+
178
+ # Extract controller/model patterns
179
+ controller_actions = extract_controller_actions(contexts)
180
+ model_methods = extract_model_methods(contexts)
181
+
182
+ {
183
+ controller_actions: controller_actions.tally,
184
+ model_methods: model_methods.tally,
185
+ unique_locations: contexts.uniq.count,
186
+ total_contexts: contexts.count
187
+ }
188
+ end
189
+
190
+ def extract_controller_actions(contexts)
191
+ contexts.filter_map do |context|
192
+ match = context.match(%r{app/controllers/(.+?)#(.+)})
193
+ "#{match[1]}##{match[2]}" if match
194
+ end
195
+ end
196
+
197
+ def extract_model_methods(contexts)
198
+ contexts.filter_map do |context|
199
+ match = context.match(%r{app/models/(.+?)\.rb.*in `(.+?)'})
200
+ "#{match[1]}.#{match[2]}" if match
201
+ end
202
+ end
203
+
204
+ def generate_activerecord_fixes
205
+ [
206
+ {
207
+ type: "includes",
208
+ description: "Use includes() to eager load associations",
209
+ code_example: "User.includes(:posts).where(active: true)"
210
+ },
211
+ {
212
+ type: "preload",
213
+ description: "Use preload() when you don't need to query on associations",
214
+ code_example: "User.preload(:posts).limit(10)"
215
+ },
216
+ {
217
+ type: "joins",
218
+ description: "Use joins() when you only need to filter, not access associated data",
219
+ code_example: "User.joins(:posts).where(posts: { published: true })"
220
+ }
221
+ ]
222
+ end
223
+
224
+ def detect_association_from_query(normalized_query)
225
+ # Try to extract table/column info to suggest specific associations
226
+ match = normalized_query.match(/from\s+(\w+).*where\s+(\w+)\s*=/)
227
+ return "Model.includes(:association)" unless match
228
+
229
+ table = match[1]
230
+ column = match[2]
231
+
232
+ if column.end_with?("_id")
233
+ association = column.gsub("_id", "")
234
+ return "#{table.classify}.includes(:#{association})"
235
+ end
236
+
237
+ "Model.includes(:association)"
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,146 @@
1
+ # Analyzes SQL query structure and complexity.
2
+ # Detects query type, table joins, WHERE complexity, and common anti-patterns like SELECT * or missing LIMIT.
3
+ module RailsPulse
4
+ module Analysis
5
+ class QueryCharacteristicsAnalyzer < BaseAnalyzer
6
+ def analyze
7
+ {
8
+ query_type: detect_query_type,
9
+ table_count: count_tables,
10
+ join_count: count_joins,
11
+ where_clause_complexity: analyze_where_complexity,
12
+ has_subqueries: has_subqueries?,
13
+ has_limit: has_limit?,
14
+ has_order_by: has_order_by?,
15
+ has_group_by: has_group_by?,
16
+ has_having: has_having?,
17
+ has_distinct: has_distinct?,
18
+ has_aggregations: has_aggregations?,
19
+ estimated_complexity: calculate_complexity_score,
20
+ pattern_issues: detect_pattern_issues
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def detect_query_type
27
+ case sql.strip.upcase
28
+ when /^SELECT/ then "SELECT"
29
+ when /^INSERT/ then "INSERT"
30
+ when /^UPDATE/ then "UPDATE"
31
+ when /^DELETE/ then "DELETE"
32
+ when /^CREATE/ then "CREATE"
33
+ when /^DROP/ then "DROP"
34
+ when /^ALTER/ then "ALTER"
35
+ else "UNKNOWN"
36
+ end
37
+ end
38
+
39
+ def count_tables
40
+ tables = []
41
+ tables.concat(sql.scan(/FROM\s+(\w+)/i).flatten)
42
+ tables.concat(sql.scan(/JOIN\s+(\w+)/i).flatten)
43
+ tables.uniq.length
44
+ end
45
+
46
+ def count_joins
47
+ sql.scan(/\bJOIN\b/i).length
48
+ end
49
+
50
+ def analyze_where_complexity
51
+ where_clause = extract_where_clause
52
+ return 0 unless where_clause
53
+
54
+ condition_count = where_clause.scan(/\bAND\b|\bOR\b/i).length + 1
55
+ function_count = where_clause.scan(/\w+\s*\(/).length
56
+
57
+ condition_count + (function_count * 2)
58
+ end
59
+
60
+ def has_subqueries?
61
+ sql.include?("(SELECT")
62
+ end
63
+
64
+ def has_limit?
65
+ sql.match?(/\bLIMIT\s+\d+/i)
66
+ end
67
+
68
+ def has_order_by?
69
+ sql.include?("ORDER BY")
70
+ end
71
+
72
+ def has_group_by?
73
+ sql.include?("GROUP BY")
74
+ end
75
+
76
+ def has_having?
77
+ sql.include?("HAVING")
78
+ end
79
+
80
+ def has_distinct?
81
+ sql.include?("DISTINCT")
82
+ end
83
+
84
+ def has_aggregations?
85
+ sql.match?(/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i)
86
+ end
87
+
88
+ def calculate_complexity_score
89
+ score = 0
90
+ score += count_tables * 2
91
+ score += count_joins * 3
92
+ score += analyze_where_complexity
93
+ score += sql.scan(/\bUNION\b/i).length * 4
94
+ score += sql.scan(/\(SELECT/i).length * 5
95
+ score
96
+ end
97
+
98
+ def detect_pattern_issues
99
+ issues = []
100
+
101
+ # Missing WHERE clause on SELECT
102
+ if sql.match?(/^SELECT.*FROM.*(?!WHERE)/i) && !has_limit?
103
+ issues << {
104
+ type: "missing_where_clause",
105
+ severity: "warning",
106
+ description: "SELECT query without WHERE clause may return excessive data",
107
+ impact: "Performance degradation from full table scans"
108
+ }
109
+ end
110
+
111
+ # SELECT * usage
112
+ if sql.include?("SELECT *")
113
+ issues << {
114
+ type: "select_star",
115
+ severity: "info",
116
+ description: "Using SELECT * may retrieve unnecessary columns",
117
+ impact: "Increased memory usage and network transfer"
118
+ }
119
+ end
120
+
121
+ # Missing LIMIT on potentially large results
122
+ if sql.match?(/^SELECT.*FROM.*WHERE/i) && !has_limit? && !sql.include?("COUNT")
123
+ issues << {
124
+ type: "missing_limit",
125
+ severity: "warning",
126
+ description: "Query may return large result sets without LIMIT",
127
+ impact: "Memory exhaustion and slow response times"
128
+ }
129
+ end
130
+
131
+ # Complex WHERE clauses
132
+ where_clause = extract_where_clause
133
+ if where_clause && where_clause.scan(/\bAND\b|\bOR\b/i).length > 5
134
+ issues << {
135
+ type: "complex_where_clause",
136
+ severity: "warning",
137
+ description: "Complex WHERE clause with many conditions",
138
+ impact: "Difficult to optimize and maintain"
139
+ }
140
+ end
141
+
142
+ issues
143
+ end
144
+ end
145
+ end
146
+ end