mysql_genius 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdeef202be3b51304362d36eeaba8ef9ca5a1f70e8449e09f168e1cdd2127843
4
- data.tar.gz: 9c2d7e67ebf4d7f76edb13f3f360f4befdc03763e1ac1200a13258e06eb53647
3
+ metadata.gz: 2ccbb1061d5424d82fe20ee1eea6793a582bb78fca163336453a794a465499ad
4
+ data.tar.gz: 267dc2a66b94b835f597e2eb6bc9540f261dad80a45a9d3fe9ddba1c714f2b5b
5
5
  SHA512:
6
- metadata.gz: 3fd5a7dafd48b6baaac1becce559937e848ee98bf4a317488a61b246c80d68b1b6332ec671c552d58ce35789ec1a739ebb0aa0552dcf0607fe2c59b923f36bba
7
- data.tar.gz: ce9271202e02a98d257467ac574a31692512c3f9693e6cceb25ce7f7ed76a397193c4437b4fa1bbe6f3c4ea4926ae6bd79e1a0dbd91b9b41f34e9434ab95ce23
6
+ metadata.gz: bc2a345c4bfc5d37eecd0567e6786998a73f1e6c0109b9b1d86be156a4994576bfe7a7653b86162a3a8f20ee00a76c94c7c36d1a780f655e602464aa4f6ba3f4
7
+ data.tar.gz: 0b9bd46ba51a668657978cacc2ef44e1928a033ed20c9f2883ac93ffbd5025587c57789991fa4fa060617146eaf74bbb8c3961d0e9d44660a0a78c3580886241
@@ -15,6 +15,11 @@ jobs:
15
15
  ruby-version: 3.3
16
16
  bundler-cache: true
17
17
  - run: bundle exec rspec
18
+ - name: Test mysql_genius-core gem
19
+ working-directory: gems/mysql_genius-core
20
+ run: |
21
+ bundle install
22
+ bundle exec rspec
18
23
 
19
24
  publish:
20
25
  needs: test
@@ -24,9 +29,23 @@ jobs:
24
29
  - uses: ruby/setup-ruby@v1
25
30
  with:
26
31
  ruby-version: 3.3
27
- - name: Build gem
32
+
33
+ # Build and push mysql_genius-core FIRST so that mysql_genius's
34
+ # runtime dependency on it can resolve at gem install time.
35
+ - name: Build mysql_genius-core
36
+ working-directory: gems/mysql_genius-core
37
+ run: gem build mysql_genius-core.gemspec
38
+
39
+ - name: Publish mysql_genius-core
40
+ working-directory: gems/mysql_genius-core
41
+ run: gem push mysql_genius-core-*.gem
42
+ env:
43
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
44
+
45
+ - name: Build mysql_genius
28
46
  run: gem build mysql_genius.gemspec
29
- - name: Publish to RubyGems
47
+
48
+ - name: Publish mysql_genius
30
49
  run: gem push mysql_genius-*.gem
31
50
  env:
32
51
  GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore CHANGED
@@ -13,3 +13,4 @@
13
13
  CLAUDE.md
14
14
  Gemfile.lock
15
15
  *.gem
16
+ docs/superpowers/
data/.rubocop.yml CHANGED
@@ -21,4 +21,4 @@ RSpec/ExampleLength:
21
21
  Max: 25
22
22
 
23
23
  RSpec/MultipleExpectations:
