rails_pulse 0.1.2 → 0.1.4
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 +66 -20
- data/Rakefile +169 -86
- 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 +49 -10
- data/app/controllers/rails_pulse/requests_controller.rb +46 -20
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
- data/app/helpers/rails_pulse/chart_helper.rb +16 -8
- data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
- 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 +249 -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/dashboard/tables/slow_queries.rb +1 -1
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
- data/app/models/rails_pulse/requests/tables/index.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
- data/app/models/rails_pulse/routes/tables/index.rb +4 -2
- data/app/models/rails_pulse/summary.rb +7 -7
- 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 +154 -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 +28 -5
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- 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 +2 -2
- 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 +117 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
- data/app/views/rails_pulse/queries/_table.html.erb +4 -8
- 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 +31 -18
- data/app/views/rails_pulse/requests/index.html.erb +55 -50
- data/app/views/rails_pulse/requests/show.html.erb +0 -2
- data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
- data/app/views/rails_pulse/routes/_table.html.erb +4 -10
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +6 -8
- data/config/initializers/rails_charts_csp_patch.rb +32 -40
- data/config/routes.rb +5 -1
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
- data/db/rails_pulse_schema.rb +10 -1
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
- data/lib/generators/rails_pulse/install_generator.rb +75 -18
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
- data/lib/rails_pulse/engine.rb +21 -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 +25 -6
- 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
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -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,154 @@
|
|
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
|
+
|
42
|
+
# Match FROM clause with various table name formats
|
43
|
+
# Handles: table_name, schema.table, "quoted_table", `backtick_table`
|
44
|
+
tables.concat(sql.scan(/FROM\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
|
45
|
+
|
46
|
+
# Match JOIN clauses (INNER JOIN, LEFT JOIN, etc.)
|
47
|
+
tables.concat(sql.scan(/(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
|
48
|
+
|
49
|
+
# Remove schema prefixes for uniqueness check (schema.table -> table)
|
50
|
+
normalized_tables = tables.map { |table| table.split(".").last }
|
51
|
+
normalized_tables.uniq.length
|
52
|
+
end
|
53
|
+
|
54
|
+
def count_joins
|
55
|
+
sql.scan(/\bJOIN\b/i).length
|
56
|
+
end
|
57
|
+
|
58
|
+
def analyze_where_complexity
|
59
|
+
where_clause = extract_where_clause
|
60
|
+
return 0 unless where_clause
|
61
|
+
|
62
|
+
condition_count = where_clause.scan(/\bAND\b|\bOR\b/i).length + 1
|
63
|
+
function_count = where_clause.scan(/\w+\s*\(/).length
|
64
|
+
|
65
|
+
condition_count + (function_count * 2)
|
66
|
+
end
|
67
|
+
|
68
|
+
def has_subqueries?
|
69
|
+
sql.include?("(SELECT")
|
70
|
+
end
|
71
|
+
|
72
|
+
def has_limit?
|
73
|
+
sql.match?(/\bLIMIT\s+\d+/i)
|
74
|
+
end
|
75
|
+
|
76
|
+
def has_order_by?
|
77
|
+
sql.include?("ORDER BY")
|
78
|
+
end
|
79
|
+
|
80
|
+
def has_group_by?
|
81
|
+
sql.include?("GROUP BY")
|
82
|
+
end
|
83
|
+
|
84
|
+
def has_having?
|
85
|
+
sql.include?("HAVING")
|
86
|
+
end
|
87
|
+
|
88
|
+
def has_distinct?
|
89
|
+
sql.include?("DISTINCT")
|
90
|
+
end
|
91
|
+
|
92
|
+
def has_aggregations?
|
93
|
+
sql.match?(/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i)
|
94
|
+
end
|
95
|
+
|
96
|
+
def calculate_complexity_score
|
97
|
+
score = 0
|
98
|
+
score += count_tables * 2
|
99
|
+
score += count_joins * 3
|
100
|
+
score += analyze_where_complexity
|
101
|
+
score += sql.scan(/\bUNION\b/i).length * 4
|
102
|
+
score += sql.scan(/\(SELECT/i).length * 5
|
103
|
+
score
|
104
|
+
end
|
105
|
+
|
106
|
+
def detect_pattern_issues
|
107
|
+
issues = []
|
108
|
+
|
109
|
+
# Missing WHERE clause on SELECT
|
110
|
+
if sql.match?(/^SELECT.*FROM.*(?!WHERE)/i) && !has_limit?
|
111
|
+
issues << {
|
112
|
+
type: "missing_where_clause",
|
113
|
+
severity: "warning",
|
114
|
+
description: "SELECT query without WHERE clause may return excessive data",
|
115
|
+
impact: "Performance degradation from full table scans"
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
# SELECT * usage
|
120
|
+
if sql.include?("SELECT *")
|
121
|
+
issues << {
|
122
|
+
type: "select_star",
|
123
|
+
severity: "info",
|
124
|
+
description: "Using SELECT * may retrieve unnecessary columns",
|
125
|
+
impact: "Increased memory usage and network transfer"
|
126
|
+
}
|
127
|
+
end
|
128
|
+
|
129
|
+
# Missing LIMIT on potentially large results
|
130
|
+
if sql.match?(/^SELECT.*FROM.*WHERE/i) && !has_limit? && !sql.include?("COUNT")
|
131
|
+
issues << {
|
132
|
+
type: "missing_limit",
|
133
|
+
severity: "warning",
|
134
|
+
description: "Query may return large result sets without LIMIT",
|
135
|
+
impact: "Memory exhaustion and slow response times"
|
136
|
+
}
|
137
|
+
end
|
138
|
+
|
139
|
+
# Complex WHERE clauses
|
140
|
+
where_clause = extract_where_clause
|
141
|
+
if where_clause && where_clause.scan(/\bAND\b|\bOR\b/i).length > 5
|
142
|
+
issues << {
|
143
|
+
type: "complex_where_clause",
|
144
|
+
severity: "warning",
|
145
|
+
description: "Complex WHERE clause with many conditions",
|
146
|
+
impact: "Difficult to optimize and maintain"
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
issues
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,217 @@
|
|
1
|
+
# Consolidates analysis results into prioritized, actionable optimization suggestions.
|
2
|
+
# Combines insights from all analyzers and categorizes suggestions by impact and implementation complexity.
|
3
|
+
module RailsPulse
|
4
|
+
module Analysis
|
5
|
+
class SuggestionGenerator
|
6
|
+
attr_reader :analysis_results
|
7
|
+
|
8
|
+
def initialize(analysis_results)
|
9
|
+
@analysis_results = analysis_results
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate
|
13
|
+
suggestions = []
|
14
|
+
|
15
|
+
# Suggestions from pattern issues
|
16
|
+
suggestions.concat(generate_issue_suggestions)
|
17
|
+
|
18
|
+
# Suggestions from index recommendations
|
19
|
+
suggestions.concat(generate_index_suggestions)
|
20
|
+
|
21
|
+
# Suggestions from N+1 analysis
|
22
|
+
suggestions.concat(generate_n_plus_one_suggestions)
|
23
|
+
|
24
|
+
# Suggestions from query characteristics
|
25
|
+
suggestions.concat(generate_query_characteristic_suggestions)
|
26
|
+
|
27
|
+
# Suggestions from explain plan issues
|
28
|
+
suggestions.concat(generate_explain_plan_suggestions)
|
29
|
+
|
30
|
+
# Prioritize and deduplicate suggestions
|
31
|
+
prioritize_suggestions(suggestions)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def generate_issue_suggestions
|
37
|
+
issues = analysis_results.dig(:query_characteristics, :pattern_issues) || []
|
38
|
+
|
39
|
+
issues.map do |issue|
|
40
|
+
case issue[:type]
|
41
|
+
when "select_star"
|
42
|
+
{
|
43
|
+
type: "optimization",
|
44
|
+
action: "Specify only needed columns instead of SELECT *",
|
45
|
+
benefit: "Reduced memory usage and faster data transfer",
|
46
|
+
priority: "medium",
|
47
|
+
category: "sql_optimization"
|
48
|
+
}
|
49
|
+
when "missing_limit"
|
50
|
+
{
|
51
|
+
type: "optimization",
|
52
|
+
action: "Add LIMIT clause to prevent large result sets",
|
53
|
+
benefit: "Controlled memory usage and faster response times",
|
54
|
+
priority: "high",
|
55
|
+
category: "sql_optimization"
|
56
|
+
}
|
57
|
+
when "missing_where_clause"
|
58
|
+
{
|
59
|
+
type: "optimization",
|
60
|
+
action: "Add WHERE clause to filter results",
|
61
|
+
benefit: "Avoid full table scans and reduce data transfer",
|
62
|
+
priority: "high",
|
63
|
+
category: "sql_optimization"
|
64
|
+
}
|
65
|
+
when "complex_where_clause"
|
66
|
+
{
|
67
|
+
type: "refactoring",
|
68
|
+
action: "Simplify WHERE clause by breaking into multiple queries or using views",
|
69
|
+
benefit: "Easier maintenance and potentially better performance",
|
70
|
+
priority: "medium",
|
71
|
+
category: "code_quality"
|
72
|
+
}
|
73
|
+
end
|
74
|
+
end.compact
|
75
|
+
end
|
76
|
+
|
77
|
+
def generate_index_suggestions
|
78
|
+
recommendations = analysis_results[:index_recommendations] || []
|
79
|
+
|
80
|
+
recommendations.map do |rec|
|
81
|
+
{
|
82
|
+
type: "index",
|
83
|
+
action: "Add #{rec[:type]} index: #{rec[:migration_code]}",
|
84
|
+
benefit: rec[:estimated_benefit],
|
85
|
+
priority: rec[:priority],
|
86
|
+
migration_code: rec[:migration_code],
|
87
|
+
table: rec[:table],
|
88
|
+
columns: rec[:columns],
|
89
|
+
category: "database_optimization"
|
90
|
+
}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def generate_n_plus_one_suggestions
|
95
|
+
n_plus_one = analysis_results[:n_plus_one_analysis] || {}
|
96
|
+
return [] unless n_plus_one[:is_likely_n_plus_one]
|
97
|
+
|
98
|
+
suggestions = n_plus_one[:suggested_fixes] || []
|
99
|
+
|
100
|
+
suggestions.map do |fix|
|
101
|
+
{
|
102
|
+
type: "n_plus_one",
|
103
|
+
action: fix[:description],
|
104
|
+
benefit: "Eliminate N+1 queries and reduce database load",
|
105
|
+
priority: "high",
|
106
|
+
code_example: fix[:code_example],
|
107
|
+
confidence: n_plus_one[:confidence_score],
|
108
|
+
category: "performance_critical"
|
109
|
+
}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def generate_query_characteristic_suggestions
|
114
|
+
stats = analysis_results[:query_characteristics] || {}
|
115
|
+
suggestions = []
|
116
|
+
|
117
|
+
if stats[:join_count] && stats[:join_count] > 3
|
118
|
+
suggestions << {
|
119
|
+
type: "optimization",
|
120
|
+
action: "Review if all #{stats[:join_count]} JOINs are necessary",
|
121
|
+
benefit: "Simplified query execution and better performance",
|
122
|
+
priority: "medium",
|
123
|
+
category: "sql_optimization"
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
if stats[:estimated_complexity] && stats[:estimated_complexity] > 10
|
128
|
+
suggestions << {
|
129
|
+
type: "refactoring",
|
130
|
+
action: "Consider breaking complex query (complexity: #{stats[:estimated_complexity]}) into smaller parts",
|
131
|
+
benefit: "Easier maintenance and potentially better performance",
|
132
|
+
priority: "medium",
|
133
|
+
category: "code_quality"
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
if stats[:has_subqueries] && stats[:join_count] && stats[:join_count] > 1
|
138
|
+
suggestions << {
|
139
|
+
type: "optimization",
|
140
|
+
action: "Consider converting subqueries to JOINs for better performance",
|
141
|
+
benefit: "More efficient query execution in most databases",
|
142
|
+
priority: "medium",
|
143
|
+
category: "sql_optimization"
|
144
|
+
}
|
145
|
+
end
|
146
|
+
|
147
|
+
suggestions
|
148
|
+
end
|
149
|
+
|
150
|
+
def generate_explain_plan_suggestions
|
151
|
+
explain_issues = analysis_results.dig(:explain_plan, :issues) || []
|
152
|
+
|
153
|
+
explain_issues.map do |issue|
|
154
|
+
case issue[:type]
|
155
|
+
when "sequential_scan"
|
156
|
+
{
|
157
|
+
type: "index",
|
158
|
+
action: "Consider adding database indexes for WHERE clause columns",
|
159
|
+
benefit: "Dramatically faster query execution",
|
160
|
+
priority: "high",
|
161
|
+
category: "database_optimization"
|
162
|
+
}
|
163
|
+
when "temporary_table"
|
164
|
+
{
|
165
|
+
type: "optimization",
|
166
|
+
action: "Optimize query to avoid temporary tables and filesort operations",
|
167
|
+
benefit: "Reduced memory usage and faster execution",
|
168
|
+
priority: "medium",
|
169
|
+
category: "sql_optimization"
|
170
|
+
}
|
171
|
+
when "high_cost_operation"
|
172
|
+
{
|
173
|
+
type: "optimization",
|
174
|
+
action: "Review query execution plan for high-cost operations",
|
175
|
+
benefit: "Identify specific bottlenecks for targeted optimization",
|
176
|
+
priority: "high",
|
177
|
+
category: "performance_critical"
|
178
|
+
}
|
179
|
+
when "where_without_index"
|
180
|
+
{
|
181
|
+
type: "index",
|
182
|
+
action: "Add indexes to support WHERE clause conditions",
|
183
|
+
benefit: "Eliminate row-by-row filtering during query execution",
|
184
|
+
priority: "high",
|
185
|
+
category: "database_optimization"
|
186
|
+
}
|
187
|
+
end
|
188
|
+
end.compact
|
189
|
+
end
|
190
|
+
|
191
|
+
def prioritize_suggestions(suggestions)
|
192
|
+
# Remove duplicates based on action
|
193
|
+
unique_suggestions = suggestions.uniq { |s| s[:action] }
|
194
|
+
|
195
|
+
# Sort by priority and category
|
196
|
+
unique_suggestions.sort_by do |suggestion|
|
197
|
+
priority_score = case suggestion[:priority]
|
198
|
+
when "high" then 3
|
199
|
+
when "medium" then 2
|
200
|
+
when "low" then 1
|
201
|
+
else 0
|
202
|
+
end
|
203
|
+
|
204
|
+
category_score = case suggestion[:category]
|
205
|
+
when "performance_critical" then 4
|
206
|
+
when "database_optimization" then 3
|
207
|
+
when "sql_optimization" then 2
|
208
|
+
when "code_quality" then 1
|
209
|
+
else 0
|
210
|
+
end
|
211
|
+
|
212
|
+
[ -priority_score, -category_score, suggestion[:action] ]
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|