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.
- checksums.yaml +4 -4
- data/README.md +10 -4
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -5
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- data/app/controllers/concerns/zoom_range_concern.rb +31 -0
- data/app/controllers/rails_pulse/application_controller.rb +5 -1
- data/app/controllers/rails_pulse/queries_controller.rb +46 -1
- data/app/controllers/rails_pulse/requests_controller.rb +14 -1
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/chart_helper.rb +15 -7
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
- data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +0 -2
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/index.html.erb +48 -51
- data/app/views/rails_pulse/queries/show.html.erb +56 -52
- data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
- data/app/views/rails_pulse/requests/_table.html.erb +3 -1
- data/app/views/rails_pulse/requests/index.html.erb +48 -51
- data/app/views/rails_pulse/routes/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +4 -4
- data/config/routes.rb +5 -1
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +9 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +71 -18
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +27 -8
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +23 -5
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- 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
|