24
- Max: 5
24
+ Max: 10
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Fixed
6
+ - **Boot-order bug: `MysqlGenius::Core::Connection::ActiveRecordAdapter` was not required by `lib/mysql_genius.rb`** — shipped in Phase 1a but never wired into the production require chain. Invisible to CI because the adapter's spec file explicitly required it, and invisible in development because pre-Phase-1b concerns didn't reference it. Phase 1b's extracted delegators instantiate it in every action, so the missing require would have surfaced as `uninitialized constant` on every tab (tables, query stats, unused indexes, duplicate indexes, server overview, execute, explain, AI suggest, AI optimize) in any host app that installed 0.4.0 without the fix. `lib/mysql_genius.rb` now explicitly requires the adapter after loading `mysql_genius/core`, and `spec/spec_helper.rb` has a regression guard that aborts the spec suite at boot if the constant is not reachable via a plain `require "mysql_genius"`.
7
+
8
+ ### Changed
9
+ - **Internal refactor: extracted Rails-free core library into a new `mysql_genius-core` gem.** The validator, AI services, value objects, database analyses, query runner, and query explainer now live in `mysql_genius-core`; the `mysql_genius` Rails engine delegates through a new `Core::Connection::ActiveRecordAdapter`. Public API, routes, config DSL, and JSON response shapes are unchanged — host apps see no difference after `bundle update`. See [the design spec](docs/superpowers/specs/2026-04-10-desktop-app-design.md) for the motivation: the new core gem is the foundation for a forthcoming `mysql_genius-desktop` standalone app.
10
+ - `mysql_genius` now declares a runtime dependency on `mysql_genius-core ~> 0.4.0`. The two gems release in lockstep under matching version numbers (0.4.0 is the first paired release); the dependency resolves transitively, so host apps do not need to add `mysql_genius-core` to their Gemfile.
11
+ - `MysqlGenius::SqlValidator` moved to `MysqlGenius::Core::SqlValidator`.
12
+ - `MysqlGenius::AiClient`, `MysqlGenius::AiSuggestionService`, `MysqlGenius::AiOptimizationService` moved to `MysqlGenius::Core::Ai::{Client, Suggestion, Optimization}` and now take an explicit `Core::Ai::Config` instead of reading `MysqlGenius.configuration` at construction time.
13
+ - The 5 database analyses (`table_sizes`, `duplicate_indexes`, `query_stats`, `unused_indexes`, `server_overview`) moved from the `DatabaseAnalysis` controller concern into `MysqlGenius::Core::Analysis::*` classes, each taking a `Core::Connection`. The concern shrunk from ~295 lines to 47 lines of thin delegating wrappers.
14
+ - `MysqlGenius::Core::QueryRunner` now owns SQL validation, row-limit application, timeout-hint wrapping (MySQL / MariaDB flavors), execution, column masking, and timeout detection. The `execute` controller action delegates to it. Audit logging stays in the Rails adapter.
15
+ - `MysqlGenius::Core::QueryExplainer` now owns the EXPLAIN path with optional validation-skipping for captured slow queries. The `explain` controller action delegates to it.
16
+
17
+ ### Documentation
18
+ - Added README troubleshooting section covering `SSL_connect ... EC lib` / `unable to decode issuer public key` errors that hit Ruby 2.7 + OpenSSL 1.1.x users talking to Google Trust Services-backed hosts like Ollama Cloud. Recommends local Ollama (`http://localhost:11434`) as the fastest unblock, `SSL_CERT_FILE` pointing at a fresher CA bundle as an intermediate fix, and upgrading to Ruby 3.2+ as the durable fix.
19
+ - Added `docs/superpowers/specs/2026-04-10-desktop-app-design.md` — the full design spec for the eventual `mysql_genius-desktop` standalone app.
20
+
3
21
  ## 0.3.2
4
22
 
5
23
  ### Fixed
data/Gemfile CHANGED
@@ -4,6 +4,8 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
+ gem "mysql_genius-core", path: "gems/mysql_genius-core"
8
+
7
9
  if ENV["RAILS_VERSION"]
8
10
  rails_version = ENV["RAILS_VERSION"]
9
11
  gem "actionpack", "~> #{rails_version}.0"
data/README.md CHANGED
@@ -103,6 +103,33 @@ end
103
103
 
