mysql_genius 0.1.1 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +5 -0
  3. data/.github/workflows/ci.yml +30 -7
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +24 -0
  6. data/CHANGELOG.md +32 -0
  7. data/Gemfile +7 -2
  8. data/README.md +50 -38
  9. data/Rakefile +3 -1
  10. data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
  11. data/app/controllers/concerns/mysql_genius/database_analysis.rb +73 -45
  12. data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
  13. data/app/controllers/mysql_genius/base_controller.rb +3 -1
  14. data/app/controllers/mysql_genius/queries_controller.rb +19 -12
  15. data/app/services/mysql_genius/ai_client.rb +9 -2
  16. data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
  17. data/app/services/mysql_genius/ai_suggestion_service.rb +5 -2
  18. data/app/views/layouts/mysql_genius/application.html.erb +141 -5
  19. data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
  20. data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
  21. data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
  22. data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +6 -4
  23. data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
  24. data/app/views/mysql_genius/queries/index.html.erb +377 -52
  25. data/bin/console +1 -0
  26. data/config/routes.rb +2 -0
  27. data/docs/screenshots/dashboard.png +0 -0
  28. data/docs/screenshots/query_explore.png +0 -0
  29. data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
  30. data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
  31. data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
  32. data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
  33. data/lib/mysql_genius/configuration.rb +8 -6
  34. data/lib/mysql_genius/engine.rb +2 -0
  35. data/lib/mysql_genius/slow_query_monitor.rb +29 -25
  36. data/lib/mysql_genius/sql_validator.rb +6 -4
  37. data/lib/mysql_genius/version.rb +3 -1
  38. data/lib/mysql_genius.rb +2 -0
  39. data/mysql_genius.gemspec +9 -8
  40. metadata +19 -15
  41. data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
  42. data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
  43. data/docs/screenshots/sql_query.png +0 -0
  44. 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 json: { error: "AI features are not configured." }, status: :not_found
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 json: { error: "Please describe what you want to query." }, status: :unprocessable_entity if prompt.blank?
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 json: { sql: sql, explanation: result["explanation"] }
17
+ render(json: { sql: sql, explanation: result["explanation"] })
16
18
  rescue StandardError => e
17
- render json: { error: "AI suggestion failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "AI features are not configured." }, status: :not_found
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 json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity
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 json: result
35
+ render(json: result)
34
36
  rescue StandardError => e
35
- render json: { error: "Optimization failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "SQL is required." }, status: :unprocessable_entity if sql.blank?
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 json: result
60
+ render(json: result)
58
61
  rescue StandardError => e
59
- render json: { error: "Explanation failed: #{e.message}" }, status: :unprocessable_entity
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
- cols = connection.columns(t).map { |c| "#{c.name} #{c.sql_type}#{c.null ? '' : ' NOT NULL'}#{c.default ? " DEFAULT #{c.default}" : ''}" }
71
- indexes = connection.indexes(t).map { |idx| "#{idx.unique ? 'UNIQUE ' : ''}INDEX #{idx.name} (#{idx.columns.join(', ')})" }
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)\nColumns: #{cols.join(', ')}\nIndexes: #{indexes.any? ? indexes.join(', ') : 'NONE'}"
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 json: result
104
+ render(json: result)
95
105
  rescue StandardError => e
96
- render json: { error: "Schema review failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "SQL is required." }, status: :unprocessable_entity if sql.blank?
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 json: result
140
+ render(json: result)
130
141
  rescue StandardError => e
131
- render json: { error: "Rewrite failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity if sql.blank? || explain_rows.blank?
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| "#{idx.unique ? 'UNIQUE ' : ''}INDEX #{idx.name} (#{idx.columns.join(', ')})" }
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('; ') : 'NONE'}\nCardinality: #{cardinality}"
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(' | ') }.join("\n")}\n\nCurrent Indexes:\n#{index_detail}" }
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 json: result
179
+ render(json: result)
168
180
  rescue StandardError => e
