mysql_genius 0.1.0 → 0.3.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 +4 -4
- data/.github/FUNDING.yml +5 -0
- data/.github/workflows/ci.yml +30 -7
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -0
- data/CHANGELOG.md +32 -0
- data/Gemfile +7 -2
- data/README.md +50 -38
- data/Rakefile +3 -1
- data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +73 -45
- data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
- data/app/controllers/mysql_genius/base_controller.rb +3 -1
- data/app/controllers/mysql_genius/queries_controller.rb +19 -12
- data/app/services/mysql_genius/ai_client.rb +10 -3
- data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
- data/app/services/mysql_genius/ai_suggestion_service.rb +6 -3
- data/app/views/layouts/mysql_genius/application.html.erb +141 -5
- data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
- data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +6 -4
- data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/index.html.erb +377 -52
- data/bin/console +1 -0
- data/config/routes.rb +2 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
- data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
- data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
- data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
- data/lib/mysql_genius/configuration.rb +8 -6
- data/lib/mysql_genius/engine.rb +2 -0
- data/lib/mysql_genius/slow_query_monitor.rb +30 -25
- data/lib/mysql_genius/sql_validator.rb +6 -4
- data/lib/mysql_genius/version.rb +3 -1
- data/lib/mysql_genius.rb +2 -0
- data/mysql_genius.gemspec +9 -8
- metadata +31 -15
- data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
- data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
- data/docs/screenshots/sql_query.png +0 -0
- data/docs/screenshots/visual_builder.png +0 -0
|
@@ -1,44 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
module AiFeatures
|
|
3
5
|
extend ActiveSupport::Concern
|
|
4
6
|
|
|
5
7
|
def suggest
|
|
6
8
|
unless mysql_genius_config.ai_enabled?
|
|
7
|
-
return render
|
|
9
|
+
return render(json: { error: "AI features are not configured." }, status: :not_found)
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
prompt = params[:prompt].to_s.strip
|
|
11
|
-
return render
|
|
13
|
+
return render(json: { error: "Please describe what you want to query." }, status: :unprocessable_entity) if prompt.blank?
|
|
12
14
|
|
|
13
15
|
result = AiSuggestionService.new.call(prompt, queryable_tables)
|
|
14
16
|
sql = sanitize_ai_sql(result["sql"].to_s)
|
|
15
|
-
render
|
|
17
|
+
render(json: { sql: sql, explanation: result["explanation"] })
|
|
16
18
|
rescue StandardError => e
|
|
17
|
-
render
|
|
19
|
+
render(json: { error: "AI suggestion failed: #{e.message}" }, status: :unprocessable_entity)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def optimize
|
|
21
23
|
unless mysql_genius_config.ai_enabled?
|
|
22
|
-
return render
|
|
24
|
+
return render(json: { error: "AI features are not configured." }, status: :not_found)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
sql = params[:sql].to_s.strip
|
|
26
28
|
explain_rows = Array(params[:explain_rows]).map { |row| row.respond_to?(:values) ? row.values : Array(row) }
|
|
27
29
|
|
|
28
30
|
if sql.blank? || explain_rows.blank?
|
|
29
|
-
return render
|
|
31
|
+
return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
result = AiOptimizationService.new.call(sql, explain_rows, queryable_tables)
|
|
33
|
-
render
|
|
35
|
+
render(json: result)
|
|
34
36
|
rescue StandardError => e
|
|
35
|
-
render
|
|
37
|
+
render(json: { error: "Optimization failed: #{e.message}" }, status: :unprocessable_entity)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def describe_query
|
|
39
41
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
42
|
+
|
|
40
43
|
sql = params[:sql].to_s.strip
|
|
41
|
-
return render
|
|
44
|
+
return render(json: { error: "SQL is required." }, status: :unprocessable_entity) if sql.blank?
|
|
42
45
|
|
|
43
46
|
messages = [
|
|
44
47
|
{ role: "system", content: <<~PROMPT },
|
|
@@ -50,32 +53,39 @@ module MysqlGenius
|
|
|
50
53
|
#{ai_domain_context}
|
|
51
54
|
Respond with JSON: {"explanation": "your plain-English explanation using markdown formatting"}
|
|
52
55
|
PROMPT
|
|
53
|
-
{ role: "user", content: sql }
|
|
56
|
+
{ role: "user", content: sql },
|
|
54
57
|
]
|
|
55
58
|
|
|
56
59
|
result = AiClient.new.chat(messages: messages)
|
|
57
|
-
render
|
|
60
|
+
render(json: result)
|
|
58
61
|
rescue StandardError => e
|
|
59
|
-
render
|
|
62
|
+
render(json: { error: "Explanation failed: #{e.message}" }, status: :unprocessable_entity)
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
def schema_review
|
|
63
66
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
67
|
+
|
|
64
68
|
table = params[:table].to_s.strip
|
|
65
69
|
connection = ActiveRecord::Base.connection
|
|
66
70
|
|
|
67
71
|
tables_to_review = table.present? ? [table] : queryable_tables.first(20)
|
|
68
72
|
schema_desc = tables_to_review.map do |t|
|
|
69
73
|
next unless connection.tables.include?(t)
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
|
|
75
|
+
cols = connection.columns(t).map { |c| "#{c.name} #{c.sql_type}#{" NOT NULL" unless c.null}#{" DEFAULT #{c.default}" if c.default}" }
|
|
76
|
+
pk = connection.primary_key(t)
|
|
77
|
+
indexes = connection.indexes(t).map { |idx| "#{"UNIQUE " if idx.unique}INDEX #{idx.name} (#{idx.columns.join(", ")})" }
|
|
72
78
|
row_count = connection.exec_query("SELECT TABLE_ROWS FROM information_schema.tables WHERE table_schema = #{connection.quote(connection.current_database)} AND table_name = #{connection.quote(t)}").rows.first&.first
|
|
73
|
-
"Table: #{t} (~#{row_count} rows)\
|
|
79
|
+
desc = "Table: #{t} (~#{row_count} rows)\n"
|
|
80
|
+
desc += "Primary Key: #{pk || "NONE"}\n"
|
|
81
|
+
desc += "Columns: #{cols.join(", ")}\n"
|
|
82
|
+
desc += "Indexes: #{indexes.any? ? indexes.join(", ") : "NONE"}"
|
|
83
|
+
desc
|
|
74
84
|
end.compact.join("\n\n")
|
|
75
85
|
|
|
76
86
|
messages = [
|
|
77
87
|
{ role: "system", content: <<~PROMPT },
|
|
78
|
-
You are a MySQL schema reviewer. Analyze the following schema and identify anti-patterns and improvement opportunities. Look for:
|
|
88
|
+
You are a MySQL schema reviewer for a Ruby on Rails application. Analyze the following schema and identify anti-patterns and improvement opportunities. Look for:
|
|
79
89
|
- Inappropriate column types (VARCHAR(255) for short values, TEXT where VARCHAR suffices, INT for booleans)
|
|
80
90
|
- Missing indexes on foreign key columns or frequently filtered columns
|
|
81
91
|
- Missing NOT NULL constraints where NULLs are unlikely
|
|
@@ -87,19 +97,20 @@ module MysqlGenius
|
|
|
87
97
|
#{ai_domain_context}
|
|
88
98
|
Respond with JSON: {"findings": "markdown-formatted findings organized by severity (Critical, Warning, Suggestion). Include specific ALTER TABLE statements where applicable."}
|
|
89
99
|
PROMPT
|
|
90
|
-
{ role: "user", content: schema_desc }
|
|
100
|
+
{ role: "user", content: schema_desc },
|
|
91
101
|
]
|
|
92
102
|
|
|
93
103
|
result = AiClient.new.chat(messages: messages)
|
|
94
|
-
render
|
|
104
|
+
render(json: result)
|
|
95
105
|
rescue StandardError => e
|
|
96
|
-
render
|
|
106
|
+
render(json: { error: "Schema review failed: #{e.message}" }, status: :unprocessable_entity)
|
|
97
107
|
end
|
|
98
108
|
|
|
99
109
|
def rewrite_query
|
|
100
110
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
111
|
+
|
|
101
112
|
sql = params[:sql].to_s.strip
|
|
102
|
-
return render
|
|
113
|
+
return render(json: { error: "SQL is required." }, status: :unprocessable_entity) if sql.blank?
|
|
103
114
|
|
|
104
115
|
schema = build_schema_for_query(sql)
|
|
105
116
|
|
|
@@ -122,30 +133,31 @@ module MysqlGenius
|
|
|
122
133
|
|
|
123
134
|
Respond with JSON: {"original": "the original SQL", "rewritten": "the improved SQL", "changes": "markdown list of each change and why it helps"}
|
|
124
135
|
PROMPT
|
|
125
|
-
{ role: "user", content: sql }
|
|
136
|
+
{ role: "user", content: sql },
|
|
126
137
|
]
|
|
127
138
|
|
|
128
139
|
result = AiClient.new.chat(messages: messages)
|
|
129
|
-
render
|
|
140
|
+
render(json: result)
|
|
130
141
|
rescue StandardError => e
|
|
131
|
-
render
|
|
142
|
+
render(json: { error: "Rewrite failed: #{e.message}" }, status: :unprocessable_entity)
|
|
132
143
|
end
|
|
133
144
|
|
|
134
145
|
def index_advisor
|
|
135
146
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
147
|
+
|
|
136
148
|
sql = params[:sql].to_s.strip
|
|
137
149
|
explain_rows = Array(params[:explain_rows]).map { |row| row.respond_to?(:values) ? row.values : Array(row) }
|
|
138
|
-
return render
|
|
150
|
+
return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity) if sql.blank? || explain_rows.blank?
|
|
139
151
|
|
|
140
152
|
connection = ActiveRecord::Base.connection
|
|
141
153
|
tables_in_query = SqlValidator.extract_table_references(sql, connection)
|
|
142
154
|
|
|
143
155
|
index_detail = tables_in_query.map do |t|
|
|
144
|
-
indexes = connection.indexes(t).map { |idx| "#{
|
|
156
|
+
indexes = connection.indexes(t).map { |idx| "#{"UNIQUE " if idx.unique}INDEX #{idx.name} (#{idx.columns.join(", ")})" }
|
|
145
157
|
stats = connection.exec_query("SELECT INDEX_NAME, COLUMN_NAME, CARDINALITY, SEQ_IN_INDEX FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = #{connection.quote(connection.current_database)} AND TABLE_NAME = #{connection.quote(t)} ORDER BY INDEX_NAME, SEQ_IN_INDEX")
|
|
146
158
|
cardinality = stats.rows.map { |r| "#{r[0]}.#{r[1]}: cardinality=#{r[2]}" }.join(", ")
|
|
147
159
|
row_count = connection.exec_query("SELECT TABLE_ROWS FROM information_schema.tables WHERE table_schema = #{connection.quote(connection.current_database)} AND table_name = #{connection.quote(t)}").rows.first&.first
|
|
148
|
-
"Table: #{t} (~#{row_count} rows)\nIndexes: #{indexes.any? ? indexes.join(
|
|
160
|
+
"Table: #{t} (~#{row_count} rows)\nIndexes: #{indexes.any? ? indexes.join("; ") : "NONE"}\nCardinality: #{cardinality}"
|
|
149
161
|
end.join("\n\n")
|
|
150
162
|
|
|
151
163
|
messages = [
|
|
@@ -160,17 +172,18 @@ module MysqlGenius
|
|
|
160
172
|
|
|
161
173
|
Respond with JSON: {"indexes": "markdown-formatted recommendations with exact CREATE INDEX statements, rationale for column ordering, and estimated impact. Include any indexes that should be DROPPED as part of the change."}
|
|
162
174
|
PROMPT
|
|
163
|
-
{ role: "user", content: "Query:\n#{sql}\n\nEXPLAIN:\n#{explain_rows.map { |r| r.join(
|
|
175
|
+
{ role: "user", content: "Query:\n#{sql}\n\nEXPLAIN:\n#{explain_rows.map { |r| r.join(" | ") }.join("\n")}\n\nCurrent Indexes:\n#{index_detail}" },
|
|
164
176
|
]
|
|
165
177
|
|
|
166
178
|
result = AiClient.new.chat(messages: messages)
|
|
167
|
-
render
|
|
179
|
+
render(json: result)
|
|
168
180
|
rescue StandardError => e
|
|
169
|
-
render
|
|
181
|
+
render(json: { error: "Index advisor failed: #{e.message}" }, status: :unprocessable_entity)
|
|
170
182
|
end
|
|
171
183
|
|
|
172
184
|
def anomaly_detection
|
|
173
185
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
186
|
+
|
|
174
187
|
connection = ActiveRecord::Base.connection
|
|
175
188
|
|
|
176
189
|
# Gather recent slow queries
|
|
@@ -178,7 +191,11 @@ module MysqlGenius
|
|
|
178
191
|
if mysql_genius_config.redis_url
|
|
179
192
|
redis = Redis.new(url: mysql_genius_config.redis_url)
|
|
180
193
|
raw = redis.lrange(SlowQueryMonitor.redis_key, 0, 99)
|
|
181
|
-
slow_data = raw.map
|
|
194
|
+
slow_data = raw.map do |e|
|
|
195
|
+
JSON.parse(e)
|
|
196
|
+
rescue
|
|
197
|
+
nil
|
|
198
|
+
end.compact
|
|
182
199
|
end
|
|
183
200
|
|
|
184
201
|
# Gather top query stats
|
|
@@ -200,7 +217,7 @@ module MysqlGenius
|
|
|
200
217
|
# performance_schema may not be available
|
|
201
218
|
end
|
|
202
219
|
|
|
203
|
-
slow_summary = slow_data.first(50).map { |q| "#{q[
|
|
220
|
+
slow_summary = slow_data.first(50).map { |q| "#{q["duration_ms"]}ms @ #{q["timestamp"]}: #{q["sql"].to_s.truncate(150)}" }.join("\n")
|
|
204
221
|
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")
|
|
205
222
|
|
|
206
223
|
messages = [
|
|
@@ -215,17 +232,18 @@ module MysqlGenius
|
|
|
215
232
|
|
|
216
233
|
Respond with JSON: {"report": "markdown-formatted health report organized by severity. For each finding, explain the issue, affected query, and recommended fix."}
|
|
217
234
|
PROMPT
|
|
218
|
-
{ role: "user", content: "Recent Slow Queries (last #{slow_data.size}):\n#{slow_summary.presence ||
|
|
235
|
+
{ 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"}" },
|
|
219
236
|
]
|
|
220
237
|
|
|
221
238
|
result = AiClient.new.chat(messages: messages)
|
|
222
|
-
render
|
|
239
|
+
render(json: result)
|
|
223
240
|
rescue StandardError => e
|
|
224
|
-
render
|
|
241
|
+
render(json: { error: "Anomaly detection failed: #{e.message}" }, status: :unprocessable_entity)
|
|
225
242
|
end
|
|
226
243
|
|
|
227
244
|
def root_cause
|
|
228
245
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
246
|
+
|
|
229
247
|
connection = ActiveRecord::Base.connection
|
|
230
248
|
|
|
231
249
|
# PROCESSLIST
|
|
@@ -237,16 +255,26 @@ module MysqlGenius
|
|
|
237
255
|
status = {}
|
|
238
256
|
status_rows.each { |r| status[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
|
|
239
257
|
|
|
240
|
-
key_stats =
|
|
241
|
-
|
|
242
|
-
|
|
258
|
+
key_stats = [
|
|
259
|
+
"Threads_connected",
|
|
260
|
+
"Threads_running",
|
|
261
|
+
"Innodb_row_lock_waits",
|
|
262
|
+
"Innodb_row_lock_current_waits",
|
|
263
|
+
"Innodb_buffer_pool_reads",
|
|
264
|
+
"Innodb_buffer_pool_read_requests",
|
|
265
|
+
"Slow_queries",
|
|
266
|
+
"Created_tmp_disk_tables",
|
|
267
|
+
"Connections",
|
|
268
|
+
"Aborted_connects",
|
|
269
|
+
].map { |k| "#{k}=#{status[k]}" }.join(", ")
|
|
243
270
|
|
|
244
271
|
# InnoDB status (truncated)
|
|
245
272
|
innodb_status = ""
|
|
246
273
|
begin
|
|
247
274
|
result = connection.exec_query("SHOW ENGINE INNODB STATUS")
|
|
248
275
|
innodb_status = result.rows.first&.last.to_s.truncate(3000)
|
|
249
|
-
rescue
|
|
276
|
+
rescue ActiveRecord::StatementInvalid
|
|
277
|
+
# InnoDB status may be unavailable depending on MySQL user privileges
|
|
250
278
|
end
|
|
251
279
|
|
|
252
280
|
# Recent slow queries
|
|
@@ -254,8 +282,12 @@ module MysqlGenius
|
|
|
254
282
|
if mysql_genius_config.redis_url
|
|
255
283
|
redis = Redis.new(url: mysql_genius_config.redis_url)
|
|
256
284
|
raw = redis.lrange(SlowQueryMonitor.redis_key, 0, 19)
|
|
257
|
-
slows = raw.map
|
|
258
|
-
|
|
285
|
+
slows = raw.map do |e|
|
|
286
|
+
JSON.parse(e)
|
|
287
|
+
rescue
|
|
288
|
+
nil
|
|
289
|
+
end.compact
|
|
290
|
+
slow_summary = slows.map { |q| "#{q["duration_ms"]}ms: #{q["sql"].to_s.truncate(150)}" }.join("\n")
|
|
259
291
|
end
|
|
260
292
|
|
|
261
293
|
messages = [
|
|
@@ -272,19 +304,20 @@ module MysqlGenius
|
|
|
272
304
|
|
|
273
305
|
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."}
|
|
274
306
|
PROMPT
|
|
275
|
-
{ role: "user", content: "PROCESSLIST:\n#{process_info}\n\nKey Status:\n#{key_stats}\n\nInnoDB Status (excerpt):\n#{innodb_status.presence ||
|
|
307
|
+
{ 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"}" },
|
|
276
308
|
]
|
|
277
309
|
|
|
278
310
|
result = AiClient.new.chat(messages: messages)
|
|
279
|
-
render
|
|
311
|
+
render(json: result)
|
|
280
312
|
rescue StandardError => e
|
|
281
|
-
render
|
|
313
|
+
render(json: { error: "Root cause analysis failed: #{e.message}" }, status: :unprocessable_entity)
|
|
282
314
|
end
|
|
283
315
|
|
|
284
316
|
def migration_risk
|
|
285
317
|
return ai_not_configured unless mysql_genius_config.ai_enabled?
|
|
318
|
+
|
|
286
319
|
migration_sql = params[:migration].to_s.strip
|
|
287
|
-
return render
|
|
320
|
+
return render(json: { error: "Migration SQL or Ruby code is required." }, status: :unprocessable_entity) if migration_sql.blank?
|
|
288
321
|
|
|
289
322
|
connection = ActiveRecord::Base.connection
|
|
290
323
|
|
|
@@ -294,8 +327,9 @@ module MysqlGenius
|
|
|
294
327
|
|
|
295
328
|
table_info = table_names.uniq.map do |t|
|
|
296
329
|
next unless connection.tables.include?(t)
|
|
330
|
+
|
|
297
331
|
row_count = connection.exec_query("SELECT TABLE_ROWS FROM information_schema.tables WHERE table_schema = #{connection.quote(connection.current_database)} AND table_name = #{connection.quote(t)}").rows.first&.first
|
|
298
|
-
indexes = connection.indexes(t).map { |idx| "#{idx.name} (#{idx.columns.join(
|
|
332
|
+
indexes = connection.indexes(t).map { |idx| "#{idx.name} (#{idx.columns.join(", ")})" }
|
|
299
333
|
"Table: #{t} (~#{row_count} rows, #{indexes.size} indexes)"
|
|
300
334
|
end.compact.join("\n")
|
|
301
335
|
|
|
@@ -312,7 +346,8 @@ module MysqlGenius
|
|
|
312
346
|
SQL
|
|
313
347
|
matching = results.rows.select { |r| table_names.any? { |t| r[0].to_s.downcase.include?(t.downcase) } }
|
|
314
348
|
active = matching.map { |r| "calls=#{r[1]} avg=#{r[2]}ms: #{r[0].to_s.truncate(200)}" }.join("\n")
|
|
315
|
-
rescue
|
|
349
|
+
rescue ActiveRecord::StatementInvalid
|
|
350
|
+
# performance_schema may be unavailable
|
|
316
351
|
end
|
|
317
352
|
|
|
318
353
|
messages = [
|
|
@@ -328,24 +363,27 @@ module MysqlGenius
|
|
|
328
363
|
|
|
329
364
|
Respond with JSON: {"risk_level": "low|medium|high|critical", "assessment": "markdown-formatted risk assessment with specific recommendations and estimated lock duration"}
|
|
330
365
|
PROMPT
|
|
331
|
-
{ role: "user", content: "Migration:\n#{migration_sql}\n\nAffected Tables:\n#{table_info.presence ||
|
|
366
|
+
{ role: "user", content: "Migration:\n#{migration_sql}\n\nAffected Tables:\n#{table_info.presence || "Could not determine"}\n\nActive Queries on These Tables:\n#{active.presence || "None found or performance_schema unavailable"}" },
|
|
332
367
|
]
|
|
333
368
|
|
|
334
369
|
result = AiClient.new.chat(messages: messages)
|
|
335
|
-
render
|
|
370
|
+
render(json: result)
|
|
336
371
|
rescue StandardError => e
|
|
337
|
-
render
|
|
372
|
+
render(json: { error: "Migration risk assessment failed: #{e.message}" }, status: :unprocessable_entity)
|
|
338
373
|
end
|
|
339
374
|
|
|
340
375
|
private
|
|
341
376
|
|
|
342
377
|
def ai_not_configured
|
|
343
|
-
render
|
|
378
|
+
render(json: { error: "AI features are not configured." }, status: :not_found)
|
|
344
379
|
end
|
|
345
380
|
|
|
346
381
|
def ai_domain_context
|
|
382
|
+
parts = []
|
|
383
|
+
parts << "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."
|
|
347
384
|
ctx = mysql_genius_config.ai_system_context
|
|
348
|
-
|
|
385
|
+
parts << "Domain context:\n#{ctx}" if ctx.present?
|
|
386
|
+
"\n" + parts.join("\n")
|
|
349
387
|
end
|
|
350
388
|
|
|
351
389
|
def build_schema_for_query(sql)
|
|
@@ -353,7 +391,7 @@ module MysqlGenius
|
|
|
353
391
|
tables = SqlValidator.extract_table_references(sql, connection)
|
|
354
392
|
tables.map do |t|
|
|
355
393
|
cols = connection.columns(t).map { |c| "#{c.name} (#{c.type})" }
|
|
356
|
-
"#{t}: #{cols.join(
|
|
394
|
+
"#{t}: #{cols.join(", ")}"
|
|
357
395
|
end.join("\n")
|
|
358
396
|
end
|
|
359
397
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
module DatabaseAnalysis
|
|
3
5
|
extend ActiveSupport::Concern
|
|
@@ -13,19 +15,20 @@ module MysqlGenius
|
|
|
13
15
|
indexes.each do |idx|
|
|
14
16
|
indexes.each do |other|
|
|
15
17
|
next if idx.name == other.name
|
|
18
|
+
|
|
16
19
|
# idx is duplicate if its columns are a left-prefix of other's columns
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
next unless idx.columns.size <= other.columns.size &&
|
|
21
|
+
other.columns.first(idx.columns.size) == idx.columns &&
|
|
22
|
+
!(idx.unique && !other.unique) # don't drop a unique index covered by a non-unique one
|
|
23
|
+
|
|
24
|
+
duplicates << {
|
|
25
|
+
table: table,
|
|
26
|
+
duplicate_index: idx.name,
|
|
27
|
+
duplicate_columns: idx.columns,
|
|
28
|
+
covered_by_index: other.name,
|
|
29
|
+
covered_by_columns: other.columns,
|
|
30
|
+
unique: idx.unique,
|
|
31
|
+
}
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -34,10 +37,15 @@ module MysqlGenius
|
|
|
34
37
|
seen = Set.new
|
|
35
38
|
duplicates = duplicates.reject do |d|
|
|
36
39
|
key = [d[:table], [d[:duplicate_index], d[:covered_by_index]].sort].flatten.join(":")
|
|
37
|
-
seen.include?(key)
|
|
40
|
+
if seen.include?(key)
|
|
41
|
+
true
|
|
42
|
+
else
|
|
43
|
+
(seen.add(key)
|
|
44
|
+
false)
|
|
45
|
+
end
|
|
38
46
|
end
|
|
39
47
|
|
|
40
|
-
render
|
|
48
|
+
render(json: duplicates)
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
def table_sizes
|
|
@@ -47,7 +55,10 @@ module MysqlGenius
|
|
|
47
55
|
results = connection.exec_query(<<~SQL)
|
|
48
56
|
SELECT
|
|
49
57
|
table_name,
|
|
50
|
-
|
|
58
|
+
engine,
|
|
59
|
+
table_collation,
|
|
60
|
+
auto_increment,
|
|
61
|
+
update_time,
|
|
51
62
|
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
|
|
52
63
|
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
|
|
53
64
|
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
|
|
@@ -59,29 +70,46 @@ module MysqlGenius
|
|
|
59
70
|
SQL
|
|
60
71
|
|
|
61
72
|
tables = results.map do |row|
|
|
73
|
+
table_name = row["table_name"] || row["TABLE_NAME"]
|
|
74
|
+
row_count = begin
|
|
75
|
+
connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table_name)}")
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
total_mb = (row["total_mb"] || 0).to_f
|
|
81
|
+
fragmented_mb = (row["fragmented_mb"] || 0).to_f
|
|
82
|
+
|
|
62
83
|
{
|
|
63
|
-
table:
|
|
64
|
-
rows:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
table: table_name,
|
|
85
|
+
rows: row_count,
|
|
86
|
+
engine: row["engine"] || row["ENGINE"],
|
|
87
|
+
collation: row["table_collation"] || row["TABLE_COLLATION"],
|
|
88
|
+
auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
|
|
89
|
+
updated_at: row["update_time"] || row["UPDATE_TIME"],
|
|
90
|
+
data_mb: (row["data_mb"] || 0).to_f,
|
|
91
|
+
index_mb: (row["index_mb"] || 0).to_f,
|
|
92
|
+
total_mb: total_mb,
|
|
93
|
+
fragmented_mb: fragmented_mb,
|
|
94
|
+
needs_optimize: total_mb > 0 && fragmented_mb > (total_mb * 0.1),
|
|
69
95
|
}
|
|
70
96
|
end
|
|
71
97
|
|
|
72
|
-
render
|
|
98
|
+
render(json: tables)
|
|
73
99
|
end
|
|
74
100
|
|
|
75
101
|
def query_stats
|
|
76
102
|
connection = ActiveRecord::Base.connection
|
|
77
|
-
sort =
|
|
103
|
+
sort = ["total_time", "avg_time", "calls", "rows_examined"].include?(params[:sort]) ? params[:sort] : "total_time"
|
|
78
104
|
|
|
79
105
|
order_clause = case sort
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
when "total_time" then "SUM_TIMER_WAIT DESC"
|
|
107
|
+
when "avg_time" then "AVG_TIMER_WAIT DESC"
|
|
108
|
+
when "calls" then "COUNT_STAR DESC"
|
|
109
|
+
when "rows_examined" then "SUM_ROWS_EXAMINED DESC"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
limit = params.fetch(:limit, 50).to_i.clamp(1, 50)
|
|
85
113
|
|
|
86
114
|
results = connection.exec_query(<<~SQL)
|
|
87
115
|
SELECT
|
|
@@ -101,7 +129,7 @@ module MysqlGenius
|
|
|
101
129
|
AND DIGEST_TEXT IS NOT NULL
|
|
102
130
|
AND DIGEST_TEXT NOT LIKE 'EXPLAIN%'
|
|
103
131
|
ORDER BY #{order_clause}
|
|
104
|
-
LIMIT
|
|
132
|
+
LIMIT #{limit}
|
|
105
133
|
SQL
|
|
106
134
|
|
|
107
135
|
queries = results.map do |row|
|
|
@@ -121,13 +149,13 @@ module MysqlGenius
|
|
|
121
149
|
tmp_disk_tables: (row["tmp_disk_tables"] || row["TMP_DISK_TABLES"] || 0).to_i,
|
|
122
150
|
sort_rows: (row["sort_rows"] || row["SORT_ROWS"] || 0).to_i,
|
|
123
151
|
first_seen: row["FIRST_SEEN"] || row["first_seen"],
|
|
124
|
-
last_seen: row["LAST_SEEN"] || row["last_seen"]
|
|
152
|
+
last_seen: row["LAST_SEEN"] || row["last_seen"],
|
|
125
153
|
}
|
|
126
154
|
end
|
|
127
155
|
|
|
128
|
-
render
|
|
156
|
+
render(json: queries)
|
|
129
157
|
rescue ActiveRecord::StatementInvalid => e
|
|
130
|
-
render
|
|
158
|
+
render(json: { error: "Query statistics require performance_schema to be enabled. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
131
159
|
end
|
|
132
160
|
|
|
133
161
|
def unused_indexes
|
|
@@ -139,8 +167,8 @@ module MysqlGenius
|
|
|
139
167
|
s.OBJECT_SCHEMA AS table_schema,
|
|
140
168
|
s.OBJECT_NAME AS table_name,
|
|
141
169
|
s.INDEX_NAME AS index_name,
|
|
142
|
-
s.COUNT_READ AS reads
|
|
143
|
-
s.COUNT_WRITE AS writes
|
|
170
|
+
s.COUNT_READ AS `reads`,
|
|
171
|
+
s.COUNT_WRITE AS `writes`,
|
|
144
172
|
t.TABLE_ROWS AS table_rows
|
|
145
173
|
FROM performance_schema.table_io_waits_summary_by_index_usage s
|
|
146
174
|
JOIN information_schema.tables t
|
|
@@ -162,13 +190,13 @@ module MysqlGenius
|
|
|
162
190
|
reads: (row["reads"] || row["READS"] || 0).to_i,
|
|
163
191
|
writes: (row["writes"] || row["WRITES"] || 0).to_i,
|
|
164
192
|
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
|
|
165
|
-
drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;"
|
|
193
|
+
drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;",
|
|
166
194
|
}
|
|
167
195
|
end
|
|
168
196
|
|
|
169
|
-
render
|
|
197
|
+
render(json: indexes)
|
|
170
198
|
rescue ActiveRecord::StatementInvalid => e
|
|
171
|
-
render
|
|
199
|
+
render(json: { error: "Unused index detection requires performance_schema. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
172
200
|
end
|
|
173
201
|
|
|
174
202
|
def server_overview
|
|
@@ -215,11 +243,11 @@ module MysqlGenius
|
|
|
215
243
|
questions = status["Questions"].to_i
|
|
216
244
|
qps = uptime_seconds > 0 ? (questions.to_f / uptime_seconds).round(1) : 0
|
|
217
245
|
|
|
218
|
-
render
|
|
246
|
+
render(json: {
|
|
219
247
|
server: {
|
|
220
248
|
version: version,
|
|
221
249
|
uptime: "#{days}d #{hours}h #{minutes}m",
|
|
222
|
-
uptime_seconds: uptime_seconds
|
|
250
|
+
uptime_seconds: uptime_seconds,
|
|
223
251
|
},
|
|
224
252
|
connections: {
|
|
225
253
|
max: max_conn,
|
|
@@ -230,7 +258,7 @@ module MysqlGenius
|
|
|
230
258
|
threads_created: status["Threads_created"].to_i,
|
|
231
259
|
aborted_connects: status["Aborted_connects"].to_i,
|
|
232
260
|
aborted_clients: status["Aborted_clients"].to_i,
|
|
233
|
-
max_used: status["Max_used_connections"].to_i
|
|
261
|
+
max_used: status["Max_used_connections"].to_i,
|
|
234
262
|
},
|
|
235
263
|
innodb: {
|
|
236
264
|
buffer_pool_mb: buffer_pool_mb,
|
|
@@ -239,7 +267,7 @@ module MysqlGenius
|
|
|
239
267
|
buffer_pool_pages_free: status["Innodb_buffer_pool_pages_free"].to_i,
|
|
240
268
|
buffer_pool_pages_total: status["Innodb_buffer_pool_pages_total"].to_i,
|
|
241
269
|
row_lock_waits: status["Innodb_row_lock_waits"].to_i,
|
|
242
|
-
row_lock_time_ms:
|
|
270
|
+
row_lock_time_ms: status["Innodb_row_lock_time"].to_f.round(0),
|
|
243
271
|
},
|
|
244
272
|
queries: {
|
|
245
273
|
questions: questions,
|
|
@@ -249,11 +277,11 @@ module MysqlGenius
|
|
|
249
277
|
tmp_disk_tables: tmp_disk_tables,
|
|
250
278
|
tmp_disk_pct: tmp_disk_pct,
|
|
251
279
|
select_full_join: status["Select_full_join"].to_i,
|
|
252
|
-
sort_merge_passes: status["Sort_merge_passes"].to_i
|
|
253
|
-
}
|
|
254
|
-
}
|
|
280
|
+
sort_merge_passes: status["Sort_merge_passes"].to_i,
|
|
281
|
+
},
|
|
282
|
+
})
|
|
255
283
|
rescue => e
|
|
256
|
-
render
|
|
284
|
+
render(json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity)
|
|
257
285
|
end
|
|
258
286
|
end
|
|
259
287
|
end
|