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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +195 -0
  3. data/LICENSE.txt +65 -0
  4. data/README.md +178 -0
  5. data/Rakefile +8 -0
  6. data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
  7. data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
  8. data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
  9. data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
  10. data/app/controllers/sql_genius/base_controller.rb +29 -0
  11. data/app/controllers/sql_genius/queries_controller.rb +94 -0
  12. data/app/views/layouts/sql_genius/application.html.erb +285 -0
  13. data/config/routes.rb +34 -0
  14. data/docs/guides/ai-features.md +115 -0
  15. data/docs/guides/getting-started-rails.md +118 -0
  16. data/docs/guides/ssh-tunnel-connections.md +151 -0
  17. data/docs/screenshots/ai_tools.png +0 -0
  18. data/docs/screenshots/dashboard.png +0 -0
  19. data/docs/screenshots/duplicate_indexes.png +0 -0
  20. data/docs/screenshots/query_explore.png +0 -0
  21. data/docs/screenshots/query_stats.png +0 -0
  22. data/docs/screenshots/server.png +0 -0
  23. data/docs/screenshots/table_sizes.png +0 -0
  24. data/lib/generators/sql_genius/install/install_generator.rb +19 -0
  25. data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
  26. data/lib/sql_genius/configuration.rb +114 -0
  27. data/lib/sql_genius/core/ai/client.rb +155 -0
  28. data/lib/sql_genius/core/ai/config.rb +47 -0
  29. data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
  30. data/lib/sql_genius/core/ai/describe_query.rb +41 -0
  31. data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
  32. data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
  33. data/lib/sql_genius/core/ai/index_planner.rb +91 -0
  34. data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
  35. data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
  36. data/lib/sql_genius/core/ai/optimization.rb +81 -0
  37. data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
  38. data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
  39. data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
  40. data/lib/sql_genius/core/ai/schema_review.rb +46 -0
  41. data/lib/sql_genius/core/ai/suggestion.rb +74 -0
  42. data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
  43. data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
  44. data/lib/sql_genius/core/analysis/columns.rb +63 -0
  45. data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
  46. data/lib/sql_genius/core/analysis/query_history.rb +50 -0
  47. data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
  48. data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
  49. data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
  50. data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
  51. data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
  52. data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
  53. data/lib/sql_genius/core/column_definition.rb +30 -0
  54. data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
  55. data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
  56. data/lib/sql_genius/core/connection.rb +37 -0
  57. data/lib/sql_genius/core/execution_result.rb +27 -0
  58. data/lib/sql_genius/core/index_definition.rb +23 -0
  59. data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
  60. data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
  61. data/lib/sql_genius/core/query_builders.rb +27 -0
  62. data/lib/sql_genius/core/query_explainer.rb +113 -0
  63. data/lib/sql_genius/core/query_runner/config.rb +21 -0
  64. data/lib/sql_genius/core/query_runner.rb +123 -0
  65. data/lib/sql_genius/core/result.rb +43 -0
  66. data/lib/sql_genius/core/server_info.rb +54 -0
  67. data/lib/sql_genius/core/sql_validator.rb +149 -0
  68. data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
  69. data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
  70. data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
  71. data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
  72. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
  73. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
  74. data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
  75. data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
  76. data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
  77. data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
  78. data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
  79. data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
  80. data/lib/sql_genius/core.rb +72 -0
  81. data/lib/sql_genius/engine.rb +31 -0
  82. data/lib/sql_genius/slow_query_monitor.rb +43 -0
  83. data/lib/sql_genius/version.rb +5 -0
  84. data/lib/sql_genius.rb +29 -0
  85. data/sql_genius.gemspec +47 -0
  86. 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
@@ -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