169
- render json: { error: "Index advisor failed: #{e.message}" }, status: :unprocessable_entity
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 { |e| JSON.parse(e) rescue nil }.compact
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['duration_ms']}ms @ #{q['timestamp']}: #{q['sql'].to_s.truncate(150)}" }.join("\n")
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 || 'None captured'}\n\nTop Queries by Total Time:\n#{stats_summary.presence || 'Not available'}" }
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 json: result
239
+ render(json: result)
223
240
  rescue StandardError => e
224
- render json: { error: "Anomaly detection failed: #{e.message}" }, status: :unprocessable_entity
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 = %w[Threads_connected Threads_running Innodb_row_lock_waits Innodb_row_lock_current_waits
241
- Innodb_buffer_pool_reads Innodb_buffer_pool_read_requests Slow_queries Created_tmp_disk_tables
242
- Connections Aborted_connects].map { |k| "#{k}=#{status[k]}" }.join(", ")
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 { |e| JSON.parse(e) rescue nil }.compact
258
- slow_summary = slows.map { |q| "#{q['duration_ms']}ms: #{q['sql'].to_s.truncate(150)}" }.join("\n")
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 || 'Not available'}\n\nRecent Slow Queries:\n#{slow_summary.presence || 'None captured'}" }
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 json: result
311
+ render(json: result)
280
312
  rescue StandardError => e
281
- render json: { error: "Root cause analysis failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "Migration SQL or Ruby code is required." }, status: :unprocessable_entity if migration_sql.blank?
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 || 'Could not determine'}\n\nActive Queries on These Tables:\n#{active.presence || 'None found or performance_schema unavailable'}" }
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 json: result
370
+ render(json: result)
336
371
  rescue StandardError => e
337
- render json: { error: "Migration risk assessment failed: #{e.message}" }, status: :unprocessable_entity
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 json: { error: "AI features are not configured." }, status: :not_found
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
- ctx.present? ? "\nDomain context:\n#{ctx}" : ""
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
- if idx.columns.size <= other.columns.size &&
18
- other.columns.first(idx.columns.size) == idx.columns &&
19
- !(idx.unique && !other.unique) # don't drop a unique index covered by a non-unique one
20
- duplicates << {
21
- table: table,
22
- duplicate_index: idx.name,
23
- duplicate_columns: idx.columns,
24
- covered_by_index: other.name,
25
- covered_by_columns: other.columns,
26
- unique: idx.unique
27
- }
28
- end
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) ? true : (seen.add(key); false)
40
+ if seen.include?(key)
41
+ true
42
+ else
43
+ (seen.add(key)
44
+ false)
45
+ end
38
46
  end
39
47
 
40
- render json: duplicates
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
- table_rows,
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: row["table_name"] || row["TABLE_NAME"],
64
- rows: row["table_rows"] || row["TABLE_ROWS"],
65
- data_mb: row["data_mb"].to_f,
66
- index_mb: row["index_mb"].to_f,
67
- total_mb: row["total_mb"].to_f,
68
- fragmented_mb: row["fragmented_mb"].to_f
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 json: tables
98
+ render(json: tables)
73
99
  end
74
100
 
75
101
  def query_stats
76
102
  connection = ActiveRecord::Base.connection
77
- sort = %w[total_time avg_time calls rows_examined].include?(params[:sort]) ? params[:sort] : "total_time"
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
- when "total_time" then "SUM_TIMER_WAIT DESC"
81
- when "avg_time" then "AVG_TIMER_WAIT DESC"
82
- when "calls" then "COUNT_STAR DESC"
83
- when "rows_examined" then "SUM_ROWS_EXAMINED DESC"
84
- end
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 50
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 json: queries
156
+ render(json: queries)
129
157
  rescue ActiveRecord::StatementInvalid => e
130
- render json: { error: "Query statistics require performance_schema to be enabled. #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
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 json: indexes
197
+ render(json: indexes)
170
198
  rescue ActiveRecord::StatementInvalid => e
171
- render json: { error: "Unused index detection requires performance_schema. #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
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 json: {
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: (status["Innodb_row_lock_time"].to_f).round(0)
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 json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity
284
+ render(json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity)
257
285
  end
258
286
  end
259
287
  end