sql_genius 0.9.0

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +195 -0
  3. data/LICENSE.txt +65 -0
  4. data/README.md +178 -0
  5. data/Rakefile +8 -0
  6. data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
  7. data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
  8. data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
  9. data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
  10. data/app/controllers/sql_genius/base_controller.rb +29 -0
  11. data/app/controllers/sql_genius/queries_controller.rb +94 -0
  12. data/app/views/layouts/sql_genius/application.html.erb +285 -0
  13. data/config/routes.rb +34 -0
  14. data/docs/guides/ai-features.md +115 -0
  15. data/docs/guides/getting-started-rails.md +118 -0
  16. data/docs/guides/ssh-tunnel-connections.md +151 -0
  17. data/docs/screenshots/ai_tools.png +0 -0
  18. data/docs/screenshots/dashboard.png +0 -0
  19. data/docs/screenshots/duplicate_indexes.png +0 -0
  20. data/docs/screenshots/query_explore.png +0 -0
  21. data/docs/screenshots/query_stats.png +0 -0
  22. data/docs/screenshots/server.png +0 -0
  23. data/docs/screenshots/table_sizes.png +0 -0
  24. data/lib/generators/sql_genius/install/install_generator.rb +19 -0
  25. data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
  26. data/lib/sql_genius/configuration.rb +114 -0
  27. data/lib/sql_genius/core/ai/client.rb +155 -0
  28. data/lib/sql_genius/core/ai/config.rb +47 -0
  29. data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
  30. data/lib/sql_genius/core/ai/describe_query.rb +41 -0
  31. data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
  32. data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
  33. data/lib/sql_genius/core/ai/index_planner.rb +91 -0
  34. data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
  35. data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
  36. data/lib/sql_genius/core/ai/optimization.rb +81 -0
  37. data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
  38. data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
  39. data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
  40. data/lib/sql_genius/core/ai/schema_review.rb +46 -0
  41. data/lib/sql_genius/core/ai/suggestion.rb +74 -0
  42. data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
  43. data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
  44. data/lib/sql_genius/core/analysis/columns.rb +63 -0
  45. data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
  46. data/lib/sql_genius/core/analysis/query_history.rb +50 -0
  47. data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
  48. data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
  49. data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
  50. data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
  51. data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
  52. data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
  53. data/lib/sql_genius/core/column_definition.rb +30 -0
  54. data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
  55. data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
  56. data/lib/sql_genius/core/connection.rb +37 -0
  57. data/lib/sql_genius/core/execution_result.rb +27 -0
  58. data/lib/sql_genius/core/index_definition.rb +23 -0
  59. data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
  60. data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
  61. data/lib/sql_genius/core/query_builders.rb +27 -0
  62. data/lib/sql_genius/core/query_explainer.rb +113 -0
  63. data/lib/sql_genius/core/query_runner/config.rb +21 -0
  64. data/lib/sql_genius/core/query_runner.rb +123 -0
  65. data/lib/sql_genius/core/result.rb +43 -0
  66. data/lib/sql_genius/core/server_info.rb +54 -0
  67. data/lib/sql_genius/core/sql_validator.rb +149 -0
  68. data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
  69. data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
  70. data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
  71. data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
  72. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
  73. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
  74. data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
  75. data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
  76. data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
  77. data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
  78. data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
  79. data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
  80. data/lib/sql_genius/core.rb +72 -0
  81. data/lib/sql_genius/engine.rb +31 -0
  82. data/lib/sql_genius/slow_query_monitor.rb +43 -0
  83. data/lib/sql_genius/version.rb +5 -0
  84. data/lib/sql_genius.rb +29 -0
  85. data/sql_genius.gemspec +47 -0
  86. metadata +171 -0
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module AiFeatures
5
+ extend ActiveSupport::Concern
6
+
7
+ def suggest
8
+ unless sql_genius_config.ai_enabled?
9
+ return render(json: { error: "AI features are not configured." }, status: :not_found)
10
+ end
11
+
12
+ prompt = params[:prompt].to_s.strip
13
+ return render(json: { error: "Please describe what you want to query." }, status: :unprocessable_entity) if prompt.blank?
14
+
15
+ service = SqlGenius::Core::Ai::Suggestion.new(rails_connection, ai_client, ai_config_for_core)
16
+ result = service.call(prompt, queryable_tables)
17
+ sql = sanitize_ai_sql(result["sql"].to_s)
18
+ render(json: { sql: sql, explanation: result["explanation"] })
19
+ rescue StandardError => e
20
+ render(json: { error: "AI suggestion failed: #{e.message}" }, status: :unprocessable_entity)
21
+ end
22
+
23
+ def optimize
24
+ unless sql_genius_config.ai_enabled?
25
+ return render(json: { error: "AI features are not configured." }, status: :not_found)
26
+ end
27
+
28
+ sql = params[:sql].to_s.strip
29
+ explain_rows = Array(params[:explain_rows]).map { |row| row.respond_to?(:values) ? row.values : Array(row) }
30
+
31
+ if sql.blank? || explain_rows.blank?
32
+ return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity)
33
+ end
34
+
35
+ service = SqlGenius::Core::Ai::Optimization.new(rails_connection, ai_client, ai_config_for_core)
36
+ result = service.call(sql, explain_rows, queryable_tables)
37
+ render(json: result)
38
+ rescue StandardError => e
39
+ render(json: { error: "Optimization failed: #{e.message}" }, status: :unprocessable_entity)
40
+ end
41
+
42
+ def describe_query
43
+ return ai_not_configured unless sql_genius_config.ai_enabled?
44
+
45
+ sql = params[:sql].to_s.strip
46
+ return render(json: { error: "SQL is required." }, status: :unprocessable_entity) if sql.blank?
47
+
48
+ result = SqlGenius::Core::Ai::DescribeQuery.new(ai_client, ai_config_for_core).call(sql)
49
+ render(json: result)
50
+ rescue StandardError => e
51
+ render(json: { error: "Explanation failed: #{e.message}" }, status: :unprocessable_entity)
52
+ end
53
+
54
+ def schema_review
55
+ return ai_not_configured unless sql_genius_config.ai_enabled?
56
+
57
+ result = SqlGenius::Core::Ai::SchemaReview.new(ai_client, ai_config_for_core, rails_connection).call(params[:table].to_s.strip.presence)
58
+ render(json: result)
59
+ rescue StandardError => e
60
+ render(json: { error: "Schema review failed: #{e.message}" }, status: :unprocessable_entity)
61
+ end
62
+
63
+ def rewrite_query
64
+ return ai_not_configured unless sql_genius_config.ai_enabled?
65
+
66
+ sql = params[:sql].to_s.strip
67
+ return render(json: { error: "SQL is required." }, status: :unprocessable_entity) if sql.blank?
68
+
69
+ result = SqlGenius::Core::Ai::RewriteQuery.new(ai_client, ai_config_for_core, rails_connection).call(sql)
70
+ render(json: result)
71
+ rescue StandardError => e
72
+ render(json: { error: "Rewrite failed: #{e.message}" }, status: :unprocessable_entity)
73
+ end
74
+
75
+ def index_advisor
76
+ return ai_not_configured unless sql_genius_config.ai_enabled?
77
+
78
+ sql = params[:sql].to_s.strip
79
+ explain_rows = Array(params[:explain_rows]).map { |row| row.respond_to?(:values) ? row.values : Array(row) }
80
+ return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity) if sql.blank? || explain_rows.blank?
81
+
82
+ result = SqlGenius::Core::Ai::IndexAdvisor.new(ai_client, ai_config_for_core, rails_connection).call(sql, explain_rows)
83
+ render(json: result)
84
+ rescue StandardError => e
85
+ render(json: { error: "Index advisor failed: #{e.message}" }, status: :unprocessable_entity)
86
+ end
87
+
88
+ def anomaly_detection
89
+ return ai_not_configured unless sql_genius_config.ai_enabled?
90
+ return ai_unsupported_on_postgresql("Anomaly detection") if connected_to_postgresql?
91
+
92
+ connection = ActiveRecord::Base.connection
93
+
94
+ # Gather recent slow queries
95
+ slow_data = []
96
+ if sql_genius_config.redis_url
97
+ redis = Redis.new(url: sql_genius_config.redis_url)
98
+ raw = redis.lrange(SlowQueryMonitor.redis_key, 0, 99)
99
+ slow_data = raw.map do |e|
100
+ JSON.parse(e)
101
+ rescue
102
+ nil
103
+ end.compact
104
+ end
105
+
106
+ # Gather top query stats
107
+ stats = []
108
+ begin
109
+ results = connection.exec_query(<<~SQL)
110
+ SELECT DIGEST_TEXT, COUNT_STAR AS calls,
111
+ ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_time_ms,
112
+ ROUND(AVG_TIMER_WAIT / 1000000000, 1) AS avg_time_ms,
113
+ SUM_ROWS_EXAMINED AS rows_examined, SUM_ROWS_SENT AS rows_sent,
114
+ FIRST_SEEN, LAST_SEEN
115
+ FROM performance_schema.events_statements_summary_by_digest
116
+ WHERE SCHEMA_NAME = #{connection.quote(connection.current_database)}
117
+ AND DIGEST_TEXT IS NOT NULL
118
+ ORDER BY SUM_TIMER_WAIT DESC LIMIT 30
119
+ SQL
120
+ stats = results.rows.map { |r| { sql: r[0].to_s.truncate(200), calls: r[1], total_ms: r[2], avg_ms: r[3], rows_examined: r[4], rows_sent: r[5], first_seen: r[6], last_seen: r[7] } }
121
+ rescue
122
+ # performance_schema may not be available
123
+ end
124
+
125
+ slow_summary = slow_data.first(50).map { |q| "#{q["duration_ms"]}ms @ #{q["timestamp"]}: #{q["sql"].to_s.truncate(150)}" }.join("\n")
126
+ stats_summary = stats.map { |q| "calls=#{q[:calls]} avg=#{q[:avg_ms]}ms total=#{q[:total_ms]}ms exam=#{q[:rows_examined]} sent=#{q[:rows_sent]}: #{q[:sql]}" }.join("\n")
127
+ domain_ctx = sql_genius_config.ai_system_context.present? ? "\nDomain context:\n#{sql_genius_config.ai_system_context}" : ""
128
+
129
+ messages = [
130
+ { role: "system", content: <<~PROMPT },
131
+ You are a MySQL query anomaly detector. Analyze the following query data and identify:
132
+ 1. Queries with degrading performance (high avg time relative to complexity)
133
+ 2. N+1 query patterns (same template called many times in short windows)
134
+ 3. Full table scans (rows_examined >> rows_sent)
135
+ 4. Sudden new query patterns that may indicate code changes
136
+ 5. Queries creating excessive temp tables or sorts
137
+ #{domain_ctx}
138
+
139
+ Respond with JSON: {"report": "markdown-formatted health report organized by severity. For each finding, explain the issue, affected query, and recommended fix."}
140
+ PROMPT
141
+ { role: "user", content: "Recent Slow Queries (last #{slow_data.size}):\n#{slow_summary.presence || "None captured"}\n\nTop Queries by Total Time:\n#{stats_summary.presence || "Not available"}" },
142
+ ]
143
+
144
+ result = ai_client.chat(messages: messages)
145
+ render(json: result)
146
+ rescue StandardError => e
147
+ render(json: { error: "Anomaly detection failed: #{e.message}" }, status: :unprocessable_entity)
148
+ end
149
+
150
+ def root_cause
151
+ return ai_not_configured unless sql_genius_config.ai_enabled?
152
+ return ai_unsupported_on_postgresql("Root cause analysis") if connected_to_postgresql?
153
+
154
+ connection = ActiveRecord::Base.connection
155
+
156
+ # PROCESSLIST
157
+ processlist = connection.exec_query("SHOW FULL PROCESSLIST")
158
+ process_info = processlist.rows.map { |r| "ID=#{r[0]} User=#{r[1]} Host=#{r[2]} DB=#{r[3]} Command=#{r[4]} Time=#{r[5]}s State=#{r[6]} SQL=#{r[7].to_s.truncate(200)}" }.join("\n")
159
+
160
+ # Key status variables
161
+ status_rows = connection.exec_query("SHOW GLOBAL STATUS")
162
+ status = {}
163
+ status_rows.each { |r| status[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
164
+
165
+ key_stats = [
166
+ "Threads_connected",
167
+ "Threads_running",
168
+ "Innodb_row_lock_waits",
169
+ "Innodb_row_lock_current_waits",
170
+ "Innodb_buffer_pool_reads",
171
+ "Innodb_buffer_pool_read_requests",
172
+ "Slow_queries",
173
+ "Created_tmp_disk_tables",
174
+ "Connections",
175
+ "Aborted_connects",
176
+ ].map { |k| "#{k}=#{status[k]}" }.join(", ")
177
+
178
+ # InnoDB status (truncated)
179
+ innodb_status = ""
180
+ begin
181
+ result = connection.exec_query("SHOW ENGINE INNODB STATUS")
182
+ innodb_status = result.rows.first&.last.to_s.truncate(3000)
183
+ rescue ActiveRecord::StatementInvalid
184
+ # InnoDB status may be unavailable depending on MySQL user privileges
185
+ end
186
+
187
+ # Recent slow queries
188
+ slow_summary = ""
189
+ if sql_genius_config.redis_url
190
+ redis = Redis.new(url: sql_genius_config.redis_url)
191
+ raw = redis.lrange(SlowQueryMonitor.redis_key, 0, 19)
192
+ slows = raw.map do |e|
193
+ JSON.parse(e)
194
+ rescue
195
+ nil
196
+ end.compact
197
+ slow_summary = slows.map { |q| "#{q["duration_ms"]}ms: #{q["sql"].to_s.truncate(150)}" }.join("\n")
198
+ end
199
+
200
+ domain_ctx = sql_genius_config.ai_system_context.present? ? "\nDomain context:\n#{sql_genius_config.ai_system_context}" : ""
201
+
202
+ messages = [
203
+ { role: "system", content: <<~PROMPT },
204
+ You are a MySQL incident responder. The user is asking "why is the database slow right now?" Analyze the provided data and give a root cause diagnosis. Consider:
205
+ - Lock contention (row locks, metadata locks, table locks)
206
+ - Long-running queries blocking others
207
+ - Connection exhaustion
208
+ - Buffer pool thrashing (low hit rate)
209
+ - Disk I/O saturation
210
+ - Replication lag
211
+ - Unusual query patterns
212
+ #{domain_ctx}
213
+
214
+ Respond with JSON: {"diagnosis": "markdown-formatted root cause analysis. Start with a 1-2 sentence summary, then detailed findings. Include specific actionable steps to resolve the issue."}
215
+ PROMPT
216
+ { role: "user", content: "PROCESSLIST:\n#{process_info}\n\nKey Status:\n#{key_stats}\n\nInnoDB Status (excerpt):\n#{innodb_status.presence || "Not available"}\n\nRecent Slow Queries:\n#{slow_summary.presence || "None captured"}" },
217
+ ]
218
+
219
+ result = ai_client.chat(messages: messages)
220
+ render(json: result)
221
+ rescue StandardError => e
222
+ render(json: { error: "Root cause analysis failed: #{e.message}" }, status: :unprocessable_entity)
223
+ end
224
+
225
+ def migration_risk
226
+ return ai_not_configured unless sql_genius_config.ai_enabled?
227
+
228
+ migration_sql = params[:migration].to_s.strip
229
+ return render(json: { error: "Migration SQL or Ruby code is required." }, status: :unprocessable_entity) if migration_sql.blank?
230
+
231
+ result = SqlGenius::Core::Ai::MigrationRisk.new(ai_client, ai_config_for_core, rails_connection).call(migration_sql)
232
+ render(json: result)
233
+ rescue StandardError => e
234
+ render(json: { error: "Migration risk assessment failed: #{e.message}" }, status: :unprocessable_entity)
235
+ end
236
+
237
+ def variable_review
238
+ return ai_not_configured unless sql_genius_config.ai_enabled?
239
+
240
+ result = SqlGenius::Core::Ai::VariableReviewer.new(ai_client, ai_config_for_core, rails_connection).call
241
+ render(json: result)
242
+ rescue StandardError => e
243
+ render(json: { error: "Variable review failed: #{e.message}" }, status: :unprocessable_entity)
244
+ end
245
+
246
+ def connection_advisor
247
+ return ai_not_configured unless sql_genius_config.ai_enabled?
248
+
249
+ result = SqlGenius::Core::Ai::ConnectionAdvisor.new(ai_client, ai_config_for_core, rails_connection).call
250
+ render(json: result)
251
+ rescue StandardError => e
252
+ render(json: { error: "Connection advisor failed: #{e.message}" }, status: :unprocessable_entity)
253
+ end
254
+
255
+ def workload_digest
256
+ return ai_not_configured unless sql_genius_config.ai_enabled?
257
+
258
+ result = SqlGenius::Core::Ai::WorkloadDigest.new(rails_connection, ai_client, ai_config_for_core).call
259
+ render(json: result)
260
+ rescue StandardError => e
261
+ render(json: { error: "Workload digest failed: #{e.message}" }, status: :unprocessable_entity)
262
+ end
263
+
264
+ def innodb_health
265
+ return ai_not_configured unless sql_genius_config.ai_enabled?
266
+
267
+ result = SqlGenius::Core::Ai::InnodbInterpreter.new(ai_client, ai_config_for_core, rails_connection).call
268
+ render(json: result)
269
+ rescue StandardError => e
270
+ render(json: { error: "InnoDB health analysis failed: #{e.message}" }, status: :unprocessable_entity)
271
+ end
272
+
273
+ def index_planner
274
+ return ai_not_configured unless sql_genius_config.ai_enabled?
275
+
276
+ tables = params[:tables].present? ? Array(params[:tables]) : nil
277
+ result = SqlGenius::Core::Ai::IndexPlanner.new(ai_client, ai_config_for_core, rails_connection).call(tables)
278
+ render(json: result)
279
+ rescue StandardError => e
280
+ render(json: { error: "Index planner failed: #{e.message}" }, status: :unprocessable_entity)
281
+ end
282
+
283
+ def pattern_grouper
284
+ return ai_not_configured unless sql_genius_config.ai_enabled?
285
+
286
+ result = SqlGenius::Core::Ai::PatternGrouper.new(rails_connection, ai_client, ai_config_for_core).call
287
+ render(json: result)
288
+ rescue StandardError => e
289
+ render(json: { error: "Pattern grouper failed: #{e.message}" }, status: :unprocessable_entity)
290
+ end
291
+
292
+ private
293
+
294
+ RAILS_DOMAIN_CONTEXT = <<~CTX
295
+ This is a Ruby on Rails application. Do NOT recommend adding foreign key constraints (FOREIGN KEY / REFERENCES); Rails handles referential integrity at the application layer. DO recommend indexes on foreign key columns for join performance.
296
+ CTX
297
+
298
+ def ai_client
299
+ SqlGenius::Core::Ai::Client.new(ai_config_for_core)
300
+ end
301
+
302
+ def ai_config_for_core
303
+ cfg = sql_genius_config
304
+ SqlGenius::Core::Ai::Config.new(
305
+ client: cfg.ai_client,
306
+ endpoint: cfg.ai_endpoint,
307
+ api_key: cfg.ai_api_key,
308
+ model: cfg.ai_model,
309
+ auth_style: cfg.ai_auth_style,
310
+ system_context: cfg.ai_system_context,
311
+ domain_context: RAILS_DOMAIN_CONTEXT,
312
+ )
313
+ end
314
+
315
+ def ai_not_configured
316
+ render(json: { error: "AI features are not configured." }, status: :not_found)
317
+ end
318
+
319
+ def ai_unsupported_on_postgresql(feature_name)
320
+ render(
321
+ json: { error: "#{feature_name} is MySQL/MariaDB-only and is not available on PostgreSQL." },
322
+ status: :unprocessable_entity,
323
+ )
324
+ end
325
+
326
+ def connected_to_postgresql?
327
+ rails_connection.server_version.postgresql?
328
+ rescue StandardError
329
+ false
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module DatabaseAnalysis
5
+ extend ActiveSupport::Concern
6
+
7
+ def duplicate_indexes
8
+ duplicates = SqlGenius::Core::Analysis::DuplicateIndexes
9
+ .new(rails_connection, blocked_tables: sql_genius_config.blocked_tables)
10
+ .call
11
+ render(json: duplicates)
12
+ end
13
+
14
+ def table_sizes
15
+ tables = SqlGenius::Core::Analysis::TableSizes.new(rails_connection).call
16
+ render(json: tables)
17
+ end
18
+
19
+ def query_stats
20
+ sort = params[:sort].to_s
21
+ limit = params.fetch(:limit, SqlGenius::Core::Analysis::QueryStats::MAX_LIMIT).to_i
22
+ queries = SqlGenius::Core::Analysis::QueryStats.new(rails_connection).call(sort: sort, limit: limit)
23
+ render(json: queries)
24
+ rescue ActiveRecord::StatementInvalid => e
25
+ render(json: { error: "#{query_stats_source_name} #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
26
+ end
27
+
28
+ def unused_indexes
29
+ result = SqlGenius::Core::Analysis::UnusedIndexes.new(
30
+ rails_connection,
31
+ min_scans: sql_genius_config.min_unused_index_scans,
32
+ ).call
33
+ render(json: {
34
+ indexes: result.indexes,
35
+ stats_reset_at: result.stats_reset_at,
36
+ min_scans: result.min_scans,
37
+ })
38
+ rescue ActiveRecord::StatementInvalid => e
39
+ render(json: { error: "#{unused_indexes_source_name} #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
40
+ end
41
+
42
+ def server_overview
43
+ overview = SqlGenius::Core::Analysis::ServerOverview.new(rails_connection).call
44
+ render(json: overview)
45
+ rescue => e
46
+ render(json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity)
47
+ end
48
+
49
+ private
50
+
51
+ def query_stats_source_name
52
+ if rails_connection.server_version.postgresql?
53
+ "Query statistics require the pg_stat_statements extension to be installed."
54
+ else
55
+ "Query statistics require performance_schema to be enabled."
56
+ end
57
+ end
58
+
59
+ def unused_indexes_source_name
60
+ if rails_connection.server_version.postgresql?
61
+ "Unused index detection requires pg_stat_user_indexes (always available on PostgreSQL — check connection)."
62
+ else
63
+ "Unused index detection requires performance_schema."
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module QueryExecution
5
+ extend ActiveSupport::Concern
6
+
7
+ def execute
8
+ sql = params[:sql].to_s.strip
9
+ row_limit = if params[:row_limit].present?
10
+ params[:row_limit].to_i.clamp(1, sql_genius_config.max_row_limit)
11
+ else
12
+ sql_genius_config.default_row_limit
13
+ end
14
+
15
+ runner_config = SqlGenius::Core::QueryRunner::Config.new(
16
+ blocked_tables: sql_genius_config.blocked_tables,
17
+ masked_column_patterns: sql_genius_config.masked_column_patterns,
18
+ query_timeout_ms: sql_genius_config.query_timeout_ms,
19
+ )
20
+ runner = SqlGenius::Core::QueryRunner.new(rails_connection, runner_config)
21
+
22
+ begin
23
+ result = runner.run(sql, row_limit: row_limit)
24
+ rescue SqlGenius::Core::QueryRunner::Rejected => e
25
+ audit(:rejection, sql: sql, reason: e.message)
26
+ return render(json: { error: e.message }, status: :unprocessable_entity)
27
+ rescue SqlGenius::Core::QueryRunner::Timeout
28
+ audit(:error, sql: sql, error: "Query timeout")
29
+ return render(json: { error: "Query exceeded the #{sql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity)
30
+ rescue ActiveRecord::StatementInvalid => e
31
+ audit(:error, sql: sql, error: e.message)
32
+ return render(json: { error: "Query error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
33
+ end
34
+
35
+ audit(:query, sql: sql, execution_time_ms: result.execution_time_ms, row_count: result.row_count)
36
+
37
+ render(json: {
38
+ columns: result.columns,
39
+ rows: result.rows,
40
+ row_count: result.row_count,
41
+ execution_time_ms: result.execution_time_ms,
42
+ truncated: result.truncated,
43
+ })
44
+ end
45
+
46
+ def explain
47
+ sql = params[:sql].to_s.strip
48
+ skip_validation = params[:from_slow_query] == "true"
49
+
50
+ runner_config = SqlGenius::Core::QueryRunner::Config.new(
51
+ blocked_tables: sql_genius_config.blocked_tables,
52
+ masked_column_patterns: sql_genius_config.masked_column_patterns,
53
+ query_timeout_ms: sql_genius_config.query_timeout_ms,
54
+ )
55
+ explainer = SqlGenius::Core::QueryExplainer.new(rails_connection, runner_config)
56
+
57
+ result = explainer.explain(sql, skip_validation: skip_validation)
58
+ render(json: { columns: result.columns, rows: result.rows })
59
+ rescue SqlGenius::Core::QueryRunner::Rejected,
60
+ SqlGenius::Core::QueryExplainer::Truncated => e
61
+ render(json: { error: e.message }, status: :unprocessable_entity)
62
+ rescue ActiveRecord::StatementInvalid => e
63
+ render(json: { error: "Explain error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
64
+ end
65
+
66
+ private
67
+
68
+ def sanitize_ai_sql(sql)
69
+ sql.gsub(/```(?:sql)?\s*/i, "").gsub("```", "").strip
70
+ end
71
+
72
+ def audit(type, **attrs)
73
+ logger = sql_genius_config.audit_logger
74
+ return unless logger
75
+
76
+ prefix = "[#{Time.current.iso8601}] [sql_genius]"
77
+ case type
78
+ when :query
79
+ logger.info("#{prefix} rows=#{attrs[:row_count]} time=#{attrs[:execution_time_ms]}ms sql=#{attrs[:sql].squish}")
80
+ when :rejection
81
+ logger.warn("#{prefix} REJECTED reason=#{attrs[:reason]} sql=#{attrs[:sql].to_s.squish}")
82
+ when :error
83
+ logger.error("#{prefix} ERROR error=#{attrs[:error]} sql=#{attrs[:sql].to_s.squish}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module SharedViewHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :path_for, :render_partial, :capability?
9
+ end
10
+
11
+ # URL path helper for shared templates.
12
+ # path_for(:execute) # => "/sql_genius/execute"
13
+ #
14
+ # When @digest is set (query detail page), routes that require a digest
15
+ # param (query_detail, query_history) are generated with it automatically.
16
+ #
17
+ # `:query_detail_prefix` returns the engine-mount-aware base path that
18
+ # the dashboard JS appends a digest to (so query stat rows link to
19
+ # /sql_genius/queries/${digest} rather than /queries/${digest}).
20
+ #
21
+ # Uses SqlGenius::Engine.routes.url_helpers directly rather than the
22
+ # `sql_genius` proxy: in production with eager loading, the proxy
23
+ # method isn't always injected onto the engine's controller in time,
24
+ # which surfaces as `NameError: undefined local variable or method
25
+ # 'sql_genius'` the first time a view tries to build a URL.
26
+ def path_for(name)
27
+ helpers = SqlGenius::Engine.routes.url_helpers
28
+ case name
29
+ when :query_detail_prefix
30
+ "#{helpers.root_path}queries/"
31
+ when :query_detail, :query_history
32
+ if @digest
33
+ helpers.public_send("#{name}_path", digest: @digest)
34
+ else
35
+ helpers.public_send("#{name}_path", digest: "")
36
+ end
37
+ else
38
+ helpers.public_send("#{name}_path")
39
+ end
40
+ end
41
+
42
+ # Partial renderer for shared templates.
43
+ # render_partial(:tab_dashboard) # => view_context.render partial: "sql_genius/queries/tab_dashboard"
44
+ def render_partial(name)
45
+ view_context.render(partial: "sql_genius/queries/#{name}")
46
+ end
47
+
48
+ # Capability flag for shared templates. Used to hide AI feature buttons
49
+ # whose underlying service has no equivalent on the connected dialect
50
+ # (e.g. InnoDB Health, Variable Review, Connection Advisor, Root Cause
51
+ # Analysis, and Anomaly Detection all read MySQL-specific server state
52
+ # via SHOW commands / performance_schema).
53
+ #
54
+ # All other capabilities default to true — the Rails adapter owns every
55
+ # route, including Redis-backed slow_queries.
56
+ def capability?(name)
57
+ case name
58
+ when :mysql_only_ai
59
+ !connected_to_postgresql?
60
+ else
61
+ true
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def connected_to_postgresql?
68
+ SqlGenius::Core::Connection::ActiveRecordAdapter
69
+ .new(ActiveRecord::Base.connection)
70
+ .server_version
71
+ .postgresql?
72
+ rescue StandardError
73
+ false
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ class BaseController < SqlGenius.configuration.base_controller.constantize
5
+ layout "sql_genius/application"
6
+ before_action :authenticate_sql_genius!
7
+
8
+ private
9
+
10
+ def authenticate_sql_genius!
11
+ unless SqlGenius.configuration.authenticate.call(self)
12
+ render(plain: "Not authorized", status: :unauthorized)
13
+ end
14
+ end
15
+
16
+ def sql_genius_config
17
+ SqlGenius.configuration
18
+ end
19
+
20
+ # Wraps ActiveRecord::Base.connection in a Core::Connection::ActiveRecordAdapter.
21
+ # Every controller action that delegates to a Core::* service calls this
22
+ # instead of instantiating the adapter inline. Shared across all concerns
23
+ # (QueryExecution, DatabaseAnalysis, AiFeatures) via BaseController's
24
+ # private method lookup.
25
+ def rails_connection
26
+ SqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ class QueriesController < BaseController
5
+ include QueryExecution
6
+ include DatabaseAnalysis
7
+ include AiFeatures
8
+ include SharedViewHelpers
9
+
10
+ def index
11
+ @featured_tables = if sql_genius_config.featured_tables.any?
12
+ sql_genius_config.featured_tables.sort
13
+ else
14
+ queryable_tables.sort
15
+ end
16
+ @all_tables = queryable_tables.sort
17
+ @ai_enabled = sql_genius_config.ai_enabled?
18
+ @framework_version_major = Rails::VERSION::MAJOR
19
+ @framework_version_minor = Rails::VERSION::MINOR
20
+ @identifier_quote_char = identifier_quote_char
21
+ render("sql_genius/queries/dashboard")
22
+ end
23
+
24
+ def columns
25
+ result = SqlGenius::Core::Analysis::Columns.new(
26
+ rails_connection,
27
+ blocked_tables: sql_genius_config.blocked_tables,
28
+ masked_column_patterns: sql_genius_config.masked_column_patterns,
29
+ default_columns: sql_genius_config.default_columns,
30
+ ).call(table: params[:table])
31
+
32
+ case result.status
33
+ when :ok then render(json: result.columns)
34
+ when :blocked then render(json: { error: result.error_message }, status: :forbidden)
35
+ when :not_found then render(json: { error: result.error_message }, status: :not_found)
36
+ end
37
+ end
38
+
39
+ def query_detail
40
+ @digest = params[:digest].to_s
41
+ render("sql_genius/queries/query_detail")
42
+ end
43
+
44
+ def query_history
45
+ digest = params[:digest].to_s
46
+
47
+ query_history_service = SqlGenius::Core::Analysis::QueryHistory.new(rails_connection)
48
+ current_query = query_history_service.call(digest)
49
+ history = fetch_query_history_series(digest, query_history_service)
50
+
51
+ render(json: { query: current_query, history: history })
52
+ rescue StandardError => e
53
+ render(json: { error: e.message }, status: :unprocessable_entity)
54
+ end
55
+
56
+ def slow_queries
57
+ unless sql_genius_config.redis_url.present?
58
+ return render(json: [], status: :ok)
59
+ end
60
+
61
+ require "redis"
62
+ redis = Redis.new(url: sql_genius_config.redis_url)
63
+ key = SlowQueryMonitor.redis_key
64
+ raw = redis.lrange(key, 0, 199)
65
+ queries = raw.map do |entry|
66
+ JSON.parse(entry)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end.compact
70
+ render(json: queries)
71
+ rescue StandardError => e
72
+ render(json: { error: "Slow query error: #{e.message}" }, status: :unprocessable_entity)
73
+ end
74
+
75
+ private
76
+
77
+ def identifier_quote_char
78
+ ActiveRecord::Base.connection.quote_table_name("sql_genius_identifier_probe")[0]
79
+ end
80
+
81
+ def queryable_tables
82
+ ActiveRecord::Base.connection.tables - sql_genius_config.blocked_tables
83
+ end
84
+
85
+ def fetch_query_history_series(digest, query_history_service)
86
+ return [] unless SqlGenius.stats_history
87
+
88
+ digest_text = query_history_service.digest_text_for(digest)
89
+ return [] unless digest_text
90
+
91
+ SqlGenius.stats_history.series_for(digest_text)
92
+ end
93
+ end
94
+ end