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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -20
  3. data/Rakefile +169 -86
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  14. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  15. data/app/controllers/rails_pulse/queries_controller.rb +49 -10
  16. data/app/controllers/rails_pulse/requests_controller.rb +46 -20
  17. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +16 -8
  20. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  21. data/app/javascript/rails_pulse/application.js +34 -3
  22. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  23. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  24. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  25. data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
  26. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  27. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  28. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  29. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  30. data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
  31. data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
  32. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
  33. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  34. data/app/models/rails_pulse/query.rb +46 -0
  35. data/app/models/rails_pulse/request.rb +1 -1
  36. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  37. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  38. data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
  39. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
  40. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
  41. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
  42. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  43. data/app/models/rails_pulse/summary.rb +7 -7
  44. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  45. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  46. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  47. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  48. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  49. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
  50. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  51. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  52. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  53. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  54. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  55. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  56. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  57. data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
  58. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  59. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  60. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  61. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  62. data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
  63. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  64. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  65. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  66. data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
  67. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  68. data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
  69. data/app/views/rails_pulse/queries/_table.html.erb +4 -8
  70. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  71. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  72. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  73. data/app/views/rails_pulse/requests/_table.html.erb +31 -18
  74. data/app/views/rails_pulse/requests/index.html.erb +55 -50
  75. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  76. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  77. data/app/views/rails_pulse/routes/_table.html.erb +4 -10
  78. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  79. data/app/views/rails_pulse/routes/show.html.erb +6 -8
  80. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  81. data/config/routes.rb +5 -1
  82. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  83. data/db/rails_pulse_schema.rb +10 -1
  84. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
  85. data/lib/generators/rails_pulse/install_generator.rb +75 -18
  86. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  87. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
  88. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  89. data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
  90. data/lib/rails_pulse/engine.rb +21 -0
  91. data/lib/rails_pulse/version.rb +1 -1
  92. data/lib/tasks/rails_pulse.rake +27 -8
  93. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  94. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  96. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  97. metadata +25 -6
  98. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  99. data/app/assets/images/rails_pulse/routes.png +0 -0
  100. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  101. 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