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 +7 -0
- data/CHANGELOG.md +31 -0
- data/MIT-LICENSE +20 -0
- data/README.md +96 -0
- data/Rakefile +8 -0
- data/app/controllers/blazer/ai/application_controller.rb +4 -0
- data/app/controllers/blazer/ai/queries_controller.rb +85 -0
- data/app/overrides/blazer/queries/_form.html.erb +27 -0
- data/app/views/blazer/ai/queries/_generate_sql_button.html.erb +210 -0
- data/config/blazer.yml +79 -0
- data/db/migrate/20250416180342_install_blazer.rb +47 -0
- data/lib/blazer/ai/configuration.rb +19 -0
- data/lib/blazer/ai/engine.rb +5 -0
- data/lib/blazer/ai/prompt_sanitizer.rb +48 -0
- data/lib/blazer/ai/railtie.rb +26 -0
- data/lib/blazer/ai/rate_limiter.rb +61 -0
- data/lib/blazer/ai/schema_cache.rb +45 -0
- data/lib/blazer/ai/sql_generator.rb +108 -0
- data/lib/blazer/ai/sql_validator.rb +93 -0
- data/lib/blazer/ai/url_helper.rb +20 -0
- data/lib/blazer/ai/version.rb +5 -0
- data/lib/blazer/ai.rb +33 -0
- data/lib/generators/blazer_ai/USAGE +8 -0
- data/lib/generators/blazer_ai/install_generator.rb +13 -0
- data/lib/generators/blazer_ai/templates/blazer_ai.rb +7 -0
- metadata +115 -0
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
|
+
[](https://badge.fury.io/rb/blazer-ai)
|
|
6
|
+
[](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,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()">×</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,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
|
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,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
|
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: []
|