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 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,10 @@
1
+ require "bundler/setup"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.pattern = "test/**/*_test.rb"
7
+ t.verbose = false
8
+ end
9
+
10
+ task default: :test
@@ -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