sql_genius 0.9.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 +7 -0
- data/CHANGELOG.md +195 -0
- data/LICENSE.txt +65 -0
- data/README.md +178 -0
- data/Rakefile +8 -0
- data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
- data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
- data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
- data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
- data/app/controllers/sql_genius/base_controller.rb +29 -0
- data/app/controllers/sql_genius/queries_controller.rb +94 -0
- data/app/views/layouts/sql_genius/application.html.erb +285 -0
- data/config/routes.rb +34 -0
- data/docs/guides/ai-features.md +115 -0
- data/docs/guides/getting-started-rails.md +118 -0
- data/docs/guides/ssh-tunnel-connections.md +151 -0
- data/docs/screenshots/ai_tools.png +0 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/duplicate_indexes.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/screenshots/query_stats.png +0 -0
- data/docs/screenshots/server.png +0 -0
- data/docs/screenshots/table_sizes.png +0 -0
- data/lib/generators/sql_genius/install/install_generator.rb +19 -0
- data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
- data/lib/sql_genius/configuration.rb +114 -0
- data/lib/sql_genius/core/ai/client.rb +155 -0
- data/lib/sql_genius/core/ai/config.rb +47 -0
- data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
- data/lib/sql_genius/core/ai/describe_query.rb +41 -0
- data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
- data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
- data/lib/sql_genius/core/ai/index_planner.rb +91 -0
- data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
- data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
- data/lib/sql_genius/core/ai/optimization.rb +81 -0
- data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
- data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
- data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
- data/lib/sql_genius/core/ai/schema_review.rb +46 -0
- data/lib/sql_genius/core/ai/suggestion.rb +74 -0
- data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
- data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
- data/lib/sql_genius/core/analysis/columns.rb +63 -0
- data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
- data/lib/sql_genius/core/analysis/query_history.rb +50 -0
- data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
- data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
- data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
- data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
- data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
- data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
- data/lib/sql_genius/core/column_definition.rb +30 -0
- data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
- data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
- data/lib/sql_genius/core/connection.rb +37 -0
- data/lib/sql_genius/core/execution_result.rb +27 -0
- data/lib/sql_genius/core/index_definition.rb +23 -0
- data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
- data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
- data/lib/sql_genius/core/query_builders.rb +27 -0
- data/lib/sql_genius/core/query_explainer.rb +113 -0
- data/lib/sql_genius/core/query_runner/config.rb +21 -0
- data/lib/sql_genius/core/query_runner.rb +123 -0
- data/lib/sql_genius/core/result.rb +43 -0
- data/lib/sql_genius/core/server_info.rb +54 -0
- data/lib/sql_genius/core/sql_validator.rb +149 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
- data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
- data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
- data/lib/sql_genius/core.rb +72 -0
- data/lib/sql_genius/engine.rb +31 -0
- data/lib/sql_genius/slow_query_monitor.rb +43 -0
- data/lib/sql_genius/version.rb +5 -0
- data/lib/sql_genius.rb +29 -0
- data/sql_genius.gemspec +47 -0
- metadata +171 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Connecting to MySQL Through an SSH Tunnel
|
|
2
|
+
|
|
3
|
+
If your MySQL server is behind a firewall, on a private network, or only accessible via a bastion/jump host, you can use an SSH tunnel to connect SqlGenius to it.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
An SSH tunnel forwards a local port on your machine to the MySQL port on the remote server. SqlGenius connects to `localhost:<local_port>` and the tunnel transparently routes traffic to the actual database server.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Your Machine (SqlGenius) → SSH Tunnel → Bastion Host → MySQL Server
|
|
11
|
+
localhost:3307 db.internal:3306
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Step 1: Open the SSH tunnel
|
|
15
|
+
|
|
16
|
+
In a terminal, run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
ssh -L 3307:db.internal:3306 user@bastion-host.example.com -N
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Flags explained:**
|
|
23
|
+
- `-L 3307:db.internal:3306` — forward local port `3307` to `db.internal:3306` through the tunnel
|
|
24
|
+
- `user@bastion-host.example.com` — your SSH login on the bastion/jump host
|
|
25
|
+
- `-N` — don't open a shell, just forward the port
|
|
26
|
+
|
|
27
|
+
**With an SSH key:**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
ssh -L 3307:db.internal:3306 user@bastion-host.example.com -N -i ~/.ssh/my_key
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Keep it running in the background:**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
ssh -L 3307:db.internal:3306 user@bastion-host.example.com -N -f
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `-f` flag sends SSH to the background after connecting.
|
|
40
|
+
|
|
41
|
+
## Step 2: Configure your Rails app
|
|
42
|
+
|
|
43
|
+
SqlGenius uses your app's `ActiveRecord::Base.connection`, which reads from `database.yml`. Point it at the tunnel:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
# config/database.yml
|
|
47
|
+
production:
|
|
48
|
+
adapter: mysql2
|
|
49
|
+
host: 127.0.0.1
|
|
50
|
+
port: 3307
|
|
51
|
+
username: readonly
|
|
52
|
+
password: <%= ENV["DB_PASSWORD"] %>
|
|
53
|
+
database: app_production
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
No special SqlGenius configuration needed — it automatically uses the same connection as your Rails app.
|
|
57
|
+
|
|
58
|
+
## Step 3: Verify the connection
|
|
59
|
+
|
|
60
|
+
Start your Rails server and visit `/sql_genius`. If the tunnel is running, the dashboard loads normally. If not, you'll see a connection error.
|
|
61
|
+
|
|
62
|
+
## Common issues
|
|
63
|
+
|
|
64
|
+
### "Connection refused"
|
|
65
|
+
|
|
66
|
+
The SSH tunnel is not running. Start it first:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
ssh -L 3307:db.internal:3306 user@bastion -N
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### "Access denied"
|
|
73
|
+
|
|
74
|
+
The tunnel is working but the MySQL credentials are wrong. Verify your username/password can connect to the database directly from the bastion host.
|
|
75
|
+
|
|
76
|
+
### "Lost connection to MySQL server during query"
|
|
77
|
+
|
|
78
|
+
The SSH tunnel dropped. This happens if the tunnel is idle for too long. Add keep-alive settings:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ssh -L 3307:db.internal:3306 user@bastion -N -o ServerAliveInterval=60 -o ServerAliveCountMax=3
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Port already in use
|
|
85
|
+
|
|
86
|
+
Another process is using port 3307. Pick a different local port:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
ssh -L 3308:db.internal:3306 user@bastion -N
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then update your SqlGenius config to use port `3308`.
|
|
93
|
+
|
|
94
|
+
## Multiple databases through one bastion
|
|
95
|
+
|
|
96
|
+
You can tunnel to multiple MySQL servers through the same bastion:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Production on local port 3307
|
|
100
|
+
ssh -L 3307:db-prod.internal:3306 user@bastion -N -f
|
|
101
|
+
|
|
102
|
+
# Staging on local port 3308
|
|
103
|
+
ssh -L 3308:db-staging.internal:3306 user@bastion -N -f
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then create separate SqlGenius profiles for each:
|
|
107
|
+
- **Production**: `127.0.0.1:3307`
|
|
108
|
+
- **Staging**: `127.0.0.1:3308`
|
|
109
|
+
|
|
110
|
+
## Automating the tunnel
|
|
111
|
+
|
|
112
|
+
### macOS: Launch Agent
|
|
113
|
+
|
|
114
|
+
Create `~/Library/LaunchAgents/com.sqlgenius.tunnel.plist`:
|
|
115
|
+
|
|
116
|
+
```xml
|
|
117
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
118
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
119
|
+
<plist version="1.0">
|
|
120
|
+
<dict>
|
|
121
|
+
<key>Label</key>
|
|
122
|
+
<string>com.sqlgenius.tunnel</string>
|
|
123
|
+
<key>ProgramArguments</key>
|
|
124
|
+
<array>
|
|
125
|
+
<string>ssh</string>
|
|
126
|
+
<string>-L</string>
|
|
127
|
+
<string>3307:db.internal:3306</string>
|
|
128
|
+
<string>user@bastion</string>
|
|
129
|
+
<string>-N</string>
|
|
130
|
+
<string>-o</string>
|
|
131
|
+
<string>ServerAliveInterval=60</string>
|
|
132
|
+
</array>
|
|
133
|
+
<key>KeepAlive</key>
|
|
134
|
+
<true/>
|
|
135
|
+
<key>RunAtLoad</key>
|
|
136
|
+
<true/>
|
|
137
|
+
</dict>
|
|
138
|
+
</plist>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Load it:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
launchctl load ~/Library/LaunchAgents/com.sqlgenius.tunnel.plist
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The tunnel will start automatically on login and restart if it drops.
|
|
148
|
+
|
|
149
|
+
## Future: Built-in SSH tunnel support
|
|
150
|
+
|
|
151
|
+
Built-in SSH tunnel support is planned for a future release. When available, you'll be able to configure the SSH connection directly in the SqlGenius profile form without needing a separate terminal.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates a SqlGenius initializer and mounts the engine in routes."
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template("initializer.rb", "config/initializers/sql_genius.rb")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mount_engine
|
|
15
|
+
route('mount SqlGenius::Engine, at: "/sql_genius"')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SqlGenius.configure do |config|
|
|
4
|
+
# --- Authentication ---
|
|
5
|
+
# Lambda that receives the controller instance. Return true to allow access.
|
|
6
|
+
# Default: allows everyone. Use route constraints for most cases.
|
|
7
|
+
# config.authenticate = ->(controller) { controller.current_user&.admin? }
|
|
8
|
+
|
|
9
|
+
# To use current_user or other app helpers, inherit from ApplicationController:
|
|
10
|
+
# config.base_controller = "ApplicationController"
|
|
11
|
+
|
|
12
|
+
# --- Tables ---
|
|
13
|
+
# Tables featured at the top of the visual builder dropdown (optional).
|
|
14
|
+
# config.featured_tables = %w[users posts comments]
|
|
15
|
+
|
|
16
|
+
# Tables blocked from querying (defaults: sessions, schema_migrations, ar_internal_metadata).
|
|
17
|
+
# config.blocked_tables += %w[oauth_tokens api_keys]
|
|
18
|
+
|
|
19
|
+
# Column patterns to redact with [REDACTED] in results (case-insensitive substring match).
|
|
20
|
+
# config.masked_column_patterns = %w[password secret digest token ssn]
|
|
21
|
+
|
|
22
|
+
# Default columns checked in the visual builder per table (optional).
|
|
23
|
+
# config.default_columns = {
|
|
24
|
+
# "users" => %w[id name email created_at],
|
|
25
|
+
# "posts" => %w[id title user_id published_at]
|
|
26
|
+
# }
|
|
27
|
+
|
|
28
|
+
# --- Query Safety ---
|
|
29
|
+
# config.max_row_limit = 1000 # Hard cap on rows returned
|
|
30
|
+
# config.default_row_limit = 25 # Default when no limit specified
|
|
31
|
+
# config.query_timeout_ms = 30_000 # 30 second timeout
|
|
32
|
+
|
|
33
|
+
# --- Slow Query Monitoring ---
|
|
34
|
+
# Requires Redis. Set to nil to disable.
|
|
35
|
+
# config.redis_url = ENV["REDIS_URL"]
|
|
36
|
+
# config.slow_query_threshold_ms = 250
|
|
37
|
+
|
|
38
|
+
# --- Audit Logging ---
|
|
39
|
+
# Set to nil to disable. Logs query executions, rejections, and errors.
|
|
40
|
+
# config.audit_logger = Logger.new(Rails.root.join("log", "sql_genius.log"))
|
|
41
|
+
|
|
42
|
+
# --- AI Features (optional) ---
|
|
43
|
+
# Supports any OpenAI-compatible API: OpenAI, Azure OpenAI, Ollama, or a custom client.
|
|
44
|
+
# config.ai_endpoint = "https://api.openai.com/v1/chat/completions"
|
|
45
|
+
# config.ai_api_key = ENV["OPENAI_API_KEY"]
|
|
46
|
+
# config.ai_model = "gpt-4o"
|
|
47
|
+
# config.ai_auth_style = :bearer # :bearer for OpenAI/Ollama, :api_key for Azure
|
|
48
|
+
|
|
49
|
+
# Domain context helps the AI understand your schema and generate better queries.
|
|
50
|
+
# config.ai_system_context = <<~CONTEXT
|
|
51
|
+
# This is an e-commerce database.
|
|
52
|
+
# - `users` stores customer accounts.
|
|
53
|
+
# - `orders` tracks purchases, linked to users via `user_id`.
|
|
54
|
+
# - Soft-deleted records have `deleted_at IS NOT NULL`.
|
|
55
|
+
# CONTEXT
|
|
56
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
class Configuration
|
|
5
|
+
# Tables to feature in the visual builder dropdown (array of strings).
|
|
6
|
+
# When empty, all non-blocked tables are shown.
|
|
7
|
+
attr_accessor :featured_tables
|
|
8
|
+
|
|
9
|
+
# Tables that must never be queried (auth, sessions, internal Rails tables).
|
|
10
|
+
attr_accessor :blocked_tables
|
|
11
|
+
|
|
12
|
+
# Column name patterns to mask with [REDACTED] in query results.
|
|
13
|
+
# Matched case-insensitively via String#include?.
|
|
14
|
+
attr_accessor :masked_column_patterns
|
|
15
|
+
|
|
16
|
+
# Default columns to check in the visual builder, keyed by table name.
|
|
17
|
+
# Example: { "users" => %w[id name email created_at] }
|
|
18
|
+
attr_accessor :default_columns
|
|
19
|
+
|
|
20
|
+
# Maximum rows a single query can return.
|
|
21
|
+
attr_accessor :max_row_limit
|
|
22
|
+
|
|
23
|
+
# Default row limit when none is specified.
|
|
24
|
+
attr_accessor :default_row_limit
|
|
25
|
+
|
|
26
|
+
# Query timeout in milliseconds.
|
|
27
|
+
attr_accessor :query_timeout_ms
|
|
28
|
+
|
|
29
|
+
# Proc that receives the controller instance and returns true if the user
|
|
30
|
+
# is authorized. Example:
|
|
31
|
+
# config.authenticate = ->(controller) { controller.current_user&.admin? }
|
|
32
|
+
attr_accessor :authenticate
|
|
33
|
+
|
|
34
|
+
# AI configuration — set to nil to disable AI features entirely.
|
|
35
|
+
# Must respond to :call(messages:, response_format:, temperature:)
|
|
36
|
+
# and return a Hash with "choices" in OpenAI-compatible format,
|
|
37
|
+
# OR set ai_endpoint + ai_api_key for a direct OpenAI-compatible HTTP API.
|
|
38
|
+
attr_accessor :ai_client
|
|
39
|
+
attr_accessor :ai_endpoint
|
|
40
|
+
attr_accessor :ai_api_key
|
|
41
|
+
|
|
42
|
+
# AI model name to pass in the request body (e.g. "gpt-4o", "gpt-3.5-turbo").
|
|
43
|
+
# Optional — if nil, the API default or deployment model is used.
|
|
44
|
+
attr_accessor :ai_model
|
|
45
|
+
|
|
46
|
+
# AI auth style: :bearer (OpenAI, Ollama Cloud) or :api_key (Azure OpenAI).
|
|
47
|
+
# Defaults to :api_key for backwards compatibility.
|
|
48
|
+
attr_accessor :ai_auth_style
|
|
49
|
+
|
|
50
|
+
# Custom system prompt prepended to AI suggestions. Use this to describe
|
|
51
|
+
# your domain, table relationships, and naming conventions.
|
|
52
|
+
attr_accessor :ai_system_context
|
|
53
|
+
|
|
54
|
+
# Slow query threshold in milliseconds. Queries slower than this are logged.
|
|
55
|
+
attr_accessor :slow_query_threshold_ms
|
|
56
|
+
|
|
57
|
+
# Redis URL for slow query storage. Set to nil to disable slow query monitoring.
|
|
58
|
+
attr_accessor :redis_url
|
|
59
|
+
|
|
60
|
+
# Logger instance for audit logging. Defaults to a file logger.
|
|
61
|
+
# Set to nil to disable audit logging.
|
|
62
|
+
attr_accessor :audit_logger
|
|
63
|
+
|
|
64
|
+
# Base controller class for the engine to inherit from.
|
|
65
|
+
# Set to "ApplicationController" to get current_user and other app helpers.
|
|
66
|
+
# Defaults to "ActionController::Base".
|
|
67
|
+
attr_accessor :base_controller
|
|
68
|
+
|
|
69
|
+
# Whether to start the background stats collector on boot.
|
|
70
|
+
# When enabled, performance_schema is sampled periodically and stored
|
|
71
|
+
# in an in-memory ring buffer accessible via SqlGenius.stats_history.
|
|
72
|
+
# Defaults to true.
|
|
73
|
+
attr_accessor :stats_collection
|
|
74
|
+
|
|
75
|
+
# Maximum scan count for an index to still be considered "unused" by the
|
|
76
|
+
# Unused Indexes dashboard. The default (0) means only indexes that have
|
|
77
|
+
# never been scanned since the stats source was last reset are flagged.
|
|
78
|
+
# Raise this to ignore indexes that are technically used but rarely
|
|
79
|
+
# enough to be worth dropping (e.g. min_unused_index_scans = 50 to require
|
|
80
|
+
# at least 50 scans before considering an index "useful").
|
|
81
|
+
attr_accessor :min_unused_index_scans
|
|
82
|
+
|
|
83
|
+
def initialize
|
|
84
|
+
@featured_tables = []
|
|
85
|
+
@blocked_tables = [
|
|
86
|
+
"sessions",
|
|
87
|
+
"ar_internal_metadata",
|
|
88
|
+
"schema_migrations",
|
|
89
|
+
]
|
|
90
|
+
@masked_column_patterns = ["password", "secret", "digest", "token"]
|
|
91
|
+
@default_columns = {}
|
|
92
|
+
@max_row_limit = 1000
|
|
93
|
+
@default_row_limit = 25
|
|
94
|
+
@query_timeout_ms = 30_000
|
|
95
|
+
@authenticate = ->(_controller) { true }
|
|
96
|
+
@ai_client = nil
|
|
97
|
+
@ai_endpoint = nil
|
|
98
|
+
@ai_api_key = nil
|
|
99
|
+
@ai_model = nil
|
|
100
|
+
@ai_auth_style = :api_key
|
|
101
|
+
@ai_system_context = nil
|
|
102
|
+
@slow_query_threshold_ms = 250
|
|
103
|
+
@redis_url = nil
|
|
104
|
+
@audit_logger = nil
|
|
105
|
+
@base_controller = "ActionController::Base"
|
|
106
|
+
@stats_collection = true
|
|
107
|
+
@min_unused_index_scans = 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def ai_enabled?
|
|
111
|
+
!ai_client.nil? || (!ai_endpoint.nil? && !ai_endpoint.empty? && !ai_api_key.nil? && !ai_api_key.empty?)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SqlGenius
|
|
8
|
+
module Core
|
|
9
|
+
module Ai
|
|
10
|
+
# HTTP client for OpenAI-compatible chat completion APIs.
|
|
11
|
+
# Construct with a Core::Ai::Config; call #chat with a messages array.
|
|
12
|
+
class Client
|
|
13
|
+
class NotConfigured < Core::Error; end
|
|
14
|
+
class ApiError < Core::Error; end
|
|
15
|
+
class TooManyRedirects < Core::Error; end
|
|
16
|
+
|
|
17
|
+
MAX_REDIRECTS = 3
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def chat(messages:, temperature: 0)
|
|
24
|
+
if @config.client
|
|
25
|
+
return @config.client.call(messages: messages, temperature: temperature)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise NotConfigured, "AI is not configured" unless @config.enabled?
|
|
29
|
+
|
|
30
|
+
body = if anthropic?
|
|
31
|
+
build_anthropic_body(messages, temperature)
|
|
32
|
+
else
|
|
33
|
+
build_openai_body(messages, temperature)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
response = post_with_redirects(URI(@config.endpoint), body.to_json)
|
|
37
|
+
parsed = JSON.parse(response.body)
|
|
38
|
+
|
|
39
|
+
if parsed["error"]
|
|
40
|
+
raise ApiError, "AI API error: #{parsed["error"]["message"] || parsed["error"]}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
content = if anthropic?
|
|
44
|
+
parsed.dig("content", 0, "text")
|
|
45
|
+
else
|
|
46
|
+
parsed.dig("choices", 0, "message", "content")
|
|
47
|
+
end
|
|
48
|
+
raise ApiError, "No content in AI response" if content.nil?
|
|
49
|
+
|
|
50
|
+
parse_json_content(content)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def anthropic?
|
|
56
|
+
@config.auth_style == :x_api_key
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_openai_body(messages, temperature)
|
|
60
|
+
body = {
|
|
61
|
+
messages: messages,
|
|
62
|
+
response_format: { type: "json_object" },
|
|
63
|
+
}
|
|
64
|
+
# GPT-5 and o-series ("reasoning") models renamed max_tokens to
|
|
65
|
+
# max_completion_tokens and only accept temperature=1 (the default).
|
|
66
|
+
# Older chat models (gpt-4o, gpt-4, gpt-3.5) keep the original names.
|
|
67
|
+
if openai_reasoning_model?
|
|
68
|
+
body[:max_completion_tokens] = @config.max_tokens.to_i if @config.max_tokens
|
|
69
|
+
else
|
|
70
|
+
body[:max_tokens] = @config.max_tokens.to_i if @config.max_tokens
|
|
71
|
+
body[:temperature] = temperature
|
|
72
|
+
end
|
|
73
|
+
body[:model] = @config.model if @config.model && !@config.model.empty?
|
|
74
|
+
body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns true when the configured model belongs to the OpenAI families
|
|
78
|
+
# that reject `max_tokens` and `temperature` overrides: gpt-5*, o1*,
|
|
79
|
+
# o3*, o4*. Matches the bare model name plus common deployment-name
|
|
80
|
+
# prefixes (Azure deployments are user-named but typically include the
|
|
81
|
+
# model identifier).
|
|
82
|
+
OPENAI_REASONING_MODEL_PATTERN = /\b(gpt-5|o1|o3|o4)(-|\b)/i.freeze
|
|
83
|
+
def openai_reasoning_model?
|
|
84
|
+
model = @config.model.to_s
|
|
85
|
+
return false if model.empty?
|
|
86
|
+
|
|
87
|
+
model.match?(OPENAI_REASONING_MODEL_PATTERN)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_anthropic_body(messages, temperature)
|
|
91
|
+
system_text = messages.select { |m| m[:role] == "system" }.map { |m| m[:content] }.join("\n\n")
|
|
92
|
+
user_messages = messages.reject { |m| m[:role] == "system" }
|
|
93
|
+
|
|
94
|
+
body = {
|
|
95
|
+
messages: user_messages,
|
|
96
|
+
max_tokens: (@config.max_tokens || 4096).to_i,
|
|
97
|
+
temperature: temperature,
|
|
98
|
+
}
|
|
99
|
+
body[:system] = system_text unless system_text.empty?
|
|
100
|
+
body[:model] = @config.model if @config.model && !@config.model.empty?
|
|
101
|
+
body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_json_content(content)
|
|
105
|
+
JSON.parse(content)
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
stripped = content.to_s
|
|
108
|
+
.gsub(/\A\s*```(?:json)?\s*/i, "")
|
|
109
|
+
.gsub(/\s*```\s*\z/, "")
|
|
110
|
+
.strip
|
|
111
|
+
begin
|
|
112
|
+
JSON.parse(stripped)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
{ "raw" => content.to_s }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def post_with_redirects(uri, body, redirects = 0)
|
|
119
|
+
raise TooManyRedirects, "Too many redirects" if redirects > MAX_REDIRECTS
|
|
120
|
+
|
|
121
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
if http.use_ssl?
|
|
124
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
125
|
+
cert_file = ENV["SSL_CERT_FILE"] || OpenSSL::X509::DEFAULT_CERT_FILE
|
|
126
|
+
http.ca_file = cert_file if File.exist?(cert_file)
|
|
127
|
+
end
|
|
128
|
+
http.open_timeout = 10
|
|
129
|
+
http.read_timeout = 60
|
|
130
|
+
|
|
131
|
+
request = Net::HTTP::Post.new(uri)
|
|
132
|
+
request["Content-Type"] = "application/json"
|
|
133
|
+
case @config.auth_style
|
|
134
|
+
when :bearer
|
|
135
|
+
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
136
|
+
when :x_api_key
|
|
137
|
+
request["x-api-key"] = @config.api_key
|
|
138
|
+
request["anthropic-version"] = "2023-06-01"
|
|
139
|
+
else
|
|
140
|
+
request["api-key"] = @config.api_key
|
|
141
|
+
end
|
|
142
|
+
request.body = body
|
|
143
|
+
|
|
144
|
+
response = http.request(request)
|
|
145
|
+
|
|
146
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
147
|
+
post_with_redirects(URI(response["location"]), body, redirects + 1)
|
|
148
|
+
else
|
|
149
|
+
response
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Keyword-init value object holding all the AI settings a Client
|
|
7
|
+
# needs. Passed explicitly to every AI service constructor — no
|
|
8
|
+
# module-level globals.
|
|
9
|
+
#
|
|
10
|
+
# Fields:
|
|
11
|
+
# client - optional callable; when set, bypasses HTTP.
|
|
12
|
+
# Signature: #call(messages:, temperature:) -> Hash
|
|
13
|
+
# endpoint - HTTPS URL of the chat completions endpoint
|
|
14
|
+
# api_key - API key (used as Bearer or api-key header)
|
|
15
|
+
# model - model name passed in the request body
|
|
16
|
+
# auth_style - :bearer or :api_key
|
|
17
|
+
# system_context - optional domain context string that services
|
|
18
|
+
# append to their system prompts
|
|
19
|
+
# domain_context - optional host-app context string interpolated into
|
|
20
|
+
# AI system prompts (e.g. "Rails app, no FKs")
|
|
21
|
+
Config = Struct.new(
|
|
22
|
+
:client,
|
|
23
|
+
:endpoint,
|
|
24
|
+
:api_key,
|
|
25
|
+
:model,
|
|
26
|
+
:auth_style,
|
|
27
|
+
:system_context,
|
|
28
|
+
:domain_context,
|
|
29
|
+
:max_tokens,
|
|
30
|
+
keyword_init: true,
|
|
31
|
+
) do
|
|
32
|
+
def initialize(**kwargs)
|
|
33
|
+
super(domain_context: "", max_tokens: 4096, **kwargs)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enabled?
|
|
38
|
+
return true if client
|
|
39
|
+
return false if endpoint.nil? || endpoint.to_s.empty?
|
|
40
|
+
return false if api_key.nil? || api_key.to_s.empty?
|
|
41
|
+
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Diagnoses connection pool health by gathering connection-related
|
|
7
|
+
# metrics from SHOW GLOBAL STATUS and SHOW GLOBAL VARIABLES, then
|
|
8
|
+
# asking the LLM to distinguish between pool misconfiguration,
|
|
9
|
+
# connection leaks, missing pooling, and traffic saturation.
|
|
10
|
+
class ConnectionAdvisor
|
|
11
|
+
STATUS_KEYS = [
|
|
12
|
+
"Threads_connected",
|
|
13
|
+
"Threads_running",
|
|
14
|
+
"Max_used_connections",
|
|
15
|
+
"Aborted_connects",
|
|
16
|
+
"Aborted_clients",
|
|
17
|
+
"Connections",
|
|
18
|
+
"Threads_created",
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
VARIABLE_KEYS = [
|
|
22
|
+
"max_connections",
|
|
23
|
+
"wait_timeout",
|
|
24
|
+
"interactive_timeout",
|
|
25
|
+
"thread_cache_size",
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(client, config, connection)
|
|
29
|
+
@client = client
|
|
30
|
+
@config = config
|
|
31
|
+
@connection = connection
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
if @connection.server_version.postgresql?
|
|
36
|
+
raise Core::UnsupportedDialect.for_postgresql("Connection Pressure Advisor")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
variables = fetch_variables
|
|
40
|
+
status = fetch_status
|
|
41
|
+
|
|
42
|
+
messages = [
|
|
43
|
+
{ role: "system", content: system_prompt },
|
|
44
|
+
{ role: "user", content: user_prompt(variables, status) },
|
|
45
|
+
]
|
|
46
|
+
@client.chat(messages: messages)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def fetch_variables
|
|
52
|
+
result = @connection.exec_query("SHOW GLOBAL VARIABLES")
|
|
53
|
+
result.rows
|
|
54
|
+
.select { |row| VARIABLE_KEYS.include?(row[0]) }
|
|
55
|
+
.map { |row| [row[0], row[1]] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_status
|
|
59
|
+
result = @connection.exec_query("SHOW GLOBAL STATUS")
|
|
60
|
+
result.rows
|
|
61
|
+
.select { |row| STATUS_KEYS.include?(row[0]) }
|
|
62
|
+
.map { |row| [row[0], row[1]] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def system_prompt
|
|
66
|
+
<<~PROMPT
|
|
67
|
+
You are a MySQL connection health advisor. Analyze the connection-related variables and status counters below, then diagnose the connection health and provide specific recommendations. Consider:
|
|
68
|
+
- Connection utilization: Max_used_connections vs max_connections (target: stay below 80%)
|
|
69
|
+
- Aborted connections: Aborted_connects indicates authentication failures or client errors; Aborted_clients indicates clients disconnecting without proper cleanup
|
|
70
|
+
- Thread cache efficiency: Threads_created vs Connections (high ratio means thread_cache_size is too small)
|
|
71
|
+
- Timeout configuration: wait_timeout and interactive_timeout impact how long idle connections persist
|
|
72
|
+
- Connection leak indicators: high Threads_connected with low Threads_running suggests idle connection accumulation
|
|
73
|
+
- Traffic saturation: high Threads_running relative to CPU cores suggests query contention
|
|
74
|
+
|
|
75
|
+
Distinguish between these root causes:
|
|
76
|
+
1. Pool misconfiguration (max_connections too low/high, bad timeout values)
|
|
77
|
+
2. Connection leaks (growing Threads_connected, high Aborted_clients)
|
|
78
|
+
3. Missing connection pooling (high Connections with short-lived threads)
|
|
79
|
+
4. Traffic saturation (high Threads_running, query contention)
|
|
80
|
+
#{@config.domain_context}
|
|
81
|
+
Respond with JSON: {"diagnosis": "markdown analysis distinguishing between pool misconfiguration, connection leaks, missing pooling, and traffic saturation, with specific variable recommendations and values"}
|
|
82
|
+
PROMPT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def user_prompt(variables, status)
|
|
86
|
+
lines = ["== Connection Variables =="]
|
|
87
|
+
variables.each { |name, value| lines << "#{name} = #{value}" }
|
|
88
|
+
lines << ""
|
|
89
|
+
lines << "== Connection Status Counters =="
|
|
90
|
+
status.each { |name, value| lines << "#{name} = #{value}" }
|
|
91
|
+
lines.join("\n")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|