104
104
  For all provider examples, see the [AI Features guide](https://github.com/antarr/mysql_genius/wiki/AI-Features).
105
105
 
106
+ ### Troubleshooting TLS errors with Ollama Cloud
107
+
108
+ If you see `SSL_connect ... unable to decode issuer public key` or `SSL_connect ... EC lib` when using an AI feature, your Rails host's Ruby is linked against an older OpenSSL that can't verify modern ECDSA certificate chains (Ollama Cloud is served behind Google Trust Services, whose ECDSA roots trip up OpenSSL 1.1.x and earlier). This is not specific to `mysql_genius` — it affects any Ruby HTTPS call to those hosts.
109
+
110
+ Three ways to fix it, in order of effort:
111
+
112
+ **Use a local Ollama instead.** Point the endpoint at `http://localhost:11434/v1/chat/completions`. Your Rails app talks plain HTTP to the local `ollama` binary, which handles the upstream TLS itself using its own modern cert handling. For cloud-backed models, run `ollama signin` once and use the `:cloud` model suffix (e.g., `gemma3:27b-cloud`).
113
+
114
+ ```ruby
115
+ MysqlGenius.configure do |config|
116
+ config.ai_endpoint = "http://localhost:11434/v1/chat/completions"
117
+ config.ai_api_key = "unused-but-required" # any non-empty string
118
+ config.ai_model = "gemma3:27b-cloud" # or any local model
119
+ config.ai_auth_style = :bearer
120
+ end
121
+ ```
122
+
123
+ **Point Ruby at a fresher CA bundle.** Set `SSL_CERT_FILE` in the environment where Rails boots. On macOS with Homebrew:
124
+
125
+ ```bash
126
+ SSL_CERT_FILE=/opt/homebrew/etc/openssl@3/cert.pem bin/rails s
127
+ ```
128
+
129
+ This helps if the problem is a stale trust store, but does **not** help if the underlying OpenSSL itself is too old to parse the cert's key algorithm.
130
+
131
+ **Upgrade Ruby** to 3.2 or newer. Ruby 2.7 is end-of-life (March 2023); newer Rubies link against OpenSSL 3.x which handles modern ECDSA chains correctly. This is the durable fix and the one we recommend for any project still on 2.7.
132
+
106
133
  ## Compatibility
107
134
 
108
135
  | Rails | Ruby |
@@ -12,7 +12,9 @@ module MysqlGenius
12
12
  prompt = params[:prompt].to_s.strip
13
13
  return render(json: { error: "Please describe what you want to query." }, status: :unprocessable_entity) if prompt.blank?
14
14
 
15
- result = AiSuggestionService.new.call(prompt, queryable_tables)
15
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
16
+ service = MysqlGenius::Core::Ai::Suggestion.new(connection, ai_client, ai_config_for_core)
17
+ result = service.call(prompt, queryable_tables)
16
18
  sql = sanitize_ai_sql(result["sql"].to_s)
17
19
  render(json: { sql: sql, explanation: result["explanation"] })
18
20
  rescue StandardError => e
@@ -31,7 +33,9 @@ module MysqlGenius
31
33
  return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity)
32
34
  end
33
35
 
34
- result = AiOptimizationService.new.call(sql, explain_rows, queryable_tables)
36
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
37
+ service = MysqlGenius::Core::Ai::Optimization.new(connection, ai_client, ai_config_for_core)
38
+ result = service.call(sql, explain_rows, queryable_tables)
35
39
  render(json: result)
36
40
  rescue StandardError => e
37
41
  render(json: { error: "Optimization failed: #{e.message}" }, status: :unprocessable_entity)
@@ -56,7 +60,7 @@ module MysqlGenius
56
60
  { role: "user", content: sql },
57
61
  ]
58
62
 
59
- result = AiClient.new.chat(messages: messages)
63
+ result = ai_client.chat(messages: messages)
60
64
  render(json: result)
61
65
  rescue StandardError => e
62
66
  render(json: { error: "Explanation failed: #{e.message}" }, status: :unprocessable_entity)
@@ -100,7 +104,7 @@ module MysqlGenius
100
104
  { role: "user", content: schema_desc },
101
105
  ]
102
106
 
103
- result = AiClient.new.chat(messages: messages)
107
+ result = ai_client.chat(messages: messages)
104
108
  render(json: result)
105
109
  rescue StandardError => e
106
110
  render(json: { error: "Schema review failed: #{e.message}" }, status: :unprocessable_entity)
