blazer-ai 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5383dbf30daabea740fc3a6623310bd7d2c79bdc80e8a9ebb682f36b1660a52
4
+ data.tar.gz: cc47d89bc105c77af5ce838c09caa3cd9bded14e70710888284dfd1ba1fd1a20
5
+ SHA512:
6
+ metadata.gz: 1ebe828b2b7f50b44d1f63b9ec0595bcf972c0136ea79da9d0bd8072f696205cd43ab766ec487fbb369e96c544b9027c68b474fa4f70e06b193aad9a4718b891
7
+ data.tar.gz: 48a86302e19684549ef22cd19d9b60e9013c91128b489f073f48c508915c47f18471c8a7a6290c073babd0f10633d6ecc289cdb6e9603cdbd04e83189033a662
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-11-22
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - AI-powered SQL generation using RubyLLM
16
+ - Multi-provider support (OpenAI, Anthropic, Gemini, Bedrock, Ollama)
17
+ - Automatic database schema injection
18
+ - SQL validation to prevent dangerous operations
19
+ - Prompt sanitization to prevent injection attacks
20
+ - Rate limiting with configurable limits
21
+ - Support for multiple Blazer data sources
22
+ - Keyboard shortcut (Ctrl+Shift+G) for quick generation
23
+ - Loading states and error handling in the UI
24
+ - Comprehensive configuration options
25
+
26
+ ### Security
27
+
28
+ - SqlValidator blocks INSERT, UPDATE, DELETE, DROP, TRUNCATE, etc.
29
+ - Detects SQL injection patterns and multi-statement queries
30
+ - PromptSanitizer removes potential prompt injection attempts
31
+ - RateLimiter prevents API abuse
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Kieran Klaassen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # Blazer AI
2
+
3
+ AI-powered SQL generation for [Blazer](https://github.com/ankane/blazer)
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/blazer-ai.svg)](https://badge.fury.io/rb/blazer-ai)
6
+ [![CI](https://github.com/kieranklaassen/blazer-ai/actions/workflows/ci.yml/badge.svg)](https://github.com/kieranklaassen/blazer-ai/actions/workflows/ci.yml)
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem "blazer-ai"
14
+ ```
15
+
16
+ Run:
17
+
18
+ ```sh
19
+ bundle install
20
+ rails generate blazer_ai:install
21
+ ```
22
+
23
+ And set your API key:
24
+
25
+ ```sh
26
+ export OPENAI_API_KEY=your_key_here
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Visit `/blazer/queries/new` and click **Generate SQL (AI)**.
32
+
33
+ Keyboard shortcut: `Cmd+Shift+G` (Mac) or `Ctrl+Shift+G`
34
+
35
+ ## Configuration
36
+
37
+ ```ruby
38
+ Blazer::Ai.configure do |config|
39
+ config.default_model = "gpt-5.1-codex"
40
+ config.temperature = 0.2
41
+ config.rate_limit_per_minute = 20
42
+ end
43
+ ```
44
+
45
+ For other providers, update the initializer:
46
+
47
+ ```ruby
48
+ # Anthropic
49
+ RubyLLM.configure do |config|
50
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
51
+ end
52
+
53
+ Blazer::Ai.configure do |config|
54
+ config.default_model = "claude-sonnet-4-20250514"
55
+ end
56
+ ```
57
+
58
+ ```ruby
59
+ # Google
60
+ RubyLLM.configure do |config|
61
+ config.gemini_api_key = ENV["GEMINI_API_KEY"]
62
+ end
63
+
64
+ Blazer::Ai.configure do |config|
65
+ config.default_model = "gemini-2.0-flash"
66
+ end
67
+ ```
68
+
69
+ ## API
70
+
71
+ Invalidate schema cache:
72
+
73
+ ```ruby
74
+ Blazer::Ai::SchemaCache.invalidate(data_source_id: "main")
75
+ ```
76
+
77
+ Validate SQL:
78
+
79
+ ```ruby
80
+ Blazer::Ai::SqlValidator.new.safe?("SELECT * FROM users")
81
+ ```
82
+
83
+ ## Security
84
+
85
+ Only `SELECT` and `WITH` statements are allowed. Use a read-only database user.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ bundle install
91
+ bundle exec rake test
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,4 @@
1
+ # Base controller for Blazer AI.
2
+ # Inherits Blazer's authentication, CSRF protection, and helpers.
3
+ class Blazer::Ai::ApplicationController < ::Blazer::BaseController
4
+ end
@@ -0,0 +1,85 @@
1
+ # Handles AI-powered SQL generation requests.
2
+ # Validates rate limits, sanitizes input, and returns generated SQL.
3
+ class Blazer::Ai::QueriesController < Blazer::Ai::ApplicationController
4
+ before_action :ensure_ai_enabled, only: [ :create ]
5
+
6
+ def create
7
+ rate_limiter.check_and_track!(identifier: current_identifier)
8
+
9
+ data_source = find_data_source
10
+ generator = SqlGenerator.new(params: query_params, data_source: data_source)
11
+
12
+ sql = generator.call
13
+ log_generation(query_params, sql)
14
+
15
+ render json: { sql: sql }
16
+ rescue SqlValidator::ValidationError
17
+ render json: { error: "Generated SQL failed safety validation" }, status: :unprocessable_entity
18
+ rescue SqlGenerator::GenerationError => e
19
+ render json: { error: e.message }, status: :unprocessable_entity
20
+ rescue RateLimiter::RateLimitExceeded => e
21
+ render json: { error: e.message, retry_after: e.retry_after }, status: :too_many_requests
22
+ rescue StandardError => e
23
+ Rails.logger.error("[BlazerAI] Generation error: #{e.class}: #{e.message}")
24
+ Rails.logger.error(e.backtrace.first(10).join("\n")) if e.backtrace
25
+ render json: { error: "An error occurred while generating SQL. Please try again." }, status: :unprocessable_entity
26
+ end
27
+
28
+ private
29
+
30
+ def query_params
31
+ params.require(:query).permit(:name, :description, :data_source)
32
+ end
33
+
34
+ def find_data_source
35
+ data_source_id = params.dig(:query, :data_source)
36
+ return nil if data_source_id.blank?
37
+ return nil unless defined?(Blazer) && Blazer.respond_to?(:data_sources)
38
+
39
+ ds = Blazer.data_sources[data_source_id]
40
+ return nil unless ds
41
+ return nil unless data_source_authorized?(ds)
42
+
43
+ ds
44
+ end
45
+
46
+ # Check if current user can access the data source (respects Blazer permissions)
47
+ def data_source_authorized?(data_source)
48
+ return true unless data_source.respond_to?(:settings)
49
+
50
+ allowed_roles = data_source.settings["roles"]
51
+ return true if allowed_roles.blank?
52
+
53
+ user = respond_to?(:blazer_user, true) ? blazer_user : nil
54
+ return false unless user
55
+
56
+ user_roles = user.respond_to?(:roles) ? user.roles : []
57
+ (allowed_roles & user_roles).any?
58
+ end
59
+
60
+ def ensure_ai_enabled
61
+ render json: { error: "AI features are disabled" }, status: :forbidden unless Blazer::Ai.configuration.enabled?
62
+ end
63
+
64
+ def rate_limiter
65
+ @rate_limiter ||= RateLimiter.new
66
+ end
67
+
68
+ def current_identifier
69
+ if respond_to?(:blazer_user, true) && blazer_user
70
+ user_id = blazer_user.respond_to?(:id) ? blazer_user.id : blazer_user.to_s
71
+ "user:#{user_id}"
72
+ else
73
+ "ip:#{request.remote_ip}"
74
+ end
75
+ end
76
+
77
+ def log_generation(query_params, sql)
78
+ return unless Rails.logger
79
+
80
+ Rails.logger.info("[BlazerAI] SQL generated for #{current_identifier}")
81
+ Rails.logger.debug("[BlazerAI] Name: #{query_params[:name].to_s.truncate(100)}")
82
+ Rails.logger.debug("[BlazerAI] Description: #{query_params[:description].to_s.truncate(200)}")
83
+ Rails.logger.debug("[BlazerAI] SQL: #{sql.truncate(500)}")
84
+ end
85
+ end
@@ -0,0 +1,27 @@
1
+ <%= form_for @query, url: @run_query ? run_queries_path : queries_path, method: @run_query ? :post : :put, html: {class: "form-horizontal"} do |f| %>
2
+ <div class="form-group">
3
+ <div class="col-sm-8">
4
+ <%= f.text_field :name, class: "form-control", placeholder: "Name" %>
5
+ </div>
6
+ <div class="col-sm-4">
7
+ <%= f.select :data_source, Blazer.data_sources.values.map { |ds| [ds.name, ds.id] }, {}, class: ("hide" if Blazer.data_sources.size == 1), style: "width: 140px;" %>
8
+ </div>
9
+ </div>
10
+ <div class="form-group">
11
+ <div class="col-sm-12">
12
+ <%= f.text_area :description, class: "form-control", placeholder: "Description", rows: 2 %>
13
+ </div>
14
+ </div>
15
+ <div class="form-group">
16
+ <div class="col-sm-12">
17
+ <%= f.text_area :statement, class: "form-control", placeholder: "SQL", rows: 10, autofocus: true %>
18
+ </div>
19
+ </div>
20
+ <div class="form-group">
21
+ <div class="col-sm-12">
22
+ <%= f.submit "Run", class: "btn btn-primary" %>
23
+ <%= render "blazer/ai/queries/generate_sql_button" %>
24
+ <%= link_to "Cancel", queries_path, class: "btn btn-default" %>
25
+ </div>
26
+ </div>
27
+ <% end %>
@@ -0,0 +1,210 @@
1
+ <style>
2
+ #blazer-ai-container {
3
+ display: inline-block;
4
+ position: relative;
5
+ }
6
+ #generate-sql {
7
+ transition: all 0.2s ease;
8
+ }
9
+ #generate-sql.loading {
10
+ opacity: 0.7;
11
+ cursor: wait;
12
+ }
13
+ #generate-sql .spinner {
14
+ display: none;
15
+ width: 14px;
16
+ height: 14px;
17
+ border: 2px solid transparent;
18
+ border-top-color: currentColor;
19
+ border-radius: 50%;
20
+ animation: spin 0.8s linear infinite;
21
+ margin-right: 6px;
22
+ vertical-align: middle;
23
+ }
24
+ #generate-sql.loading .spinner {
25
+ display: inline-block;
26
+ }
27
+ #generate-sql.loading .btn-text {
28
+ opacity: 0.8;
29
+ }
30
+ @keyframes spin {
31
+ to { transform: rotate(360deg); }
32
+ }
33
+ #blazer-ai-error {
34
+ display: none;
35
+ margin-top: 8px;
36
+ padding: 8px 12px;
37
+ background-color: #f8d7da;
38
+ border: 1px solid #f5c6cb;
39
+ border-radius: 4px;
40
+ color: #721c24;
41
+ font-size: 13px;
42
+ }
43
+ #blazer-ai-error.show {
44
+ display: block;
45
+ }
46
+ #blazer-ai-error .close-btn {
47
+ float: right;
48
+ cursor: pointer;
49
+ font-weight: bold;
50
+ opacity: 0.6;
51
+ }
52
+ #blazer-ai-error .close-btn:hover {
53
+ opacity: 1;
54
+ }
55
+ </style>
56
+
57
+ <span id="blazer-ai-container">
58
+ <button type="button"
59
+ id="generate-sql"
60
+ class="btn btn-success">
61
+ <span class="spinner"></span>
62
+ <span class="btn-text">Generate SQL (AI)</span>
63
+ </button>
64
+ <div id="blazer-ai-error">
65
+ <span class="close-btn" onclick="BlazerAI.hideError()">&times;</span>
66
+ <span class="error-message"></span>
67
+ </div>
68
+ </span>
69
+
70
+ <script>
71
+ (function() {
72
+ window.BlazerAI = {
73
+ isLoading: false,
74
+
75
+ init: function() {
76
+ var btn = document.getElementById('generate-sql');
77
+ if (btn) {
78
+ btn.addEventListener('click', this.handleGenerate.bind(this));
79
+ }
80
+
81
+ // Keyboard shortcut: Ctrl/Cmd + Shift + G
82
+ document.addEventListener('keydown', function(e) {
83
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'G') {
84
+ e.preventDefault();
85
+ BlazerAI.handleGenerate();
86
+ }
87
+ });
88
+ },
89
+
90
+ handleGenerate: function(event) {
91
+ if (event) event.preventDefault();
92
+ if (this.isLoading) return;
93
+
94
+ var name = document.querySelector('input[name="query[name]"]');
95
+ var description = document.querySelector('textarea[name="query[description]"]');
96
+
97
+ var nameValue = name ? name.value.trim() : '';
98
+ var descValue = description ? description.value.trim() : '';
99
+
100
+ if (!nameValue && !descValue) {
101
+ this.showError('Please enter a query name or description to generate SQL.');
102
+ return;
103
+ }
104
+
105
+ this.hideError();
106
+ this.setLoading(true);
107
+
108
+ var dataSourceSelect = document.querySelector('select[name="query[data_source]"]');
109
+ var dataSource = dataSourceSelect ? dataSourceSelect.value : '';
110
+
111
+ var generateSqlUrl = '<%= Blazer::Ai::UrlHelper.blazer_ai_generate_sql_path %>';
112
+
113
+ fetch(generateSqlUrl, {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]') || {}).content || ''
118
+ },
119
+ body: JSON.stringify({
120
+ query: {
121
+ name: nameValue,
122
+ description: descValue,
123
+ data_source: dataSource
124
+ }
125
+ })
126
+ })
127
+ .then(function(response) {
128
+ return response.json().then(function(data) {
129
+ return { status: response.status, data: data };
130
+ });
131
+ })
132
+ .then(function(result) {
133
+ BlazerAI.setLoading(false);
134
+
135
+ if (result.status === 429) {
136
+ var retryAfter = result.data.retry_after || 60;
137
+ BlazerAI.showError('Rate limit exceeded. Please wait ' + retryAfter + ' seconds before trying again.');
138
+ return;
139
+ }
140
+
141
+ if (result.status !== 200 || result.data.error) {
142
+ BlazerAI.showError(result.data.error || 'Failed to generate SQL. Please try again.');
143
+ return;
144
+ }
145
+
146
+ if (result.data.sql) {
147
+ BlazerAI.insertSql(result.data.sql);
148
+ }
149
+ })
150
+ .catch(function(error) {
151
+ BlazerAI.setLoading(false);
152
+ console.error('[BlazerAI] Error:', error);
153
+ BlazerAI.showError('Network error. Please check your connection and try again.');
154
+ });
155
+ },
156
+
157
+ insertSql: function(sql) {
158
+ if (window.editor) {
159
+ window.editor.setValue(sql, 1);
160
+ window.editor.focus();
161
+ } else {
162
+ var textarea = document.querySelector('textarea[name="query[statement]"]');
163
+ if (textarea) {
164
+ textarea.value = sql;
165
+ textarea.focus();
166
+ }
167
+ }
168
+ },
169
+
170
+ setLoading: function(loading) {
171
+ this.isLoading = loading;
172
+ var btn = document.getElementById('generate-sql');
173
+ if (btn) {
174
+ if (loading) {
175
+ btn.classList.add('loading');
176
+ btn.disabled = true;
177
+ } else {
178
+ btn.classList.remove('loading');
179
+ btn.disabled = false;
180
+ }
181
+ }
182
+ },
183
+
184
+ showError: function(message) {
185
+ var errorDiv = document.getElementById('blazer-ai-error');
186
+ var errorMsg = errorDiv ? errorDiv.querySelector('.error-message') : null;
187
+ if (errorDiv && errorMsg) {
188
+ errorMsg.textContent = message;
189
+ errorDiv.classList.add('show');
190
+ }
191
+ },
192
+
193
+ hideError: function() {
194
+ var errorDiv = document.getElementById('blazer-ai-error');
195
+ if (errorDiv) {
196
+ errorDiv.classList.remove('show');
197
+ }
198
+ }
199
+ };
200
+
201
+ // Initialize when DOM is ready
202
+ if (document.readyState === 'loading') {
203
+ document.addEventListener('DOMContentLoaded', function() {
204
+ BlazerAI.init();
205
+ });
206
+ } else {
207
+ BlazerAI.init();
208
+ }
209
+ })();
210
+ </script>
data/config/blazer.yml ADDED
@@ -0,0 +1,79 @@
1
+ # see https://github.com/ankane/blazer for more info
2
+
3
+ data_sources:
4
+ main:
5
+ url: <%= ENV["BLAZER_DATABASE_URL"] %>
6
+
7
+ # statement timeout, in seconds
8
+ # none by default
9
+ # timeout: 15
10
+
11
+ # caching settings
12
+ # can greatly improve speed
13
+ # off by default
14
+ # cache:
15
+ # mode: slow # or all
16
+ # expires_in: 60 # min
17
+ # slow_threshold: 15 # sec, only used in slow mode
18
+
19
+ # wrap queries in a transaction for safety
20
+ # not necessary if you use a read-only user
21
+ # true by default
22
+ # use_transaction: false
23
+
24
+ smart_variables:
25
+ # zone_id: "SELECT id, name FROM zones ORDER BY name ASC"
26
+ # period: ["day", "week", "month"]
27
+ # status: {0: "Active", 1: "Archived"}
28
+
29
+ linked_columns:
30
+ # user_id: "/admin/users/{value}"
31
+
32
+ smart_columns:
33
+ # user_id: "SELECT id, name FROM users WHERE id IN {value}"
34
+
35
+ # create audits
36
+ audit: true
37
+
38
+ # change the time zone
39
+ # time_zone: "Pacific Time (US & Canada)"
40
+
41
+ # class name of the user model
42
+ # user_class: User
43
+
44
+ # method name for the current user
45
+ # user_method: current_user
46
+
47
+ # method name for the display name
48
+ # user_name: name
49
+
50
+ # custom before_action to use for auth
51
+ # before_action_method: require_admin
52
+
53
+ # email to send checks from
54
+ # from_email: blazer@example.org
55
+
56
+ # webhook for Slack
57
+ # slack_webhook_url: <%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %>
58
+
59
+ check_schedules:
60
+ - "1 day"
61
+ - "1 hour"
62
+ - "5 minutes"
63
+
64
+ # enable anomaly detection
65
+ # note: with trend, time series are sent to https://trendapi.org
66
+ # anomaly_checks: prophet / trend / anomaly_detection
67
+
68
+ # enable forecasting
69
+ # note: with trend, time series are sent to https://trendapi.org
70
+ # forecasting: prophet / trend
71
+
72
+ # enable map
73
+ # mapbox_access_token: <%= ENV["MAPBOX_ACCESS_TOKEN"] %>
74
+
75
+ # enable uploads
76
+ # uploads:
77
+ # url: <%= ENV["BLAZER_UPLOADS_URL"] %>
78
+ # schema: uploads
79
+ # data_source: main
@@ -0,0 +1,47 @@
1
+ class InstallBlazer < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :blazer_queries do |t|
4
+ t.references :creator
5
+ t.string :name
6
+ t.text :description
7
+ t.text :statement
8
+ t.string :data_source
9
+ t.string :status
10
+ t.timestamps null: false
11
+ end
12
+
13
+ create_table :blazer_audits do |t|
14
+ t.references :user
15
+ t.references :query
16
+ t.text :statement
17
+ t.string :data_source
18
+ t.datetime :created_at
19
+ end
20
+
21
+ create_table :blazer_dashboards do |t|
22
+ t.references :creator
23
+ t.string :name
24
+ t.timestamps null: false
25
+ end
26
+
27
+ create_table :blazer_dashboard_queries do |t|
28
+ t.references :dashboard
29
+ t.references :query
30
+ t.integer :position
31
+ t.timestamps null: false
32
+ end
33
+
34
+ create_table :blazer_checks do |t|
35
+ t.references :creator
36
+ t.references :query
37
+ t.string :state
38
+ t.string :schedule
39
+ t.text :emails
40
+ t.text :slack_channels
41
+ t.string :check_type
42
+ t.text :message
43
+ t.datetime :last_run_at
44
+ t.timestamps null: false
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # Configuration for Blazer AI.
2
+ class Blazer::Ai::Configuration
3
+ attr_accessor :enabled, :default_model, :temperature, :rate_limit_per_minute,
4
+ :schema_cache_ttl, :max_prompt_length, :max_sql_length
5
+
6
+ def initialize
7
+ @enabled = true
8
+ @default_model = "gpt-5.1-codex"
9
+ @temperature = 0.2
10
+ @rate_limit_per_minute = 20
11
+ @schema_cache_ttl = 12.hours
12
+ @max_prompt_length = 2000
13
+ @max_sql_length = 10_000
14
+ end
15
+
16
+ def enabled?
17
+ @enabled
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # Rails engine for Blazer AI.
2
+ # Views can be overridden by creating app/views/blazer/ai/queries/_generate_sql_button.html.erb
3
+ class Blazer::Ai::Engine < ::Rails::Engine
4
+ isolate_namespace Blazer::Ai
5
+ end
@@ -0,0 +1,48 @@
1
+ module Blazer
2
+ module Ai
3
+ class PromptSanitizer
4
+ # Patterns that might indicate prompt injection attempts
5
+ SUSPICIOUS_PATTERNS = [
6
+ /ignore\s+(previous|above|all)\s+instructions/i,
7
+ /disregard\s+(previous|above|all)/i,
8
+ /forget\s+(everything|what|your)/i,
9
+ /new\s+instructions?:/i,
10
+ /system\s*:/i,
11
+ /assistant\s*:/i,
12
+ /\bDROP\b/i,
13
+ /\bDELETE\b/i,
14
+ /\bTRUNCATE\b/i,
15
+ /<script/i,
16
+ /javascript:/i
17
+ ].freeze
18
+
19
+ def initialize(max_length: nil)
20
+ @max_length = max_length || Blazer::Ai.configuration.max_prompt_length
21
+ end
22
+
23
+ def sanitize(prompt)
24
+ return "" if prompt.blank?
25
+
26
+ sanitized = prompt.to_s.strip
27
+
28
+ # Truncate to max length
29
+ sanitized = sanitized[0...@max_length]
30
+
31
+ # Remove potential injection patterns (replace with empty string)
32
+ SUSPICIOUS_PATTERNS.each do |pattern|
33
+ sanitized = sanitized.gsub(pattern, "")
34
+ end
35
+
36
+ # Remove angle brackets that might affect prompt parsing
37
+ sanitized = sanitized.gsub(/[<>]/, "")
38
+
39
+ sanitized.strip
40
+ end
41
+
42
+ def suspicious?(prompt)
43
+ return false if prompt.blank?
44
+ SUSPICIOUS_PATTERNS.any? { |p| prompt.match?(p) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # Integrates Blazer AI into Rails applications.
2
+ # Injects routes into Blazer::Engine and sets up view paths.
3
+ class Blazer::Ai::Railtie < ::Rails::Railtie
4
+ initializer "blazer_ai.load", before: :load_config_initializers do
5
+ require "blazer/ai/engine"
6
+ end
7
+
8
+ initializer "blazer_ai.prepend_view_paths", after: :load_config_initializers do |app|
9
+ ActiveSupport.on_load(:action_controller) do
10
+ append_view_path(Blazer::Ai::Engine.root.join("app/views"))
11
+ end
12
+ end
13
+
14
+ # Inject routes directly into Blazer::Engine to avoid nested engine routing issues
15
+ initializer "blazer_ai.extend_routes", after: "blazer.routes" do |app|
16
+ if defined?(Blazer::Engine)
17
+ Blazer::Engine.routes.prepend do
18
+ post "/ai/generate_sql" => "ai/queries#create", as: "blazer_ai_generate_sql"
19
+ end
20
+
21
+ ActiveSupport.on_load(:action_controller) do
22
+ helper Blazer::Ai::UrlHelper
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ module Blazer
2
+ module Ai
3
+ class RateLimiter
4
+ class RateLimitExceeded < StandardError
5
+ attr_reader :retry_after
6
+
7
+ def initialize(message = "Rate limit exceeded", retry_after: 60)
8
+ @retry_after = retry_after
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ def initialize(cache: Rails.cache, max_requests: nil, window: 1.minute)
14
+ @cache = cache
15
+ @max_requests = max_requests || Blazer::Ai.configuration.rate_limit_per_minute
16
+ @window = window
17
+ end
18
+
19
+ # Atomically increment and check rate limit in one operation
20
+ # This prevents race conditions where concurrent requests bypass the limit
21
+ def check_and_track!(identifier:)
22
+ key = rate_limit_key(identifier)
23
+
24
+ # Use atomic increment - most cache stores support this
25
+ # For stores that don't support increment with initial value,
26
+ # we fall back to a best-effort approach
27
+ count = atomic_increment(key)
28
+
29
+ if count > @max_requests
30
+ raise RateLimitExceeded.new(
31
+ "Rate limit exceeded. Please wait before generating more queries.",
32
+ retry_after: @window.to_i
33
+ )
34
+ end
35
+
36
+ count
37
+ end
38
+
39
+ private
40
+
41
+ def atomic_increment(key)
42
+ # Try to use atomic increment if available
43
+ # Rails.cache.increment returns the new value after incrementing
44
+ result = @cache.increment(key, 1, expires_in: @window)
45
+
46
+ # Some cache stores return nil on first increment, handle that case
47
+ if result.nil?
48
+ @cache.write(key, 1, expires_in: @window)
49
+ 1
50
+ else
51
+ result
52
+ end
53
+ end
54
+
55
+ def rate_limit_key(identifier)
56
+ window_id = Time.current.to_i / @window.to_i
57
+ "blazer_ai:rate_limit:#{identifier}:#{window_id}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,45 @@
1
+ module Blazer::Ai
2
+ module SchemaCache
3
+ SCHEMA_VERSION = ENV.fetch("BLAZER_AI_SCHEMA_VERSION", 1).to_i
4
+
5
+ class << self
6
+ def fetch(connection, data_source_id: nil)
7
+ cache_key = build_cache_key(data_source_id)
8
+ ttl = Blazer::Ai.configuration.schema_cache_ttl
9
+
10
+ Rails.cache.fetch(cache_key, expires_in: ttl) do
11
+ build_schema_string(connection)
12
+ end
13
+ end
14
+
15
+ def invalidate(data_source_id: nil)
16
+ cache_key = build_cache_key(data_source_id)
17
+ Rails.cache.delete(cache_key)
18
+ end
19
+
20
+ def invalidate_all
21
+ # Pattern-based deletion if supported, otherwise noop
22
+ if Rails.cache.respond_to?(:delete_matched)
23
+ Rails.cache.delete_matched("blazer_ai:schema:*")
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def build_cache_key(data_source_id)
30
+ ds_part = data_source_id || "default"
31
+ "blazer_ai:schema:v#{SCHEMA_VERSION}:#{ds_part}"
32
+ end
33
+
34
+ def build_schema_string(connection)
35
+ tables = connection.tables.sort
36
+ tables.map do |table_name|
37
+ columns = connection.columns(table_name).map do |col|
38
+ "#{col.name} (#{col.sql_type})"
39
+ end
40
+ "#{table_name}: #{columns.join(', ')}"
41
+ end.join("\n")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,108 @@
1
+ module Blazer::Ai
2
+ class SqlGenerator
3
+ class GenerationError < StandardError; end
4
+
5
+ def initialize(params:, data_source: nil)
6
+ @name = params[:name].to_s.strip
7
+ @description = params[:description].to_s.strip
8
+ @data_source = data_source || default_data_source
9
+ @sanitizer = PromptSanitizer.new
10
+ @validator = SqlValidator.new
11
+ end
12
+
13
+ def call
14
+ validate_input!
15
+ sanitize_input!
16
+
17
+ schema = build_schema_context
18
+ adapter_name = determine_adapter_name
19
+
20
+ response = generate_sql(schema, adapter_name)
21
+ sql = @validator.extract_clean_sql(response)
22
+
23
+ raise GenerationError, "Failed to generate valid SQL" if sql.blank?
24
+
25
+ @validator.validate!(sql)
26
+ sql
27
+ end
28
+
29
+ private
30
+
31
+ def validate_input!
32
+ if @name.blank? && @description.blank?
33
+ raise GenerationError, "Please provide a name or description for the query"
34
+ end
35
+ end
36
+
37
+ def sanitize_input!
38
+ @name = @sanitizer.sanitize(@name)
39
+ @description = @sanitizer.sanitize(@description)
40
+ end
41
+
42
+ def build_schema_context
43
+ connection = data_source_connection
44
+ SchemaCache.fetch(connection, data_source_id: @data_source&.id)
45
+ end
46
+
47
+ def determine_adapter_name
48
+ if @data_source
49
+ @data_source.adapter.to_s.downcase
50
+ else
51
+ data_source_connection.adapter_name.downcase
52
+ end
53
+ end
54
+
55
+ def data_source_connection
56
+ if @data_source && @data_source.respond_to?(:connection)
57
+ @data_source.connection
58
+ else
59
+ ActiveRecord::Base.connection
60
+ end
61
+ end
62
+
63
+ def default_data_source
64
+ return nil unless defined?(Blazer) && Blazer.respond_to?(:data_sources)
65
+ Blazer.data_sources.values.first
66
+ end
67
+
68
+ def generate_sql(schema, adapter_name)
69
+ prompt = build_prompt(schema, adapter_name)
70
+ model = Blazer::Ai.configuration.default_model
71
+ temperature = Blazer::Ai.configuration.temperature
72
+
73
+ # Timeout to prevent hung requests if LLM provider is slow/unresponsive
74
+ Timeout.timeout(30, GenerationError, "SQL generation timed out. Please try again.") do
75
+ RubyLLM.chat(model: model)
76
+ .with_temperature(temperature)
77
+ .ask(prompt)
78
+ .content
79
+ end
80
+ end
81
+
82
+ def build_prompt(schema, adapter_name)
83
+ <<~PROMPT
84
+ You are an expert SQL analyst generating queries for a data exploration tool.
85
+
86
+ CRITICAL RULES:
87
+ 1. Generate ONLY SELECT or WITH...SELECT queries
88
+ 2. NEVER use INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, GRANT, REVOKE, EXEC, EXECUTE, or UNION
89
+ 3. Use only tables and columns from the schema below
90
+ 4. Always include a LIMIT clause (max 10000 rows)
91
+ 5. Use table aliases for readability
92
+ 6. Handle NULL values appropriately with COALESCE or IS NULL checks
93
+ 7. Return ONLY the SQL query - no explanations, no markdown formatting, no code blocks
94
+
95
+ DATABASE TYPE: #{adapter_name}
96
+
97
+ SCHEMA:
98
+ #{schema}
99
+
100
+ USER REQUEST:
101
+ Name: #{@name}
102
+ Description: #{@description}
103
+
104
+ Generate the SQL query now:
105
+ PROMPT
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,93 @@
1
+ module Blazer
2
+ module Ai
3
+ class SqlValidator
4
+ class ValidationError < StandardError; end
5
+
6
+ # Keywords that indicate write/dangerous operations - never allow these
7
+ FORBIDDEN_KEYWORDS = %w[
8
+ INSERT UPDATE DELETE DROP TRUNCATE ALTER CREATE
9
+ GRANT REVOKE COMMIT ROLLBACK SAVEPOINT LOCK
10
+ EXEC EXECUTE CALL
11
+ UNION
12
+ COPY
13
+ DECLARE SET
14
+ PREPARE DEALLOCATE
15
+ ATTACH DETACH
16
+ ].freeze
17
+
18
+ # Patterns that indicate potential SQL injection or dangerous operations
19
+ DANGEROUS_PATTERNS = [
20
+ /;\s*\w/i, # Multiple statements
21
+ /--/, # SQL comments (potential injection)
22
+ /\/\*/, # Block comments
23
+ /#(?!\{)/, # MySQL comments (but not Ruby interpolation)
24
+ /\$\$/, # PostgreSQL dollar quoting
25
+ /INTO\s+OUTFILE/i, # File operations
26
+ /INTO\s+DUMPFILE/i, # File operations
27
+ /LOAD_FILE/i, # File operations
28
+ /LOAD\s+DATA/i, # MySQL file loading
29
+ /SLEEP\s*\(/i, # Time-based attacks
30
+ /BENCHMARK\s*\(/i, # Time-based attacks
31
+ /WAITFOR\s+DELAY/i, # SQL Server time attack
32
+ /PG_SLEEP/i, # PostgreSQL time attack
33
+ /PG_READ_FILE/i, # PostgreSQL file read
34
+ /PG_LS_DIR/i, # PostgreSQL directory listing
35
+ /UTL_FILE/i, # Oracle file operations
36
+ /DBMS_/i, # Oracle packages
37
+ /XP_CMDSHELL/i, # SQL Server command execution
38
+ /SP_CONFIGURE/i, # SQL Server configuration
39
+ /0x[0-9A-Fa-f]{8,}/i # Long hex strings (potential encoding attacks)
40
+ ].freeze
41
+
42
+ def validate!(sql)
43
+ raise ValidationError, "SQL cannot be empty" if sql.blank?
44
+
45
+ # Normalize Unicode to prevent homoglyph attacks (e.g., Cyrillic characters)
46
+ # and strip non-ASCII from keyword matching
47
+ ascii_only = sql.encode("ASCII", undef: :replace, replace: "").upcase.gsub(/\s+/, " ")
48
+ normalized = sql.upcase.gsub(/\s+/, " ")
49
+
50
+ # Check for forbidden keywords (check both normalized and ASCII-only versions)
51
+ FORBIDDEN_KEYWORDS.each do |keyword|
52
+ if normalized.match?(/\b#{keyword}\b/) || ascii_only.match?(/\b#{keyword}\b/)
53
+ raise ValidationError, "SQL contains forbidden keyword: #{keyword}"
54
+ end
55
+ end
56
+
57
+ # Check for dangerous patterns
58
+ DANGEROUS_PATTERNS.each do |pattern|
59
+ if sql.match?(pattern)
60
+ raise ValidationError, "SQL contains potentially dangerous pattern"
61
+ end
62
+ end
63
+
64
+ # Ensure it starts with SELECT or WITH
65
+ unless normalized.match?(/^\s*(SELECT|WITH)\b/)
66
+ raise ValidationError, "SQL must start with SELECT or WITH"
67
+ end
68
+
69
+ true
70
+ end
71
+
72
+ def safe?(sql)
73
+ validate!(sql)
74
+ true
75
+ rescue ValidationError
76
+ false
77
+ end
78
+
79
+ def extract_clean_sql(content)
80
+ return nil if content.blank?
81
+
82
+ # Try to extract SQL from markdown code blocks
83
+ if content.include?("```")
84
+ match = content.match(/```(?:sql)?\s*\n?(.*?)\n?```/m)
85
+ return match[1].strip if match
86
+ end
87
+
88
+ # Otherwise return the content stripped
89
+ content.strip
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,20 @@
1
+ # Provides URL helpers for Blazer AI routes.
2
+ # Computes path fresh each time to handle development reloads
3
+ # and multi-tenant scenarios where mount points may vary.
4
+ module Blazer::Ai::UrlHelper
5
+ # Returns the path to the generate_sql endpoint.
6
+ # Automatically detects Blazer's mount point.
7
+ def self.blazer_ai_generate_sql_path
8
+ path = "/ai/generate_sql"
9
+
10
+ if defined?(Rails.application)
11
+ main_route = Rails.application.routes.routes.find { |r| r.name == "blazer" }
12
+ if main_route
13
+ blazer_mount = main_route.path.spec.to_s.gsub("(.:format)", "")
14
+ path = blazer_mount + path
15
+ end
16
+ end
17
+
18
+ path
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ module Blazer
2
+ module Ai
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
data/lib/blazer/ai.rb ADDED
@@ -0,0 +1,33 @@
1
+ require "blazer/ai/version"
2
+ require "blazer/ai/configuration"
3
+ require "blazer/ai/url_helper"
4
+
5
+ # Load security components
6
+ require "blazer/ai/sql_validator"
7
+ require "blazer/ai/prompt_sanitizer"
8
+ require "blazer/ai/rate_limiter"
9
+
10
+ # Load schema and SQL generators
11
+ require "blazer/ai/schema_cache"
12
+ require "blazer/ai/sql_generator"
13
+
14
+ # Load railtie last - it will load the engine
15
+ require "blazer/ai/railtie"
16
+
17
+ module Blazer
18
+ module Ai
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(configuration)
26
+ end
27
+
28
+ def reset_configuration!
29
+ @configuration = Configuration.new
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Creates the Blazer AI initializer for AI-powered SQL generation.
3
+
4
+ Example:
5
+ bin/rails generate blazer_ai:install
6
+
7
+ This will create:
8
+ config/initializers/blazer_ai.rb
@@ -0,0 +1,13 @@
1
+ require "rails/generators"
2
+
3
+ module BlazerAi
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def copy_initializer
9
+ copy_file "blazer_ai.rb", "config/initializers/blazer_ai.rb"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ RubyLLM.configure do |config|
2
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
3
+ end
4
+
5
+ Blazer::Ai.configure do |config|
6
+ config.default_model = "gpt-5.1-codex"
7
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: blazer-ai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kieran Klaassen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: blazer
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ruby_llm
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ description: A Rails engine that adds AI-powered natural language to SQL generation
56
+ for the Blazer analytics dashboard. Uses RubyLLM to support multiple AI providers
57
+ (OpenAI, Anthropic, Gemini, etc.).
58
+ email:
59
+ - kieranklaassen@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - MIT-LICENSE
66
+ - README.md
67
+ - Rakefile
68
+ - app/controllers/blazer/ai/application_controller.rb
69
+ - app/controllers/blazer/ai/queries_controller.rb
70
+ - app/overrides/blazer/queries/_form.html.erb
71
+ - app/views/blazer/ai/queries/_generate_sql_button.html.erb
72
+ - config/blazer.yml
73
+ - db/migrate/20250416180342_install_blazer.rb
74
+ - lib/blazer/ai.rb
75
+ - lib/blazer/ai/configuration.rb
76
+ - lib/blazer/ai/engine.rb
77
+ - lib/blazer/ai/prompt_sanitizer.rb
78
+ - lib/blazer/ai/railtie.rb
79
+ - lib/blazer/ai/rate_limiter.rb
80
+ - lib/blazer/ai/schema_cache.rb
81
+ - lib/blazer/ai/sql_generator.rb
82
+ - lib/blazer/ai/sql_validator.rb
83
+ - lib/blazer/ai/url_helper.rb
84
+ - lib/blazer/ai/version.rb
85
+ - lib/generators/blazer_ai/USAGE
86
+ - lib/generators/blazer_ai/install_generator.rb
87
+ - lib/generators/blazer_ai/templates/blazer_ai.rb
88
+ homepage: https://github.com/kieranklaassen/blazer-ai
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ homepage_uri: https://github.com/kieranklaassen/blazer-ai
93
+ source_code_uri: https://github.com/kieranklaassen/blazer-ai
94
+ changelog_uri: https://github.com/kieranklaassen/blazer-ai/blob/main/CHANGELOG.md
95
+ rubygems_mfa_required: 'true'
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.2'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.4.10
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: AI-powered SQL generation for Blazer
115
+ test_files: []