mysql_genius 0.3.1 → 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.
@@ -12,103 +12,61 @@ module MysqlGenius
12
12
  mysql_genius_config.default_row_limit
13
13
  end
14
14
 
15
- error = validate_sql(sql)
16
- if error
17
- audit(:rejection, sql: sql, reason: error)
18
- return render(json: { error: error }, status: :unprocessable_entity)
19
- end
20
-
21
- limited_sql = apply_row_limit(sql, row_limit)
22
- timed_sql = apply_timeout_hint(limited_sql)
15
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
16
+ runner_config = MysqlGenius::Core::QueryRunner::Config.new(
17
+ blocked_tables: mysql_genius_config.blocked_tables,
18
+ masked_column_patterns: mysql_genius_config.masked_column_patterns,
19
+ query_timeout_ms: mysql_genius_config.query_timeout_ms,
20
+ )
21
+ runner = MysqlGenius::Core::QueryRunner.new(connection, runner_config)
23
22
 
24
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
23
  begin
26
- results = ActiveRecord::Base.connection.exec_query(timed_sql)
27
- execution_time_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
28
-
29
- columns = results.columns
30
- rows = results.rows.map do |row|
31
- row.each_with_index.map do |value, i|
32
- masked_column?(columns[i]) ? "[REDACTED]" : value
33
- end
34
- end
35
-
36
- truncated = rows.length >= row_limit
37
-
38
- audit(:query, sql: sql, execution_time_ms: execution_time_ms, row_count: rows.length)
39
-
40
- render(json: {
41
- columns: columns,
42
- rows: rows,
43
- row_count: rows.length,
44
- execution_time_ms: execution_time_ms,
45
- truncated: truncated,
46
- })
24
+ result = runner.run(sql, row_limit: row_limit)
25
+ rescue MysqlGenius::Core::QueryRunner::Rejected => e
26
+ audit(:rejection, sql: sql, reason: e.message)
27
+ return render(json: { error: e.message }, status: :unprocessable_entity)
28
+ rescue MysqlGenius::Core::QueryRunner::Timeout
29
+ audit(:error, sql: sql, error: "Query timeout")
30
+ return render(json: { error: "Query exceeded the #{mysql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity)
47
31
  rescue ActiveRecord::StatementInvalid => e
48
- if timeout_error?(e)
49
- audit(:error, sql: sql, error: "Query timeout")
50
- render(json: { error: "Query exceeded the #{mysql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity)
51
- else
52
- audit(:error, sql: sql, error: e.message)
53
- render(json: { error: "Query error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
54
- end
32
+ audit(:error, sql: sql, error: e.message)
33
+ return render(json: { error: "Query error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
55
34
  end
35
+
36
+ audit(:query, sql: sql, execution_time_ms: result.execution_time_ms, row_count: result.row_count)
37
+
38
+ render(json: {
39
+ columns: result.columns,
40
+ rows: result.rows,
41
+ row_count: result.row_count,
42
+ execution_time_ms: result.execution_time_ms,
43
+ truncated: result.truncated,
44
+ })
56
45
  end
57
46
 
58
47
  def explain
59
48
  sql = params[:sql].to_s.strip
60
49
  skip_validation = params[:from_slow_query] == "true"
61
50
 
62
- unless skip_validation
63
- error = validate_sql(sql)
64
- return render(json: { error: error }, status: :unprocessable_entity) if error
65
- end
66
-
67
- # Reject truncated SQL (captured slow queries are capped at 2000 chars)
68
- unless sql.match?(/\)\s*$/) || sql.match?(/\w\s*$/) || sql.match?(/['"`]\s*$/) || sql.match?(/\d\s*$/)
69
- return render(json: { error: "This query appears to be truncated and cannot be explained." }, status: :unprocessable_entity)
70
- end
71
-
72
- explain_sql = "EXPLAIN #{sql.gsub(/;\s*\z/, "")}"
73
- results = ActiveRecord::Base.connection.exec_query(explain_sql)
74
-
75
- render(json: { columns: results.columns, rows: results.rows })
51
+ connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
52
+ runner_config = MysqlGenius::Core::QueryRunner::Config.new(
53
+ blocked_tables: mysql_genius_config.blocked_tables,
54
+ masked_column_patterns: mysql_genius_config.masked_column_patterns,
55
+ query_timeout_ms: mysql_genius_config.query_timeout_ms,
56
+ )
57
+ explainer = MysqlGenius::Core::QueryExplainer.new(connection, runner_config)
58
+
59
+ result = explainer.explain(sql, skip_validation: skip_validation)
60
+ render(json: { columns: result.columns, rows: result.rows })
61
+ rescue MysqlGenius::Core::QueryRunner::Rejected,
62
+ MysqlGenius::Core::QueryExplainer::Truncated => e
63
+ render(json: { error: e.message }, status: :unprocessable_entity)
76
64
  rescue ActiveRecord::StatementInvalid => e
77
65
  render(json: { error: "Explain error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
78
66
  end
79
67
 
80
68
  private
81
69
 
82
- def validate_sql(sql)
83
- SqlValidator.validate(sql, blocked_tables: mysql_genius_config.blocked_tables, connection: ActiveRecord::Base.connection)
84
- end
85
-
86
- def apply_timeout_hint(sql)
87
- if mariadb?
88
- timeout_seconds = mysql_genius_config.query_timeout_ms / 1000
89
- "SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
90
- else
91
- sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{mysql_genius_config.query_timeout_ms}) */")
92
- end
93
- end
94
-
95
- def mariadb?
96
- @mariadb ||= ActiveRecord::Base.connection.select_value("SELECT VERSION()").to_s.include?("MariaDB")
97
- end
98
-
99
- def apply_row_limit(sql, limit)
100
- SqlValidator.apply_row_limit(sql, limit)
101
- end
102
-
103
- def timeout_error?(exception)
104
- msg = exception.message
105
- msg.include?("max_statement_time") || msg.include?("max_execution_time") || msg.include?("Query execution was interrupted")
106
- end
107
-
108
- def masked_column?(column_name)
109
- SqlValidator.masked_column?(column_name, mysql_genius_config.masked_column_patterns)
110
- end
111
-
112
70
  def sanitize_ai_sql(sql)
113
71
  sql.gsub(/```(?:sql)?\s*/i, "").gsub("```", "").strip
114
72
  end
@@ -2,16 +2,6 @@
2
2
  <div class="mg-tab-content" id="tab-qstats">
3
3
  <div class="mg-row" style="justify-content:space-between;align-items:center;margin-bottom:12px;">
4
4
  <div class="mg-text-muted">Top queries from <code>performance_schema.events_statements_summary_by_digest</code>. <span id="qstats-count" class="mg-badge mg-badge-secondary"></span></div>
5
- <!--div>
6
- <label style="display:inline;font-size:12px;margin-right:4px;">Sort by:</label>
7
- <select id="qstats-sort" style="width:auto;display:inline-block;padding:3px 6px;font-size:12px;">
8
- <option value="total_time">Total Time</option>
9
- <option value="avg_time">Avg Time</option>
10
- <option value="calls">Calls</option>
11
- <option value="rows_examined">Rows Examined</option>
12
- </select>
13
- <button id="qstats-refresh" class="mg-btn mg-btn-outline-secondary mg-btn-sm" style="margin-left:4px;">&#8635; Refresh</button>
14
- </div-->
15
5
  </div>
16
6
  <div id="qstats-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Loading...</div>
17
7
  <div id="qstats-error" class="mg-hidden"></div>
@@ -1037,8 +1037,7 @@
1037
1037
  hide(el('qstats-empty')); hide(el('qstats-table-wrapper')); hide(el('qstats-error'));
1038
1038
  el('qstats-tbody').innerHTML = '';
1039
1039
 
1040
- var sort = el('qstats-sort').value;
1041
- ajaxGet(ROUTES.query_stats, { sort: sort }, function(data) {
1040
+ ajaxGet(ROUTES.query_stats, { sort: 'total_time' }, function(data) {
1042
1041
  hide(el('qstats-loading'));
1043
1042
  if (data.error) {
1044
1043
  el('qstats-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml(data.error) + '</div>';
@@ -1074,9 +1073,6 @@
1074
1073
  return ms.toFixed(1) + ' ms';
1075
1074
  }
1076
1075
 
1077
- el('qstats-refresh').addEventListener('click', loadQueryStats);
1078
- el('qstats-sort').addEventListener('change', loadQueryStats);
1079
-
1080
1076
  // --- Unused Indexes ---
1081
1077
 
1082
1078
  function loadUnusedIndexes() {
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mysql_genius/core"
4
+
5
+ module MysqlGenius
6
+ module Core
7
+ module Connection
8
+ # Wraps an ActiveRecord::Base.connection and implements the
9
+ # Core::Connection contract. Lives in the mysql_genius (Rails
10
+ # adapter) gem because it depends on ActiveRecord; the contract
11
+ # itself lives in mysql_genius-core.
12
+ class ActiveRecordAdapter
13
+ def initialize(ar_connection)
14
+ @ar = ar_connection
15
+ end
16
+
17
+ def exec_query(sql, binds: [])
18
+ _ = binds
19
+ ar_result = @ar.exec_query(sql)
20
+ Core::Result.new(columns: ar_result.columns, rows: ar_result.rows)
21
+ end
22
+
23
+ def select_value(sql)
24
+ @ar.select_value(sql)
25
+ end
26
+
27
+ def server_version
28
+ Core::ServerInfo.parse(@ar.select_value("SELECT VERSION()").to_s)
29
+ end
30
+
31
+ def current_database
32
+ @ar.current_database
33
+ end
34
+
35
+ def quote(value)
36
+ @ar.quote(value)
37
+ end
38
+
39
+ def quote_table_name(name)
40
+ @ar.quote_table_name(name)
41
+ end
42
+
43
+ def tables
44
+ @ar.tables
45
+ end
46
+
47
+ def columns_for(table)
48
+ pk = @ar.primary_key(table)
49
+ @ar.columns(table).map do |c|
50
+ Core::ColumnDefinition.new(
51
+ name: c.name,
52
+ type: c.type,
53
+ sql_type: c.sql_type,
54
+ null: c.null,
55
+ default: c.default,
56
+ primary_key: c.name == pk,
57
+ )
58
+ end
59
+ end
60
+
61
+ def indexes_for(table)
62
+ @ar.indexes(table).map do |idx|
63
+ Core::IndexDefinition.new(name: idx.name, columns: idx.columns, unique: idx.unique)
64
+ end
65
+ end
66
+
67
+ def primary_key(table)
68
+ @ar.primary_key(table)
69
+ end
70
+
71
+ def close
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MysqlGenius
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/mysql_genius.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mysql_genius/version"
4
+ require "mysql_genius/core"
5
+ require "mysql_genius/core/connection/active_record_adapter"
4
6
  require "mysql_genius/configuration"
5
- require "mysql_genius/sql_validator"
6
7
 
7
8
  module MysqlGenius
8
9
  class Error < StandardError; end
data/mysql_genius.gemspec CHANGED
@@ -24,12 +24,13 @@ Gem::Specification.new do |spec|
24
24
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
25
25
 
26
26
  spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
27
- %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
+ %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features|gems)/}) }
28
28
  end
29
29
  spec.bindir = "exe"
30
30
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
31
  spec.require_paths = ["lib"]
32
32
 
33
33
  spec.add_dependency("activerecord", ">= 5.2", "< 9")
34
+ spec.add_dependency("mysql_genius-core", "~> 0.4.0")
34
35
  spec.add_dependency("railties", ">= 5.2", "< 9")
35
36
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mysql_genius
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antarr Byrd
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-10 00:00:00.000000000 Z
11
+ date: 2026-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -30,6 +30,20 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: mysql_genius-core
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.4.0
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.4.0
33
47
  - !ruby/object:Gem::Dependency
34
48
  name: railties
35
49
  requirement: !ruby/object:Gem::Requirement
@@ -76,9 +90,6 @@ files:
76
90
  - app/controllers/concerns/mysql_genius/query_execution.rb
77
91
  - app/controllers/mysql_genius/base_controller.rb
78
92
  - app/controllers/mysql_genius/queries_controller.rb
79
- - app/services/mysql_genius/ai_client.rb
80
- - app/services/mysql_genius/ai_optimization_service.rb
81
- - app/services/mysql_genius/ai_suggestion_service.rb
82
93
  - app/views/layouts/mysql_genius/application.html.erb
83
94
  - app/views/mysql_genius/queries/_shared_results.html.erb
84
95
  - app/views/mysql_genius/queries/_tab_ai_tools.html.erb
@@ -101,15 +112,13 @@ files:
101
112
  - docs/screenshots/query_stats.png
102
113
  - docs/screenshots/server.png
103
114
  - docs/screenshots/table_sizes.png
104
- - docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md
105
- - docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md
106
115
  - lib/generators/mysql_genius/install/install_generator.rb
107
116
  - lib/generators/mysql_genius/install/templates/initializer.rb
108
117
  - lib/mysql_genius.rb
109
118
  - lib/mysql_genius/configuration.rb
119
+ - lib/mysql_genius/core/connection/active_record_adapter.rb
110
120
  - lib/mysql_genius/engine.rb
111
121
  - lib/mysql_genius/slow_query_monitor.rb
112
- - lib/mysql_genius/sql_validator.rb
113
122
  - lib/mysql_genius/version.rb
114
123
  - mysql_genius.gemspec
115
124
  homepage: https://github.com/antarr/mysql_genius
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "json"
5
- require "uri"
6
-
7
- module MysqlGenius
8
- class AiClient
9
- MAX_REDIRECTS = 3
10
-
11
- def initialize
12
- @config = MysqlGenius.configuration
13
- end
14
-
15
- def chat(messages:, temperature: 0)
16
- if @config.ai_client
17
- return @config.ai_client.call(messages: messages, temperature: temperature)
18
- end
19
-
20
- raise Error, "AI is not configured" unless @config.ai_endpoint && @config.ai_api_key
21
-
22
- body = {
23
- messages: messages,
24
- response_format: { type: "json_object" },
25
- temperature: temperature,
26
- }
27
- body[:model] = @config.ai_model if @config.ai_model && !@config.ai_model.empty?
28
-
29
- response = post_with_redirects(URI(@config.ai_endpoint), body.to_json)
30
- parsed = JSON.parse(response.body)
31
-
32
- if parsed["error"]
33
- raise Error, "AI API error: #{parsed["error"]["message"] || parsed["error"]}"
34
- end
35
-
36
- content = parsed.dig("choices", 0, "message", "content")
37
- raise Error, "No content in AI response" if content.nil?
38
-
39
- parse_json_content(content)
40
- end
41
-
42
- private
43
-
44
- def parse_json_content(content)
45
- # Try direct parse first
46
- JSON.parse(content)
47
- rescue JSON::ParserError
48
- # Strip markdown code fences that some models wrap around JSON
49
- stripped = content.to_s
50
- .gsub(/\A\s*```(?:json)?\s*/i, "")
51
- .gsub(/\s*```\s*\z/, "")
52
- .strip
53
- begin
54
- JSON.parse(stripped)
55
- rescue JSON::ParserError
56
- { "raw" => content.to_s }
57
- end
58
- end
59
-
60
- def post_with_redirects(uri, body, redirects = 0)
61
- raise Error, "Too many redirects" if redirects > MAX_REDIRECTS
62
-
63
- http = Net::HTTP.new(uri.host, uri.port)
64
- http.use_ssl = uri.scheme == "https"
65
- if http.use_ssl?
66
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
67
- cert_file = ENV["SSL_CERT_FILE"] || OpenSSL::X509::DEFAULT_CERT_FILE
68
- http.ca_file = cert_file if File.exist?(cert_file)
69
- end
70
- http.open_timeout = 10
71
- http.read_timeout = 60
72
-
73
- request = Net::HTTP::Post.new(uri)
74
- request["Content-Type"] = "application/json"
75
- if @config.ai_auth_style == :bearer
76
- request["Authorization"] = "Bearer #{@config.ai_api_key}"
77
- else
78
- request["api-key"] = @config.ai_api_key
79
- end
80
- request.body = body
81
-
82
- response = http.request(request)
83
-
84
- if response.is_a?(Net::HTTPRedirection)
85
- post_with_redirects(URI(response["location"]), body, redirects + 1)
86
- else
87
- response
88
- end
89
- end
90
- end
91
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module MysqlGenius
4
- class AiOptimizationService
5
- def call(sql, explain_rows, allowed_tables)
6
- schema = build_schema_description(allowed_tables)
7
- messages = [
8
- { role: "system", content: system_prompt(schema) },
9
- { role: "user", content: user_prompt(sql, explain_rows) },
10
- ]
11
-
12
- AiClient.new.chat(messages: messages)
13
- end
14
-
15
- private
16
-
17
- def system_prompt(schema_description)
18
- <<~PROMPT
19
- You are a MySQL query optimization expert. Given a SQL query and its EXPLAIN output, analyze the query execution plan and provide actionable optimization suggestions.
20
-
21
- Available schema:
22
- #{schema_description}
23
-
24
- Respond with JSON:
25
- {
26
- "suggestions": "Markdown-formatted analysis and suggestions. Include: 1) Summary of current execution plan (scan types, rows examined). 2) Specific recommendations such as indexes to add (provide exact CREATE INDEX statements), query rewrites, or structural changes. 3) Expected impact of each suggestion."
27
- }
28
- PROMPT
29
- end
30
-
31
- def user_prompt(sql, explain_rows)
32
- <<~PROMPT
33
- SQL Query:
34
- #{sql}
35
-
36
- EXPLAIN Output:
37
- #{format_explain(explain_rows)}
38
- PROMPT
39
- end
40
-
41
- def format_explain(explain_rows)
42
- return explain_rows if explain_rows.is_a?(String)
43
-
44
- explain_rows.map { |row| row.join(" | ") }.join("\n")
45
- end
46
-
47
- def build_schema_description(allowed_tables)
48
- connection = ActiveRecord::Base.connection
49
- allowed_tables.map do |table|
50
- next unless connection.tables.include?(table)
51
-
52
- columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
53
- indexes = connection.indexes(table).map { |idx| "#{idx.name}: [#{idx.columns.join(", ")}]#{" UNIQUE" if idx.unique}" }
54
- desc = "#{table}: #{columns.join(", ")}"
55
- desc += "\n Indexes: #{indexes.join("; ")}" if indexes.any?
56
- desc
57
- end.compact.join("\n")
58
- end
59
- end
60
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module MysqlGenius
4
- class AiSuggestionService
5
- def call(user_prompt, allowed_tables)
6
- schema = build_schema_description(allowed_tables)
7
- messages = [
8
- { role: "system", content: system_prompt(schema) },
9
- { role: "user", content: user_prompt },
10
- ]
11
-
12
- AiClient.new.chat(messages: messages)
13
- end
14
-
15
- private
16
-
17
- def system_prompt(schema_description)
18
- custom_context = MysqlGenius.configuration.ai_system_context
19
-
20
- prompt = <<~PROMPT
21
- You are a SQL query assistant for a MySQL database.
22
- PROMPT
23
-
24
- if custom_context && !custom_context.empty?
25
- prompt += <<~PROMPT
26
-
27
- Domain context:
28
- #{custom_context}
29
- PROMPT
30
- end
31
-
32
- prompt += <<~PROMPT
33
-
34
- Rules:
35
- - Only generate SELECT statements. Never generate INSERT, UPDATE, DELETE, or any other mutation.
36
- - Only reference the tables and columns listed in the schema below. Do not guess or invent column names.
37
- - Use backticks for table and column names.
38
- - Include a LIMIT 100 unless the user specifies otherwise.
39
-
40
- Available schema:
41
- #{schema_description}
42
-
43
- Respond with JSON: {"sql": "the SQL query", "explanation": "brief explanation of what the query does"}
44
- PROMPT
45
-
46
- prompt
47
- end
48
-
49
- def build_schema_description(allowed_tables)
50
- connection = ActiveRecord::Base.connection
51
- allowed_tables.map do |table|
52
- next unless connection.tables.include?(table)
53
-
54
- columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
55
- "#{table}: #{columns.join(", ")}"
56
- end.compact.join("\n")
57
- end
58
- end
59
- end