@@ -136,7 +140,7 @@ module MysqlGenius
136
140
  { role: "user", content: sql },
137
141
  ]
138
142
 
139
- result = AiClient.new.chat(messages: messages)
143
+ result = ai_client.chat(messages: messages)
140
144
  render(json: result)
141
145
  rescue StandardError => e
142
146
  render(json: { error: "Rewrite failed: #{e.message}" }, status: :unprocessable_entity)
@@ -150,7 +154,7 @@ module MysqlGenius
150
154
  return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity) if sql.blank? || explain_rows.blank?
151
155
 
152
156
  connection = ActiveRecord::Base.connection
153
- tables_in_query = SqlValidator.extract_table_references(sql, connection)
157
+ tables_in_query = MysqlGenius::Core::SqlValidator.extract_table_references(sql, connection)
154
158
 
155
159
  index_detail = tables_in_query.map do |t|
156
160
  indexes = connection.indexes(t).map { |idx| "#{"UNIQUE " if idx.unique}INDEX #{idx.name} (#{idx.columns.join(", ")})" }
@@ -175,7 +179,7 @@ module MysqlGenius
175
179
  { role: "user", content: "Query:\n#{sql}\n\nEXPLAIN:\n#{explain_rows.map { |r| r.join(" | ") }.join("\n")}\n\nCurrent Indexes:\n#{index_detail}" },
176
180
  ]
177
181
 
178
- result = AiClient.new.chat(messages: messages)
182
+ result = ai_client.chat(messages: messages)
179
183
  render(json: result)
180
184
  rescue StandardError => e
181
185
  render(json: { error: "Index advisor failed: #{e.message}" }, status: :unprocessable_entity)
@@ -235,7 +239,7 @@ module MysqlGenius
235
239
  { 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"}" },
236
240
  ]
237
241
 
238
- result = AiClient.new.chat(messages: messages)
242
+ result = ai_client.chat(messages: messages)
239
243
  render(json: result)
240
244
  rescue StandardError => e
241
245
  render(json: { error: "Anomaly detection failed: #{e.message}" }, status: :unprocessable_entity)
@@ -307,7 +311,7 @@ module MysqlGenius
307
311
  { 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"}" },
308
312
  ]
309
313
 
310
- result = AiClient.new.chat(messages: messages)
314
+ result = ai_client.chat(messages: messages)
311
315
  render(json: result)
312
316
  rescue StandardError => e
313
317
  render(json: { error: "Root cause analysis failed: #{e.message}" }, status: :unprocessable_entity)
@@ -366,7 +370,7 @@ module MysqlGenius
366
370
  { 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"}" },
367
371
  ]
368
372
 
369
- result = AiClient.new.chat(messages: messages)
373
+ result = ai_client.chat(messages: messages)
370
374
  render(json: result)
371
375
  rescue StandardError => e
372
376
  render(json: { error: "Migration risk assessment failed: #{e.message}" }, status: :unprocessable_entity)
@@ -374,6 +378,22 @@ module MysqlGenius
374
378
 
375
379
  private
376
380
 
381
+ def ai_client
382
+ MysqlGenius::Core::Ai::Client.new(ai_config_for_core)
383
+ end
384
+
385
+ def ai_config_for_core
386
+ cfg = mysql_genius_config
387
+ MysqlGenius::Core::Ai::Config.new(
388
+ client: cfg.ai_client,
389
+ endpoint: cfg.ai_endpoint,
390
+ api_key: cfg.ai_api_key,
391
+ model: cfg.ai_model,
392
+ auth_style: cfg.ai_auth_style,
393
+ system_context: cfg.ai_system_context,
394
+ )
395
+ end
396
+
377
397
  def ai_not_configured
378
398
  render(json: { error: "AI features are not configured." }, status: :not_found)
379
399
  end
@@ -388,7 +408,7 @@ module MysqlGenius
388
408
 
389
409
  def build_schema_for_query(sql)
390
410
  connection = ActiveRecord::Base.connection
391
- tables = SqlValidator.extract_table_references(sql, connection)
411
+ tables = MysqlGenius::Core::SqlValidator.extract_table_references(sql, connection)
392
412
  tables.map do |t|
