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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +195 -0
- data/LICENSE.txt +65 -0
- data/README.md +178 -0
- data/Rakefile +8 -0
- data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
- data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
- data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
- data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
- data/app/controllers/sql_genius/base_controller.rb +29 -0
- data/app/controllers/sql_genius/queries_controller.rb +94 -0
- data/app/views/layouts/sql_genius/application.html.erb +285 -0
- data/config/routes.rb +34 -0
- data/docs/guides/ai-features.md +115 -0
- data/docs/guides/getting-started-rails.md +118 -0
- data/docs/guides/ssh-tunnel-connections.md +151 -0
- data/docs/screenshots/ai_tools.png +0 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/duplicate_indexes.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/screenshots/query_stats.png +0 -0
- data/docs/screenshots/server.png +0 -0
- data/docs/screenshots/table_sizes.png +0 -0
- data/lib/generators/sql_genius/install/install_generator.rb +19 -0
- data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
- data/lib/sql_genius/configuration.rb +114 -0
- data/lib/sql_genius/core/ai/client.rb +155 -0
- data/lib/sql_genius/core/ai/config.rb +47 -0
- data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
- data/lib/sql_genius/core/ai/describe_query.rb +41 -0
- data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
- data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
- data/lib/sql_genius/core/ai/index_planner.rb +91 -0
- data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
- data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
- data/lib/sql_genius/core/ai/optimization.rb +81 -0
- data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
- data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
- data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
- data/lib/sql_genius/core/ai/schema_review.rb +46 -0
- data/lib/sql_genius/core/ai/suggestion.rb +74 -0
- data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
- data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
- data/lib/sql_genius/core/analysis/columns.rb +63 -0
- data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
- data/lib/sql_genius/core/analysis/query_history.rb +50 -0
- data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
- data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
- data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
- data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
- data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
- data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
- data/lib/sql_genius/core/column_definition.rb +30 -0
- data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
- data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
- data/lib/sql_genius/core/connection.rb +37 -0
- data/lib/sql_genius/core/execution_result.rb +27 -0
- data/lib/sql_genius/core/index_definition.rb +23 -0
- data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
- data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
- data/lib/sql_genius/core/query_builders.rb +27 -0
- data/lib/sql_genius/core/query_explainer.rb +113 -0
- data/lib/sql_genius/core/query_runner/config.rb +21 -0
- data/lib/sql_genius/core/query_runner.rb +123 -0
- data/lib/sql_genius/core/result.rb +43 -0
- data/lib/sql_genius/core/server_info.rb +54 -0
- data/lib/sql_genius/core/sql_validator.rb +149 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
- data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
- data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
- data/lib/sql_genius/core.rb +72 -0
- data/lib/sql_genius/engine.rb +31 -0
- data/lib/sql_genius/slow_query_monitor.rb +43 -0
- data/lib/sql_genius/version.rb +5 -0
- data/lib/sql_genius.rb +29 -0
- data/sql_genius.gemspec +47 -0
- 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
|