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,67 @@
|
|
1
|
+
# Base class providing common utilities for all query analyzers.
|
2
|
+
# Handles database adapter detection, SQL parsing, and normalization.
|
3
|
+
module RailsPulse
|
4
|
+
module Analysis
|
5
|
+
class BaseAnalyzer
|
6
|
+
attr_reader :query, :operations
|
7
|
+
|
8
|
+
def initialize(query, operations = [])
|
9
|
+
@query = query
|
10
|
+
@operations = Array(operations)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Each analyzer must implement this method
|
14
|
+
def analyze
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #analyze"
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def sql
|
21
|
+
@sql ||= query.normalized_sql
|
22
|
+
end
|
23
|
+
|
24
|
+
def recent_operations
|
25
|
+
@recent_operations ||= operations.select { |op| op.occurred_at > 48.hours.ago }
|
26
|
+
end
|
27
|
+
|
28
|
+
# Utility method for database adapter detection
|
29
|
+
def database_adapter
|
30
|
+
@database_adapter ||= RailsPulse::ApplicationRecord.connection.adapter_name.downcase
|
31
|
+
end
|
32
|
+
|
33
|
+
def postgresql?
|
34
|
+
database_adapter == "postgresql"
|
35
|
+
end
|
36
|
+
|
37
|
+
def mysql?
|
38
|
+
database_adapter.in?([ "mysql", "mysql2" ])
|
39
|
+
end
|
40
|
+
|
41
|
+
def sqlite?
|
42
|
+
database_adapter == "sqlite"
|
43
|
+
end
|
44
|
+
|
45
|
+
# Common SQL parsing utilities
|
46
|
+
def extract_main_table(sql_string = sql)
|
47
|
+
match = sql_string.match(/FROM\s+(\w+)/i)
|
48
|
+
match ? match[1] : nil
|
49
|
+
end
|
50
|
+
|
51
|
+
def extract_where_clause(sql_string = sql)
|
52
|
+
match = sql_string.match(/WHERE\s+(.+?)(?:\s+ORDER\s+BY|\s+GROUP\s+BY|\s+LIMIT|\s*$)/i)
|
53
|
+
match ? match[1] : nil
|
54
|
+
end
|
55
|
+
|
56
|
+
def normalize_sql_for_pattern_detection(sql_string)
|
57
|
+
return "" unless sql_string.present?
|
58
|
+
|
59
|
+
sql_string.gsub(/\d+/, "?") # Replace numbers with placeholders
|
60
|
+
.gsub(/'[^']*'/, "?") # Replace strings with placeholders
|
61
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
62
|
+
.strip
|
63
|
+
.downcase
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
# Executes database EXPLAIN commands and analyzes query execution plans.
|
2
|
+
# Detects sequential scans, temporary tables, high-cost operations, and database-specific performance issues.
|
3
|
+
module RailsPulse
|
4
|
+
module Analysis
|
5
|
+
class ExplainPlanAnalyzer < BaseAnalyzer
|
6
|
+
EXPLAIN_TIMEOUT = 5.seconds
|
7
|
+
|
8
|
+
def analyze
|
9
|
+
return { explain_plan: nil, issues: [] } if recent_operations.empty?
|
10
|
+
|
11
|
+
actual_sql = recent_operations.first.label
|
12
|
+
explain_plan = generate_explain_plan(actual_sql)
|
13
|
+
|
14
|
+
{
|
15
|
+
explain_plan: explain_plan,
|
16
|
+
issues: detect_explain_issues(explain_plan)
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def generate_explain_plan(sql)
|
23
|
+
return nil unless sql.present?
|
24
|
+
|
25
|
+
# Skip EXPLAIN queries in test environment to avoid transaction issues
|
26
|
+
return nil if Rails.env.test?
|
27
|
+
|
28
|
+
begin
|
29
|
+
sanitized_sql = sanitize_sql_for_explain(sql)
|
30
|
+
|
31
|
+
Timeout.timeout(EXPLAIN_TIMEOUT) do
|
32
|
+
case database_adapter
|
33
|
+
when "postgresql"
|
34
|
+
execute_postgres_explain(sanitized_sql)
|
35
|
+
when "mysql", "mysql2"
|
36
|
+
execute_mysql_explain(sanitized_sql)
|
37
|
+
when "sqlite"
|
38
|
+
execute_sqlite_explain(sanitized_sql)
|
39
|
+
else
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
rescue => e
|
44
|
+
Rails.logger.warn("[ExplainPlanAnalyzer] EXPLAIN failed for query #{query.id}: #{e.message}")
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def detect_explain_issues(explain_plan)
|
50
|
+
return [] unless explain_plan.present?
|
51
|
+
|
52
|
+
issues = []
|
53
|
+
|
54
|
+
# Look for common issues in EXPLAIN output
|
55
|
+
if sequential_scan?(explain_plan)
|
56
|
+
issues << {
|
57
|
+
type: "sequential_scan",
|
58
|
+
severity: "warning",
|
59
|
+
description: "Query performs sequential/table scan",
|
60
|
+
impact: "Poor performance on large tables"
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
if temporary_operations?(explain_plan)
|
65
|
+
issues << {
|
66
|
+
type: "temporary_table",
|
67
|
+
severity: "warning",
|
68
|
+
description: "Query uses temporary tables or filesort",
|
69
|
+
impact: "Increased memory usage and processing time"
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
# Database-specific analysis
|
74
|
+
case database_adapter
|
75
|
+
when "postgresql"
|
76
|
+
issues.concat(analyze_postgres_specific_issues(explain_plan))
|
77
|
+
when "mysql", "mysql2"
|
78
|
+
issues.concat(analyze_mysql_specific_issues(explain_plan))
|
79
|
+
when "sqlite"
|
80
|
+
issues.concat(analyze_sqlite_specific_issues(explain_plan))
|
81
|
+
end
|
82
|
+
|
83
|
+
issues
|
84
|
+
end
|
85
|
+
|
86
|
+
def sequential_scan?(explain_plan)
|
87
|
+
explain_plan.downcase.include?("seq scan") ||
|
88
|
+
explain_plan.downcase.include?("table scan") ||
|
89
|
+
explain_plan.downcase.include?("full table scan")
|
90
|
+
end
|
91
|
+
|
92
|
+
def temporary_operations?(explain_plan)
|
93
|
+
explain_plan.downcase.include?("temporary") ||
|
94
|
+
explain_plan.downcase.include?("filesort") ||
|
95
|
+
explain_plan.downcase.include?("using temporary")
|
96
|
+
end
|
97
|
+
|
98
|
+
def analyze_postgres_specific_issues(explain_plan)
|
99
|
+
issues = []
|
100
|
+
|
101
|
+
# High cost operations
|
102
|
+
if explain_plan.match(/cost=(\d+\.\d+)\.\.(\d+\.\d+)/)
|
103
|
+
total_cost = $2.to_f
|
104
|
+
if total_cost > 1000
|
105
|
+
issues << {
|
106
|
+
type: "high_cost_operation",
|
107
|
+
severity: "warning",
|
108
|
+
description: "Query has high execution cost (#{total_cost.round(2)})",
|
109
|
+
impact: "May indicate need for optimization or indexing"
|
110
|
+
}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Hash joins on large datasets
|
115
|
+
if explain_plan.include?("Hash Join") && explain_plan.match(/rows=(\d+)/)
|
116
|
+
rows = $1.to_i
|
117
|
+
if rows > 10000
|
118
|
+
issues << {
|
119
|
+
type: "large_hash_join",
|
120
|
+
severity: "info",
|
121
|
+
description: "Hash join on large dataset (#{rows} rows)",
|
122
|
+
impact: "High memory usage during query execution"
|
123
|
+
}
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
issues
|
128
|
+
end
|
129
|
+
|
130
|
+
def analyze_mysql_specific_issues(explain_plan)
|
131
|
+
issues = []
|
132
|
+
|
133
|
+
# Using where with no index
|
134
|
+
if explain_plan.include?("Using where") && !explain_plan.include?("Using index")
|
135
|
+
issues << {
|
136
|
+
type: "where_without_index",
|
137
|
+
severity: "warning",
|
138
|
+
description: "WHERE clause not using index efficiently",
|
139
|
+
impact: "Slower query execution due to row-by-row filtering"
|
140
|
+
}
|
141
|
+
end
|
142
|
+
|
143
|
+
# Full table scan with large row count
|
144
|
+
if explain_plan.match(/type: ALL.*rows: (\d+)/)
|
145
|
+
rows = $1.to_i
|
146
|
+
if rows > 1000
|
147
|
+
issues << {
|
148
|
+
type: "full_scan_large_table",
|
149
|
+
severity: "warning",
|
150
|
+
description: "Full table scan on table with #{rows} rows",
|
151
|
+
impact: "Very slow query execution on large dataset"
|
152
|
+
}
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
issues
|
157
|
+
end
|
158
|
+
|
159
|
+
def analyze_sqlite_specific_issues(explain_plan)
|
160
|
+
issues = []
|
161
|
+
|
162
|
+
# SCAN TABLE operations
|
163
|
+
if explain_plan.include?("SCAN TABLE")
|
164
|
+
issues << {
|
165
|
+
type: "table_scan",
|
166
|
+
severity: "warning",
|
167
|
+
description: "SQLite performing table scan",
|
168
|
+
impact: "Linear search through all table rows"
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
172
|
+
# Missing index usage
|
173
|
+
if explain_plan.include?("USING INDEX") == false && explain_plan.include?("WHERE")
|
174
|
+
issues << {
|
175
|
+
type: "no_index_usage",
|
176
|
+
severity: "info",
|
177
|
+
description: "Query not utilizing available indexes",
|
178
|
+
impact: "Potential for optimization with proper indexing"
|
179
|
+
}
|
180
|
+
end
|
181
|
+
|
182
|
+
issues
|
183
|
+
end
|
184
|
+
|
185
|
+
def sanitize_sql_for_explain(sql)
|
186
|
+
# Basic sanitization for EXPLAIN
|
187
|
+
sql.strip.gsub(/;+\s*$/, "")
|
188
|
+
end
|
189
|
+
|
190
|
+
def execute_postgres_explain(sql)
|
191
|
+
result = RailsPulse::ApplicationRecord.connection.execute("EXPLAIN (ANALYZE, BUFFERS) #{sql}")
|
192
|
+
result.values.flatten.join("\n")
|
193
|
+
end
|
194
|
+
|
195
|
+
def execute_mysql_explain(sql)
|
196
|
+
result = RailsPulse::ApplicationRecord.connection.execute("EXPLAIN #{sql}")
|
197
|
+
result.to_a.map { |row| row.values.join(" | ") }.join("\n")
|
198
|
+
end
|
199
|
+
|
200
|
+
def execute_sqlite_explain(sql)
|
201
|
+
result = RailsPulse::ApplicationRecord.connection.execute("EXPLAIN QUERY PLAN #{sql}")
|
202
|
+
result.map { |row| row.values.join(" | ") }.join("\n")
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -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
|