393
413
  cols = connection.columns(t).map { |c| "#{c.name} (#{c.type})" }
394
414
  "#{t}: #{cols.join(", ")}"
@@ -5,289 +5,41 @@ module MysqlGenius
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  def duplicate_indexes
8
- connection = ActiveRecord::Base.connection
9
- duplicates = []
10
-
11
- queryable_tables.each do |table|
12
- indexes = connection.indexes(table)
13
- next if indexes.size < 2
14
-
15
- indexes.each do |idx|
16
- indexes.each do |other|
17
- next if idx.name == other.name
18
-
19
- # idx is duplicate if its columns are a left-prefix of other's columns
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
- }
32
- end
33
- end
34
- end
35
-
36
- # Deduplicate (A covers B and B covers A when columns are identical -- keep only one)
37
- seen = Set.new
38
- duplicates = duplicates.reject do |d|
39
- key = [d[:table], [d[:duplicate_index], d[:covered_by_index]].sort].flatten.join(":")
40
- if seen.include?(key)
41
- true
42
- else
43
- (seen.add(key)
44
- false)
45
- end
46
- end
47
-
8
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
9
+ duplicates = MysqlGenius::Core::Analysis::DuplicateIndexes
10
+ .new(connection, blocked_tables: mysql_genius_config.blocked_tables)
11
+ .call
48
12
  render(json: duplicates)
49
13
  end
50
14
 
51
15
  def table_sizes
52
- connection = ActiveRecord::Base.connection
53
- db_name = connection.current_database
54
-
55
- results = connection.exec_query(<<~SQL)
56
- SELECT
57
- table_name,
58
- engine,
59
- table_collation,
60
- auto_increment,
61
- update_time,
62
- ROUND(data_length / 1024 / 1024, 2) AS data_mb,
63
- ROUND(index_length / 1024 / 1024, 2) AS index_mb,
64
- ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
65
- ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
66
- FROM information_schema.tables
67
- WHERE table_schema = #{connection.quote(db_name)}
68
- AND table_type = 'BASE TABLE'
69
- ORDER BY (data_length + index_length) DESC
70
- SQL
71
-
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
-
83
- {
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),
95
- }
96
- end
97
-
16
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
17
+ tables = MysqlGenius::Core::Analysis::TableSizes.new(connection).call
98
18
  render(json: tables)
99
19
  end
100
20
 
101
21
  def query_stats
