query_lens 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/MIT-LICENSE +21 -0
- data/README.md +331 -0
- data/Rakefile +10 -0
- data/app/controllers/query_lens/ai_controller.rb +23 -0
- data/app/controllers/query_lens/application_controller.rb +46 -0
- data/app/controllers/query_lens/conversations_controller.rb +65 -0
- data/app/controllers/query_lens/projects_controller.rb +64 -0
- data/app/controllers/query_lens/queries_controller.rb +95 -0
- data/app/controllers/query_lens/saved_queries_controller.rb +43 -0
- data/app/models/query_lens/application_record.rb +5 -0
- data/app/models/query_lens/conversation.rb +16 -0
- data/app/models/query_lens/project.rb +11 -0
- data/app/models/query_lens/saved_query.rb +13 -0
- data/app/services/query_lens/schema_introspector.rb +156 -0
- data/app/services/query_lens/sql_generator.rb +146 -0
- data/app/views/query_lens/layouts/application.html.erb +526 -0
- data/app/views/query_lens/queries/show.html.erb +863 -0
- data/config/routes.rb +11 -0
- data/lib/generators/query_lens/install/install_generator.rb +28 -0
- data/lib/generators/query_lens/install/templates/initializer.rb +64 -0
- data/lib/query_lens/configuration.rb +21 -0
- data/lib/query_lens/engine.rb +17 -0
- data/lib/query_lens/version.rb +3 -0
- data/lib/query_lens.rb +22 -0
- metadata +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d28f67096af3240b7f12db962ca83d94b7cb0677d729f3410f4101ef2a827ddd
|
|
4
|
+
data.tar.gz: cf58506046e37fbd584b6cd8fcf11f3b24567d6495a16250d50797f8f206872e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6f544be843075beeb03ae79316627e2970b5b8644358fdf60b2576c22555790e97699502a93ba09d9c64b724547e2f47770c25953c2a16ebe9a485fa8dfc3ee7
|
|
7
|
+
data.tar.gz: d6ef26c0c2fcf1527bb57eb70d98e56a4fe18bc8e57965a3b917b1a537dca8007684e76fcd21ee8099d401c1d3cf6da8623e9bd19d6bb0c8353948ff9f9ecee5
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryan Beshore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# QueryLens
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine that lets users write natural language questions and get SQL queries generated by AI, executed against their database, with results displayed — all in one interface. Think "Blazer meets ChatGPT."
|
|
4
|
+
|
|
5
|
+
Powered by [RubyLLM](https://rubyllm.com), QueryLens works with any major AI provider: OpenAI, Anthropic (Claude), Google Gemini, DeepSeek, Mistral, Ollama (local models), and more.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Natural language to SQL conversion powered by any LLM
|
|
10
|
+
- Works with OpenAI, Anthropic, Gemini, Ollama, and 10+ other providers
|
|
11
|
+
- **Saved queries** organized into projects (like Blazer) — save, edit, move, and reuse known-good queries
|
|
12
|
+
- Automatic database schema introspection with caching
|
|
13
|
+
- Smart schema handling for large databases (two-stage table selection)
|
|
14
|
+
- **Conversation history** — auto-saved conversations persist across page refreshes, with a Claude.ai-style sidebar for browsing recent chats
|
|
15
|
+
- Interactive conversation with context (follow-up questions refine queries)
|
|
16
|
+
- Read-only query execution (safety enforced at transaction level)
|
|
17
|
+
- Editable SQL editor with syntax highlighting
|
|
18
|
+
- Results displayed as sortable tables
|
|
19
|
+
- Configurable authentication, timeouts, and row limits
|
|
20
|
+
- Zero frontend dependencies (self-contained CSS, vanilla JS)
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Add to your Gemfile:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
gem "query_lens"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bundle install
|
|
34
|
+
rails generate query_lens:install
|
|
35
|
+
rails db:migrate
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This will:
|
|
39
|
+
1. Create `config/initializers/query_lens.rb` with RubyLLM and QueryLens configuration
|
|
40
|
+
2. Add the engine route to your `config/routes.rb`
|
|
41
|
+
3. Create the `query_lens_projects`, `query_lens_saved_queries`, and `query_lens_conversations` tables
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Configure your AI provider in `config/initializers/query_lens.rb`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Configure your AI provider (you only need one)
|
|
49
|
+
RubyLLM.configure do |config|
|
|
50
|
+
config.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
51
|
+
# config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
52
|
+
# config.gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
53
|
+
# config.ollama_api_base = "http://localhost:11434"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
QueryLens.configure do |config|
|
|
57
|
+
# Pick any model supported by your provider
|
|
58
|
+
config.model = "gpt-4o" # OpenAI
|
|
59
|
+
# config.model = "claude-sonnet-4-5-20250929" # Anthropic
|
|
60
|
+
# config.model = "gemini-2.0-flash" # Google
|
|
61
|
+
# config.model = "llama3.2" # Ollama (local)
|
|
62
|
+
|
|
63
|
+
config.max_rows = 1000 # Max rows returned
|
|
64
|
+
config.query_timeout = 30 # Seconds
|
|
65
|
+
config.excluded_tables = %w[api_keys secrets] # Hide from AI
|
|
66
|
+
config.authentication = ->(controller) { # Auth check
|
|
67
|
+
controller.current_user&.admin?
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Schema cache TTL in seconds (default: 300 / 5 minutes)
|
|
71
|
+
# config.schema_cache_ttl = 300
|
|
72
|
+
|
|
73
|
+
# Table selection threshold (default: 50)
|
|
74
|
+
# Schemas larger than this use two-stage AI generation
|
|
75
|
+
# config.table_selection_threshold = 50
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Visit `/query_lens` in your browser and start asking questions:
|
|
82
|
+
|
|
83
|
+
- "How many users signed up this month?"
|
|
84
|
+
- "What's the total revenue by plan?"
|
|
85
|
+
- "Show me the top 10 accounts by transaction volume"
|
|
86
|
+
- "Break that down by month" (follow-up questions work!)
|
|
87
|
+
|
|
88
|
+
## Saved Queries
|
|
89
|
+
|
|
90
|
+
QueryLens supports saving queries for reuse, organized into projects (e.g., "Rewards", "User Analytics"). Saved queries are shared across all admins.
|
|
91
|
+
|
|
92
|
+
### Saving a Query
|
|
93
|
+
|
|
94
|
+
1. Run a query (via AI or manually)
|
|
95
|
+
2. Click **Save** in the sidebar's Saved Queries section
|
|
96
|
+
3. Enter a name, optional description, and optionally assign to a project
|
|
97
|
+
|
|
98
|
+
### Projects
|
|
99
|
+
|
|
100
|
+
Projects are folders for organizing saved queries. Create them from the Saved Queries toolbar. When a project is deleted, its queries are moved to "Unorganized" rather than being destroyed.
|
|
101
|
+
|
|
102
|
+
### Loading a Saved Query
|
|
103
|
+
|
|
104
|
+
Click any saved query in the sidebar to load its SQL into the editor and auto-run it.
|
|
105
|
+
|
|
106
|
+
### Managing Queries
|
|
107
|
+
|
|
108
|
+
Use the kebab menu (three dots) on any project or query to rename, edit, move between projects, or delete.
|
|
109
|
+
|
|
110
|
+
## Conversation History
|
|
111
|
+
|
|
112
|
+
Conversations auto-save as you chat — no save button needed. A page refresh preserves your full conversation history, including the SQL editor state.
|
|
113
|
+
|
|
114
|
+
### How It Works
|
|
115
|
+
|
|
116
|
+
- **First message** in a new chat creates a conversation (titled from your first question)
|
|
117
|
+
- **Subsequent messages** update the conversation automatically after each AI response
|
|
118
|
+
- **Conversation sidebar** shows recent chats in the left sidebar under "Recents"
|
|
119
|
+
- Click any conversation to restore it — messages, SQL editor, and all
|
|
120
|
+
- Click **New Chat** to start fresh
|
|
121
|
+
- Delete conversations with the × button (appears on hover)
|
|
122
|
+
|
|
123
|
+
Conversations are shared across all admins (no per-user scoping), matching the pattern of saved queries. The most recent 50 conversations are shown in the sidebar.
|
|
124
|
+
|
|
125
|
+
## How Schema Handling Works
|
|
126
|
+
|
|
127
|
+
QueryLens needs to tell the AI about your database structure so it can write accurate SQL. Naively sending your entire schema on every request would be slow and expensive for large databases. Here's how QueryLens handles this:
|
|
128
|
+
|
|
129
|
+
### Schema Caching
|
|
130
|
+
|
|
131
|
+
The database schema is introspected once and cached in memory. Subsequent AI requests reuse the cached schema instead of re-querying every table, column, and row count from the database. The cache expires after 5 minutes by default (configurable via `schema_cache_ttl`).
|
|
132
|
+
|
|
133
|
+
### Small Databases (< 50 tables)
|
|
134
|
+
|
|
135
|
+
For most applications, the full schema is compact enough to send directly to the AI in a single request. Every table with its columns, types, foreign keys, and approximate row counts is included in the system prompt. This gives the AI complete context to write accurate queries.
|
|
136
|
+
|
|
137
|
+
### Large Databases (50+ tables)
|
|
138
|
+
|
|
139
|
+
For large schemas — hundreds of tables, thousands of columns — sending everything would burn excessive tokens, increase latency, and potentially exceed context windows. QueryLens uses a **two-stage approach** instead:
|
|
140
|
+
|
|
141
|
+
**Stage 1 — Table Selection:** A compact index is sent to the AI — one line per table listing just the table name, column names, and row count. The AI identifies which tables (typically 3-10) are relevant to the user's question.
|
|
142
|
+
|
|
143
|
+
**Stage 2 — Query Generation:** The full schema (columns, types, foreign keys, constraints) for only the selected tables is sent to the AI, which generates the SQL query.
|
|
144
|
+
|
|
145
|
+
This mirrors how a human DBA works: scan the table list, zero in on the relevant ones, then examine their structure. It also mirrors how tools like Claude Code work — they don't load an entire codebase into context, they search for and read only the relevant files.
|
|
146
|
+
|
|
147
|
+
The threshold is configurable via `table_selection_threshold` (default: 50 tables). For databases right around the threshold, you can tune this based on your preference for completeness vs. speed.
|
|
148
|
+
|
|
149
|
+
## Security
|
|
150
|
+
|
|
151
|
+
QueryLens enforces multiple layers of safety:
|
|
152
|
+
|
|
153
|
+
1. **Read-only transactions**: All queries run inside `SET TRANSACTION READ ONLY` (PostgreSQL)
|
|
154
|
+
2. **SQL parsing**: Rejects any statement that isn't a SELECT or WITH (CTE)
|
|
155
|
+
3. **Statement blocklist**: Blocks INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, GRANT, REVOKE, EXECUTE, CALL
|
|
156
|
+
4. **Semicolon blocking**: Prevents multi-statement injection
|
|
157
|
+
5. **Function blocklist**: Blocks dangerous PostgreSQL functions (pg_sleep, pg_terminate_backend, etc.)
|
|
158
|
+
6. **Query timeout**: Configurable per-query timeout enforced at the database level
|
|
159
|
+
7. **Row limits**: Configurable max rows (default 1000)
|
|
160
|
+
8. **Authentication**: Configurable auth lambda to restrict access
|
|
161
|
+
9. **Table enforcement**: `excluded_tables` blocks both AI context and query execution — queries referencing restricted tables are rejected with a clear error
|
|
162
|
+
10. **Audit logging**: Configurable logging of all query executions, blocked attempts, and AI generations
|
|
163
|
+
|
|
164
|
+
**Important**: Always restrict access to QueryLens in production using the `authentication` config option. Even with read-only enforcement, database access should be limited to authorized users.
|
|
165
|
+
|
|
166
|
+
### Excluded Tables
|
|
167
|
+
|
|
168
|
+
Tables listed in `excluded_tables` are enforced at two levels:
|
|
169
|
+
- **AI context**: The AI never sees these tables, so it won't suggest queries against them
|
|
170
|
+
- **Execution**: Even if a user manually types a query referencing a restricted table, it's blocked with a clear error
|
|
171
|
+
|
|
172
|
+
Restricted tables are shown in a collapsible banner in the UI so admins know which tables are off-limits.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
QueryLens.configure do |config|
|
|
176
|
+
config.excluded_tables = %w[api_keys admin_users payment_methods ssn_records]
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Audit Logging
|
|
181
|
+
|
|
182
|
+
Every query execution, blocked attempt, and AI generation can be logged. The audit logger receives a hash with `:user`, `:action`, `:sql`, `:row_count`, `:error`, `:timestamp`, and `:ip`.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
QueryLens.configure do |config|
|
|
186
|
+
# Simple: log to Rails logger
|
|
187
|
+
config.audit_logger = ->(entry) {
|
|
188
|
+
Rails.logger.info("[QueryLens] #{entry[:action]} by #{entry[:user]} — #{entry[:sql]}")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Production: log to a database table
|
|
192
|
+
config.audit_logger = ->(entry) {
|
|
193
|
+
QueryAuditLog.create!(
|
|
194
|
+
user_identifier: entry[:user],
|
|
195
|
+
action: entry[:action],
|
|
196
|
+
sql_query: entry[:sql],
|
|
197
|
+
row_count: entry[:row_count],
|
|
198
|
+
error_message: entry[:error],
|
|
199
|
+
ip_address: entry[:ip]
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# The method called on the controller to identify the user (default: :current_user)
|
|
204
|
+
# For Active Admin: :current_admin_user
|
|
205
|
+
config.current_user_method = :current_admin_user
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Actions logged:
|
|
210
|
+
- `execute` — successful query execution (includes SQL and row count)
|
|
211
|
+
- `execute_blocked` — query rejected by safety checks (includes reason)
|
|
212
|
+
- `execute_error` — query failed at database level (includes error message)
|
|
213
|
+
- `generate` — AI generated SQL
|
|
214
|
+
- `generate_error` — AI generation failed
|
|
215
|
+
|
|
216
|
+
Audit logging is fail-safe: if your logger raises an error, the query still executes normally and the failure is logged to `Rails.logger.error`.
|
|
217
|
+
|
|
218
|
+
### Read-Only Connection (Recommended for Production)
|
|
219
|
+
|
|
220
|
+
For production use, point QueryLens at a read-only database replica or a connection using a read-only PostgreSQL user:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
QueryLens.configure do |config|
|
|
224
|
+
config.read_only_connection = ActiveRecord::Base.connected_to(role: :reading) {
|
|
225
|
+
ActiveRecord::Base.connection
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Production Checklist
|
|
231
|
+
|
|
232
|
+
Before deploying QueryLens to a production environment, especially one with sensitive data:
|
|
233
|
+
|
|
234
|
+
1. **Restrict access with authentication.** Never run QueryLens without an `authentication` lambda. Limit access to specific admin roles — not everyone who can log in should be able to query your database.
|
|
235
|
+
|
|
236
|
+
2. **Point it at a read-only replica.** A runaway query (big joins, full table scans) hitting your primary database can affect production performance. A replica isolates that blast radius. See [Read-Only Connection](#read-only-connection-recommended-for-production) above.
|
|
237
|
+
|
|
238
|
+
3. **Use a read-only database user.** Belt and suspenders. Even with transaction-level read-only enforcement, connecting via a PostgreSQL user with only `SELECT` grants means the database itself won't allow writes regardless of what happens at the application level.
|
|
239
|
+
|
|
240
|
+
4. **Exclude sensitive tables.** Any table containing PII, credentials, financial secrets, or data that shouldn't be queryable — add it to `excluded_tables`. The AI will never see these tables, and manual queries against them are blocked at execution time.
|
|
241
|
+
|
|
242
|
+
5. **Enable audit logging.** Log every query, every blocked attempt, with the user and IP address. If someone queries something they shouldn't, you want to know. See [Audit Logging](#audit-logging) above.
|
|
243
|
+
|
|
244
|
+
6. **Review your schema exposure.** QueryLens sends your database schema (table names, column names, types, foreign keys) to your configured LLM provider. If your schema itself is sensitive, consider using a local model via Ollama instead of a cloud provider.
|
|
245
|
+
|
|
246
|
+
## Mounting with Active Admin
|
|
247
|
+
|
|
248
|
+
If you use [Active Admin](https://activeadmin.info), you can mount QueryLens under your admin path and reuse Active Admin's authentication:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
# config/routes.rb
|
|
252
|
+
Rails.application.routes.draw do
|
|
253
|
+
devise_for :admin_users, ActiveAdmin::Devise.config
|
|
254
|
+
ActiveAdmin.routes(self)
|
|
255
|
+
|
|
256
|
+
# Mount QueryLens under /admin/query_lens
|
|
257
|
+
mount QueryLens::Engine, at: "/admin/query_lens"
|
|
258
|
+
|
|
259
|
+
# ...
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Then configure QueryLens to require an authenticated admin user:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# config/initializers/query_lens.rb
|
|
267
|
+
QueryLens.configure do |config|
|
|
268
|
+
config.authentication = ->(controller) {
|
|
269
|
+
# Warden is available because Devise is middleware
|
|
270
|
+
controller.request.env["warden"].authenticated?(:admin_user)
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
QueryLens will be available at `/admin/query_lens`. Unauthenticated requests get a 401. Active Admin's navigation won't show a link automatically — you can add one with a custom menu item:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
# app/admin/query_lens.rb
|
|
279
|
+
ActiveAdmin.register_page "QueryLens" do
|
|
280
|
+
menu label: "QueryLens", url: "/admin/query_lens", priority: 99
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Requirements
|
|
285
|
+
|
|
286
|
+
- Rails 7.1+
|
|
287
|
+
- Ruby 3.2+
|
|
288
|
+
- An API key for any [RubyLLM-supported provider](https://rubyllm.com) (or a local Ollama instance)
|
|
289
|
+
- PostgreSQL recommended (SQLite works but without transaction-level read-only enforcement)
|
|
290
|
+
|
|
291
|
+
## Try It Out
|
|
292
|
+
|
|
293
|
+
Want to see QueryLens in action before integrating it into your own app? The [QueryLens Testbed](https://github.com/bryanbeshore/query_lens_testbed) is a standalone Rails app with a realistic SaaS dataset — users, teams, posts, comments, tags, and invoices (~1,450 records) — ready to query.
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
git clone https://github.com/bryanbeshore/query_lens_testbed.git
|
|
297
|
+
git clone https://github.com/bryanbeshore/query_lens.git
|
|
298
|
+
cd query_lens_testbed
|
|
299
|
+
bundle install
|
|
300
|
+
bin/rails db:prepare
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Start the server with your API key (any [RubyLLM-supported provider](https://rubyllm.com) works):
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
ANTHROPIC_API_KEY=sk-ant-your-key bin/dev
|
|
307
|
+
# or: OPENAI_API_KEY=sk-your-key bin/dev
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Then visit [localhost:3000/query_lens](http://localhost:3000/query_lens) and start asking questions.
|
|
311
|
+
|
|
312
|
+
## Development
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
git clone https://github.com/bryanbeshore/query_lens.git
|
|
316
|
+
cd query_lens
|
|
317
|
+
bundle install
|
|
318
|
+
bundle exec rake test
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Contributing
|
|
322
|
+
|
|
323
|
+
1. Fork the repo
|
|
324
|
+
2. Create your feature branch (`git checkout -b my-feature`)
|
|
325
|
+
3. Commit your changes (`git commit -am 'Add feature'`)
|
|
326
|
+
4. Push to the branch (`git push origin my-feature`)
|
|
327
|
+
5. Create a Pull Request
|
|
328
|
+
|
|
329
|
+
## License
|
|
330
|
+
|
|
331
|
+
MIT License. See [MIT-LICENSE](MIT-LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module QueryLens
|
|
2
|
+
class AiController < ApplicationController
|
|
3
|
+
def generate
|
|
4
|
+
messages = params[:messages] || []
|
|
5
|
+
messages = messages.map { |m| m.permit(:role, :content).to_h }
|
|
6
|
+
|
|
7
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
|
+
|
|
9
|
+
schema = SchemaIntrospector.cached_schema
|
|
10
|
+
generator = SqlGenerator.new(schema: schema)
|
|
11
|
+
result = generator.generate(messages: messages)
|
|
12
|
+
|
|
13
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
14
|
+
|
|
15
|
+
audit(action: "generate", sql: result[:sql])
|
|
16
|
+
|
|
17
|
+
render json: result.merge(generation_ms: elapsed_ms)
|
|
18
|
+
rescue => e
|
|
19
|
+
audit(action: "generate_error", error: e.message)
|
|
20
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module QueryLens
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :null_session
|
|
4
|
+
layout "query_lens/layouts/application"
|
|
5
|
+
before_action :authenticate!
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def authenticate!
|
|
10
|
+
unless QueryLens.configuration.authentication.call(self)
|
|
11
|
+
head :unauthorized
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def audit(action:, sql: nil, row_count: nil, error: nil)
|
|
16
|
+
logger = QueryLens.configuration.audit_logger
|
|
17
|
+
return unless logger
|
|
18
|
+
|
|
19
|
+
entry = {
|
|
20
|
+
user: current_query_lens_user,
|
|
21
|
+
action: action,
|
|
22
|
+
sql: sql,
|
|
23
|
+
row_count: row_count,
|
|
24
|
+
error: error,
|
|
25
|
+
timestamp: Time.current,
|
|
26
|
+
ip: request.remote_ip
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
logger.call(entry)
|
|
30
|
+
rescue => e
|
|
31
|
+
Rails.logger.error("[QueryLens] Audit logging failed: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_query_lens_user
|
|
35
|
+
method_name = QueryLens.configuration.current_user_method
|
|
36
|
+
return nil unless respond_to?(method_name, true)
|
|
37
|
+
|
|
38
|
+
user = send(method_name)
|
|
39
|
+
return "#{user.class.name}##{user.id}" if user.respond_to?(:id)
|
|
40
|
+
|
|
41
|
+
user.to_s
|
|
42
|
+
rescue
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module QueryLens
|
|
2
|
+
class ConversationsController < ApplicationController
|
|
3
|
+
skip_forgery_protection
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
conversations = Conversation.select(:id, :title, :updated_at).limit(50)
|
|
7
|
+
render json: conversations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
conversation = Conversation.find(params[:id])
|
|
12
|
+
render json: {
|
|
13
|
+
id: conversation.id,
|
|
14
|
+
title: conversation.title,
|
|
15
|
+
messages: conversation.messages,
|
|
16
|
+
last_sql: conversation.last_sql,
|
|
17
|
+
updated_at: conversation.updated_at
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
conversation = Conversation.new(conversation_params)
|
|
23
|
+
|
|
24
|
+
if conversation.save
|
|
25
|
+
render json: {
|
|
26
|
+
id: conversation.id,
|
|
27
|
+
title: conversation.title,
|
|
28
|
+
messages: conversation.messages,
|
|
29
|
+
last_sql: conversation.last_sql,
|
|
30
|
+
updated_at: conversation.updated_at
|
|
31
|
+
}, status: :created
|
|
32
|
+
else
|
|
33
|
+
render json: { errors: conversation.errors.full_messages }, status: :unprocessable_entity
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update
|
|
38
|
+
conversation = Conversation.find(params[:id])
|
|
39
|
+
|
|
40
|
+
if conversation.update(conversation_params)
|
|
41
|
+
render json: {
|
|
42
|
+
id: conversation.id,
|
|
43
|
+
title: conversation.title,
|
|
44
|
+
messages: conversation.messages,
|
|
45
|
+
last_sql: conversation.last_sql,
|
|
46
|
+
updated_at: conversation.updated_at
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
render json: { errors: conversation.errors.full_messages }, status: :unprocessable_entity
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def destroy
|
|
54
|
+
conversation = Conversation.find(params[:id])
|
|
55
|
+
conversation.destroy
|
|
56
|
+
head :no_content
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def conversation_params
|
|
62
|
+
params.permit(:title, :last_sql, messages: [:role, :content])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module QueryLens
|
|
2
|
+
class ProjectsController < ApplicationController
|
|
3
|
+
skip_forgery_protection
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
projects = Project.includes(:saved_queries).map do |project|
|
|
7
|
+
{
|
|
8
|
+
id: project.id,
|
|
9
|
+
name: project.name,
|
|
10
|
+
description: project.description,
|
|
11
|
+
position: project.position,
|
|
12
|
+
saved_queries: project.saved_queries.map { |q| saved_query_json(q) }
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
unorganized = SavedQuery.where(project_id: nil).map { |q| saved_query_json(q) }
|
|
17
|
+
|
|
18
|
+
render json: { projects: projects, unorganized: unorganized }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
project = Project.new(project_params)
|
|
23
|
+
|
|
24
|
+
if project.save
|
|
25
|
+
render json: { id: project.id, name: project.name, description: project.description }, status: :created
|
|
26
|
+
else
|
|
27
|
+
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update
|
|
32
|
+
project = Project.find(params[:id])
|
|
33
|
+
|
|
34
|
+
if project.update(project_params)
|
|
35
|
+
render json: { id: project.id, name: project.name, description: project.description }
|
|
36
|
+
else
|
|
37
|
+
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def destroy
|
|
42
|
+
project = Project.find(params[:id])
|
|
43
|
+
project.destroy
|
|
44
|
+
head :no_content
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def project_params
|
|
50
|
+
params.permit(:name, :description, :position)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def saved_query_json(query)
|
|
54
|
+
{
|
|
55
|
+
id: query.id,
|
|
56
|
+
name: query.name,
|
|
57
|
+
description: query.description,
|
|
58
|
+
sql: query.sql,
|
|
59
|
+
project_id: query.project_id,
|
|
60
|
+
position: query.position
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module QueryLens
|
|
2
|
+
class QueriesController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def info
|
|
7
|
+
render json: {
|
|
8
|
+
excluded_tables: QueryLens.configuration.excluded_tables,
|
|
9
|
+
max_rows: QueryLens.configuration.max_rows,
|
|
10
|
+
query_timeout: QueryLens.configuration.query_timeout
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute
|
|
15
|
+
sql = params[:sql].to_s.strip.chomp(";").strip
|
|
16
|
+
|
|
17
|
+
# Layer 1: Must start with SELECT or WITH (for CTEs)
|
|
18
|
+
unless sql.match?(/\A\s*(\(?\s*SELECT|WITH\s)/i)
|
|
19
|
+
audit(action: "execute_blocked", sql: sql, error: "Not a SELECT")
|
|
20
|
+
return render json: { error: "Only SELECT queries are allowed" }, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Layer 2: Block any DML/DDL keywords anywhere in the query
|
|
24
|
+
if sql.match?(/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|EXEC|EXECUTE|CALL)\b/i)
|
|
25
|
+
audit(action: "execute_blocked", sql: sql, error: "DML/DDL keyword detected")
|
|
26
|
+
return render json: { error: "Only SELECT queries are allowed" }, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Layer 3: Block semicolons to prevent multi-statement injection
|
|
30
|
+
if sql.include?(";")
|
|
31
|
+
audit(action: "execute_blocked", sql: sql, error: "Semicolon detected")
|
|
32
|
+
return render json: { error: "Multiple statements are not allowed" }, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Layer 4: Block dangerous PostgreSQL functions
|
|
36
|
+
if sql.match?(/\b(pg_sleep|pg_terminate_backend|pg_cancel_backend|lo_import|lo_export|copy\s)/i)
|
|
37
|
+
audit(action: "execute_blocked", sql: sql, error: "Dangerous function detected")
|
|
38
|
+
return render json: { error: "This function is not allowed" }, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Layer 5: Block queries against excluded tables
|
|
42
|
+
excluded = QueryLens.configuration.excluded_tables
|
|
43
|
+
if excluded.any?
|
|
44
|
+
referenced = excluded.select { |table| sql.match?(/\b#{Regexp.escape(table)}\b/i) }
|
|
45
|
+
if referenced.any?
|
|
46
|
+
audit(action: "execute_blocked", sql: sql, error: "Restricted table: #{referenced.join(', ')}")
|
|
47
|
+
return render json: { error: "Access to restricted table: #{referenced.join(', ')}" }, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
connection = QueryLens.configuration.read_only_connection || ActiveRecord::Base.connection
|
|
52
|
+
postgresql = connection.adapter_name.downcase.include?("postgresql")
|
|
53
|
+
timeout = QueryLens.configuration.query_timeout
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
57
|
+
|
|
58
|
+
# Layer 6: Database-level read-only enforcement + timeout
|
|
59
|
+
if postgresql
|
|
60
|
+
connection.execute("BEGIN")
|
|
61
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
62
|
+
connection.execute("SET LOCAL statement_timeout = '#{timeout.to_i * 1000}'")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
result = connection.exec_query(sql)
|
|
66
|
+
columns = result.columns
|
|
67
|
+
rows = result.rows
|
|
68
|
+
|
|
69
|
+
if postgresql
|
|
70
|
+
connection.execute("ROLLBACK")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
74
|
+
|
|
75
|
+
max_rows = QueryLens.configuration.max_rows
|
|
76
|
+
truncated = rows.length > max_rows
|
|
77
|
+
rows = rows.first(max_rows) if truncated
|
|
78
|
+
|
|
79
|
+
audit(action: "execute", sql: sql, row_count: rows.length)
|
|
80
|
+
|
|
81
|
+
render json: {
|
|
82
|
+
columns: columns,
|
|
83
|
+
rows: rows,
|
|
84
|
+
row_count: rows.length,
|
|
85
|
+
truncated: truncated,
|
|
86
|
+
execution_ms: elapsed_ms
|
|
87
|
+
}
|
|
88
|
+
rescue => e
|
|
89
|
+
connection.execute("ROLLBACK") if postgresql rescue nil
|
|
90
|
+
audit(action: "execute_error", sql: sql, error: e.message)
|
|
91
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|