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.
- checksums.yaml +4 -4
- data/.github/workflows/publish.yml +21 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +23 -0
- data/Gemfile +2 -0
- data/README.md +27 -0
- data/app/controllers/concerns/mysql_genius/ai_features.rb +31 -11
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +15 -263
- data/app/controllers/concerns/mysql_genius/query_execution.rb +39 -81
- data/app/views/mysql_genius/queries/_tab_query_stats.html.erb +0 -10
- data/app/views/mysql_genius/queries/index.html.erb +1 -5
- data/lib/mysql_genius/core/connection/active_record_adapter.rb +77 -0
- data/lib/mysql_genius/version.rb +1 -1
- data/lib/mysql_genius.rb +2 -1
- data/mysql_genius.gemspec +2 -1
- metadata +17 -8
- data/app/services/mysql_genius/ai_client.rb +0 -91
- data/app/services/mysql_genius/ai_optimization_service.rb +0 -60
- data/app/services/mysql_genius/ai_suggestion_service.rb +0 -59
- data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +0 -741
- data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +0 -87
- data/lib/mysql_genius/sql_validator.rb +0 -57
|
@@ -12,103 +12,61 @@ module MysqlGenius
|
|
|
12
12
|
mysql_genius_config.default_row_limit
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;">↻ 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
|
-
|
|
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
|
data/lib/mysql_genius/version.rb
CHANGED
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.
|
|
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-
|
|
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
|