102
- connection = ActiveRecord::Base.connection
103
- sort = ["total_time", "avg_time", "calls", "rows_examined"].include?(params[:sort]) ? params[:sort] : "total_time"
104
-
105
- order_clause = case sort
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)
113
-
114
- results = connection.exec_query(<<~SQL)
115
- SELECT
116
- DIGEST_TEXT,
117
- COUNT_STAR AS calls,
118
- ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_time_ms,
119
- ROUND(AVG_TIMER_WAIT / 1000000000, 1) AS avg_time_ms,
120
- ROUND(MAX_TIMER_WAIT / 1000000000, 1) AS max_time_ms,
121
- SUM_ROWS_EXAMINED AS rows_examined,
122
- SUM_ROWS_SENT AS rows_sent,
123
- SUM_CREATED_TMP_DISK_TABLES AS tmp_disk_tables,
124
- SUM_SORT_ROWS AS sort_rows,
125
- FIRST_SEEN,
126
- LAST_SEEN
127
- FROM performance_schema.events_statements_summary_by_digest
128
- WHERE SCHEMA_NAME = #{connection.quote(connection.current_database)}
129
- AND DIGEST_TEXT IS NOT NULL
130
- AND DIGEST_TEXT NOT LIKE 'EXPLAIN%'
131
- AND DIGEST_TEXT NOT LIKE '%`information_schema`%'
132
- AND DIGEST_TEXT NOT LIKE '%`performance_schema`%'
133
- AND DIGEST_TEXT NOT LIKE '%information_schema.%'
134
- AND DIGEST_TEXT NOT LIKE '%performance_schema.%'
135
- AND DIGEST_TEXT NOT LIKE 'SHOW %'
136
- AND DIGEST_TEXT NOT LIKE 'SET STATEMENT %'
137
- AND DIGEST_TEXT NOT LIKE 'SELECT VERSION ( )%'
138
- AND DIGEST_TEXT NOT LIKE 'SELECT @@%'
139
- ORDER BY #{order_clause}
140
- LIMIT #{limit}
141
- SQL
142
-
143
- queries = results.map do |row|
144
- digest = row["DIGEST_TEXT"] || row["digest_text"] || ""
145
- calls = (row["calls"] || row["CALLS"] || 0).to_i
146
- rows_examined = (row["rows_examined"] || row["ROWS_EXAMINED"] || 0).to_i
147
- rows_sent = (row["rows_sent"] || row["ROWS_SENT"] || 0).to_i
148
- {
149
- sql: digest.truncate(500),
150
- calls: calls,
151
- total_time_ms: row["total_time_ms"].to_f,
152
- avg_time_ms: row["avg_time_ms"].to_f,
153
- max_time_ms: row["max_time_ms"].to_f,
154
- rows_examined: rows_examined,
155
- rows_sent: rows_sent,
156
- rows_ratio: rows_sent > 0 ? (rows_examined.to_f / rows_sent).round(1) : 0,
157
- tmp_disk_tables: (row["tmp_disk_tables"] || row["TMP_DISK_TABLES"] || 0).to_i,
158
- sort_rows: (row["sort_rows"] || row["SORT_ROWS"] || 0).to_i,
159
- first_seen: row["FIRST_SEEN"] || row["first_seen"],
160
- last_seen: row["LAST_SEEN"] || row["last_seen"],
161
- }
162
- end
163
-
22
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
23
+ sort = params[:sort].to_s
24
+ limit = params.fetch(:limit, MysqlGenius::Core::Analysis::QueryStats::MAX_LIMIT).to_i
25
+ queries = MysqlGenius::Core::Analysis::QueryStats.new(connection).call(sort: sort, limit: limit)
164
26
  render(json: queries)
165
27
  rescue ActiveRecord::StatementInvalid => e
166
28
  render(json: { error: "Query statistics require performance_schema to be enabled. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
167
29
  end
168
30
 
169
31
  def unused_indexes
170
- connection = ActiveRecord::Base.connection
171
- db_name = connection.current_database
172
-
173
- results = connection.exec_query(<<~SQL)
174
- SELECT
175
- s.OBJECT_SCHEMA AS table_schema,
176
- s.OBJECT_NAME AS table_name,
177
- s.INDEX_NAME AS index_name,
178
- s.COUNT_READ AS `reads`,
179
- s.COUNT_WRITE AS `writes`,
180
- t.TABLE_ROWS AS table_rows
181
- FROM performance_schema.table_io_waits_summary_by_index_usage s
182
- JOIN information_schema.tables t
183
- ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
184
- WHERE s.OBJECT_SCHEMA = #{connection.quote(db_name)}
185
- AND s.INDEX_NAME IS NOT NULL
186
- AND s.INDEX_NAME != 'PRIMARY'
187
- AND s.COUNT_READ = 0
188
- AND t.TABLE_ROWS > 0
189
- ORDER BY s.COUNT_WRITE DESC
190
- SQL
191
-
192
- indexes = results.map do |row|
193
- table = row["table_name"] || row["TABLE_NAME"]
194
- index_name = row["index_name"] || row["INDEX_NAME"]
195
- {
196
- table: table,
197
- index_name: index_name,
198
- reads: (row["reads"] || row["READS"] || 0).to_i,
199
- writes: (row["writes"] || row["WRITES"] || 0).to_i,
200
- table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
201
- drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;",
202
- }
203
- end
204
-
32
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
33
+ indexes = MysqlGenius::Core::Analysis::UnusedIndexes.new(connection).call
205
34
  render(json: indexes)
206
35
  rescue ActiveRecord::StatementInvalid => e
207
36
  render(json: { error: "Unused index detection requires performance_schema. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
208
37
  end
209
38
 
210
39
  def server_overview
211
- connection = ActiveRecord::Base.connection
212
-
213
- # Global status variables
214
- status_rows = connection.exec_query("SHOW GLOBAL STATUS")
215
- status = {}
216
- status_rows.each { |r| status[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
217
-
218
- # Global variables
219
- vars_rows = connection.exec_query("SHOW GLOBAL VARIABLES")
220
- vars = {}
221
- vars_rows.each { |r| vars[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
222
-
223
- version = connection.select_value("SELECT VERSION()")
224
- uptime_seconds = status["Uptime"].to_i
225
-
226
- days = uptime_seconds / 86400
227
- hours = (uptime_seconds % 86400) / 3600
228
- minutes = (uptime_seconds % 3600) / 60
229
-
230
- max_conn = vars["max_connections"].to_i
231
- current_conn = status["Threads_connected"].to_i
232
- conn_pct = max_conn > 0 ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
233
-
234
- buffer_pool_bytes = vars["innodb_buffer_pool_size"].to_i
235
- buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
236
-
237
- # Buffer pool hit rate
238
- reads = status["Innodb_buffer_pool_read_requests"].to_f
239
- disk_reads = status["Innodb_buffer_pool_reads"].to_f
240
- hit_rate = reads > 0 ? (((reads - disk_reads) / reads) * 100).round(2) : 0
241
-
242
- # Tmp tables
243
- tmp_tables = status["Created_tmp_tables"].to_i
244
- tmp_disk_tables = status["Created_tmp_disk_tables"].to_i
245
- tmp_disk_pct = tmp_tables > 0 ? ((tmp_disk_tables.to_f / tmp_tables) * 100).round(1) : 0
246
-
247
- # Slow queries from MySQL's own counter
248
- slow_queries = status["Slow_queries"].to_i
249
-
250
- # Questions (total queries)
251
- questions = status["Questions"].to_i
252
- qps = uptime_seconds > 0 ? (questions.to_f / uptime_seconds).round(1) : 0
253
-
254
- render(json: {
255
- server: {
256
- version: version,
257
- uptime: "#{days}d #{hours}h #{minutes}m",
258
- uptime_seconds: uptime_seconds,
259
- },
260
- connections: {
261
- max: max_conn,
262
- current: current_conn,
263
- usage_pct: conn_pct,
264
- threads_running: status["Threads_running"].to_i,
265
- threads_cached: status["Threads_cached"].to_i,
266
- threads_created: status["Threads_created"].to_i,
267
- aborted_connects: status["Aborted_connects"].to_i,
268
- aborted_clients: status["Aborted_clients"].to_i,
269
- max_used: status["Max_used_connections"].to_i,
270
- },
271
- innodb: {
272
- buffer_pool_mb: buffer_pool_mb,
273
- buffer_pool_hit_rate: hit_rate,
274
- buffer_pool_pages_dirty: status["Innodb_buffer_pool_pages_dirty"].to_i,
275
- buffer_pool_pages_free: status["Innodb_buffer_pool_pages_free"].to_i,
276
- buffer_pool_pages_total: status["Innodb_buffer_pool_pages_total"].to_i,
277
- row_lock_waits: status["Innodb_row_lock_waits"].to_i,
278
- row_lock_time_ms: status["Innodb_row_lock_time"].to_f.round(0),
279
- },
280
- queries: {
281
- questions: questions,
282
- qps: qps,
283
- slow_queries: slow_queries,
284
- tmp_tables: tmp_tables,
285
- tmp_disk_tables: tmp_disk_tables,
286
- tmp_disk_pct: tmp_disk_pct,
287
- select_full_join: status["Select_full_join"].to_i,
288
- sort_merge_passes: status["Sort_merge_passes"].to_i,
289
- },
290
- })
40
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
41
+ overview = MysqlGenius::Core::Analysis::ServerOverview.new(connection).call
42
+ render(json: overview)
291
43
  rescue => e
292
44
  render(json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity)
293
45
  end