query_console 0.2.0 → 0.2.1
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 +4 -4
- data/README.md +190 -33
- data/app/controllers/query_console/explain_controller.rb +5 -5
- data/app/controllers/query_console/queries_controller.rb +2 -1
- data/app/javascript/query_console/controllers/editor_controller.js +182 -45
- data/app/services/query_console/audit_logger.rb +23 -1
- data/app/services/query_console/runner.rb +56 -3
- data/app/services/query_console/sql_limiter.rb +10 -0
- data/app/services/query_console/sql_validator.rb +33 -6
- data/app/views/query_console/queries/_results.html.erb +35 -3
- data/app/views/query_console/queries/new.html.erb +168 -45
- data/lib/query_console/configuration.rb +2 -0
- data/lib/query_console/version.rb +1 -1
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ea5d214eb67f13c153578fce1f001109518b6935409774d9ae343de3ee87258
|
|
4
|
+
data.tar.gz: e7f2c6c7a528ababbef441b2d548cc635222701f5903cbdd1f06b9ecbefd3bc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5780f01ddfc86f3f998ea7f67b449ff312c3e4e305a6a5c454593a9aec6d691879837171d0567d2db8def6b70503d77203eecf708e4878947463b9f0443ba5eb
|
|
7
|
+
data.tar.gz: 99f44e0e10f2c06cb428936bfeaf8757bef817353d535ef1088761c6df7ea6a546905ce12c076207a5252103ce7df37a487ff56846a7e89d92c9e37e376de813
|
data/README.md
CHANGED
|
@@ -1,26 +1,76 @@
|
|
|
1
1
|
# QueryConsole
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/query_console)
|
|
4
|
+
[](MIT-LICENSE)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](https://rubyonrails.org)
|
|
7
|
+
|
|
8
|
+
A Rails engine that provides a secure, mountable web interface for running SQL queries against your application's database. Read-only by default with optional DML support.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
*Modern, responsive SQL query interface with schema explorer, query management, and real-time execution*
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Features](#features)
|
|
16
|
+
- [Screenshots](#screenshots)
|
|
17
|
+
- [Security Features](#security-features)
|
|
18
|
+
- [Installation](#installation)
|
|
19
|
+
- [Configuration](#configuration)
|
|
20
|
+
- [Usage](#usage)
|
|
21
|
+
- [Security Considerations](#security-considerations)
|
|
22
|
+
- [Development](#development)
|
|
23
|
+
- [Troubleshooting](#troubleshooting)
|
|
24
|
+
- [Contributing](#contributing)
|
|
25
|
+
- [Changelog](#changelog)
|
|
4
26
|
|
|
5
27
|
## Features
|
|
6
28
|
|
|
7
|
-
###
|
|
8
|
-
- 🔒 **Security First**: Read-only
|
|
29
|
+
### Security & Control
|
|
30
|
+
- 🔒 **Security First**: Read-only by default with multi-layer enforcement
|
|
9
31
|
- 🚦 **Environment Gating**: Disabled by default in production
|
|
10
32
|
- 🔑 **Flexible Authorization**: Integrate with your existing auth system
|
|
11
|
-
- 📊 **Modern UI**: Clean, responsive interface with query history
|
|
12
|
-
- 📝 **Audit Logging**: All queries logged with actor information
|
|
13
33
|
- ⚡ **Resource Protection**: Configurable row limits and query timeouts
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- 🎨 **Zero Build Step**: CDN-hosted Hotwire, no asset compilation needed
|
|
34
|
+
- 📝 **Comprehensive Audit Logging**: All queries logged with actor information and metadata
|
|
35
|
+
- 🔐 **Optional DML Support**: Enable INSERT/UPDATE/DELETE with confirmation dialogs
|
|
17
36
|
|
|
18
|
-
###
|
|
37
|
+
### Query Execution
|
|
19
38
|
- 📊 **EXPLAIN Query Plans**: Analyze query execution plans for performance debugging
|
|
39
|
+
- ✅ **Smart Validation**: SQL validation with keyword blocking and statement isolation
|
|
40
|
+
- 🎯 **Accurate Results**: Proper row counts for both SELECT and DML operations
|
|
41
|
+
- ⏱️ **Query Timeout**: Configurable timeout to prevent long-running queries
|
|
42
|
+
|
|
43
|
+
### User Interface
|
|
44
|
+
- 📊 **Modern UI**: Clean, responsive interface with real-time updates
|
|
20
45
|
- 🗂️ **Schema Explorer**: Browse tables, columns, types with quick actions
|
|
21
|
-
- 💾 **
|
|
22
|
-
-
|
|
46
|
+
- 💾 **Query Management**: Save, organize, import/export queries (client-side)
|
|
47
|
+
- 📜 **Query History**: Client-side history stored in browser localStorage
|
|
48
|
+
- 🎨 **Tabbed Navigation**: Switch between History, Schema, and Saved Queries seamlessly
|
|
23
49
|
- 🔍 **Quick Actions**: Generate queries from schema, copy names, insert WHERE clauses
|
|
50
|
+
- ⚡ **Hotwire-Powered**: Turbo Frames and Stimulus for smooth, SPA-like experience
|
|
51
|
+
- 🎨 **Zero Build Step**: CDN-hosted dependencies, no asset compilation needed
|
|
52
|
+
|
|
53
|
+
## Screenshots
|
|
54
|
+
|
|
55
|
+
### Query Execution with Results
|
|
56
|
+

|
|
57
|
+
*Execute SQL queries with real-time results, execution time, and row counts displayed in a clean, scrollable table*
|
|
58
|
+
|
|
59
|
+
### Schema Explorer
|
|
60
|
+

|
|
61
|
+
*Browse database tables, columns with data types, nullable status, and quick-action buttons (Insert, WHERE, Copy Table Name)*
|
|
62
|
+
|
|
63
|
+
### DML Operations with Safety Features
|
|
64
|
+

|
|
65
|
+
*DML operations show "Data Modified" banner, accurate "Rows Affected" count, and permanent change confirmation. A browser confirmation dialog appears before execution (not shown - browser native UI).*
|
|
66
|
+
|
|
67
|
+
### Query History
|
|
68
|
+

|
|
69
|
+
*Access recent queries with timestamps - click any query to load it into the editor instantly*
|
|
70
|
+
|
|
71
|
+
### Saved Queries Management
|
|
72
|
+

|
|
73
|
+
*Save important queries with names and tags, then load, export, or import them with one click*
|
|
24
74
|
|
|
25
75
|
## Security Features
|
|
26
76
|
|
|
@@ -28,12 +78,13 @@ QueryConsole implements multiple layers of security:
|
|
|
28
78
|
|
|
29
79
|
1. **Environment Gating**: Only enabled in configured environments (development by default)
|
|
30
80
|
2. **Authorization Hook**: Requires explicit authorization configuration
|
|
31
|
-
3. **
|
|
32
|
-
4. **
|
|
33
|
-
5. **
|
|
34
|
-
6. **
|
|
35
|
-
7. **
|
|
36
|
-
8. **
|
|
81
|
+
3. **Read-Only by Default**: Only SELECT and WITH (CTE) queries allowed by default
|
|
82
|
+
4. **Optional DML with Safeguards**: INSERT/UPDATE/DELETE available when explicitly enabled, with mandatory user confirmation dialogs
|
|
83
|
+
5. **Keyword Blocking**: Always blocks DDL operations (DROP, ALTER, CREATE, TRUNCATE, etc.)
|
|
84
|
+
6. **Statement Isolation**: Prevents multiple statement execution
|
|
85
|
+
7. **Row Limiting**: Automatic result limiting to prevent resource exhaustion
|
|
86
|
+
8. **Query Timeout**: Configurable timeout to prevent long-running queries
|
|
87
|
+
9. **Comprehensive Audit Trail**: All queries logged with actor, query type, and execution metadata
|
|
37
88
|
|
|
38
89
|
## Installation
|
|
39
90
|
|
|
@@ -79,7 +130,7 @@ QueryConsole.configure do |config|
|
|
|
79
130
|
# config.max_rows = 1000
|
|
80
131
|
# config.timeout_ms = 5000
|
|
81
132
|
|
|
82
|
-
#
|
|
133
|
+
# Advanced Features
|
|
83
134
|
# EXPLAIN feature (default: enabled)
|
|
84
135
|
# config.enable_explain = true
|
|
85
136
|
# config.enable_explain_analyze = false # Disabled by default for safety
|
|
@@ -129,6 +180,93 @@ config.authorize = ->(_controller) { true }
|
|
|
129
180
|
| `timeout_ms` | `3000` | Query timeout in milliseconds |
|
|
130
181
|
| `forbidden_keywords` | See code | SQL keywords that are blocked |
|
|
131
182
|
| `allowed_starts_with` | `["select", "with"]` | Allowed query starting keywords |
|
|
183
|
+
| `enable_dml` | `false` | Enable DML queries (INSERT, UPDATE, DELETE) |
|
|
184
|
+
| `enable_explain` | `true` | Enable EXPLAIN query plans |
|
|
185
|
+
| `enable_explain_analyze` | `false` | Enable EXPLAIN ANALYZE (use with caution) |
|
|
186
|
+
| `schema_explorer` | `true` | Enable schema browser |
|
|
187
|
+
| `schema_cache_seconds` | `60` | Schema cache duration in seconds |
|
|
188
|
+
|
|
189
|
+
### DML (Data Manipulation Language) Support
|
|
190
|
+
|
|
191
|
+
By default, Query Console is **read-only**. To enable DML operations (INSERT, UPDATE, DELETE):
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
QueryConsole.configure do |config|
|
|
195
|
+
config.enable_dml = true
|
|
196
|
+
|
|
197
|
+
# Recommended: Restrict to specific environments
|
|
198
|
+
config.enabled_environments = ["development", "staging"]
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Important Security Notes
|
|
203
|
+
|
|
204
|
+
- **DML is disabled by default** for safety
|
|
205
|
+
- When enabled, INSERT, UPDATE, DELETE, and MERGE queries are permitted
|
|
206
|
+
- All DML operations are logged with actor information and query type
|
|
207
|
+
- No transaction support - queries auto-commit immediately
|
|
208
|
+
- Consider additional application-level authorization for production use
|
|
209
|
+
|
|
210
|
+
#### What's Still Blocked
|
|
211
|
+
|
|
212
|
+
Even with DML enabled, these operations remain **forbidden**:
|
|
213
|
+
- `DROP`, `ALTER`, `CREATE` (schema changes)
|
|
214
|
+
- `TRUNCATE` (bulk deletion)
|
|
215
|
+
- `GRANT`, `REVOKE` (permission changes)
|
|
216
|
+
- `EXECUTE`, `EXEC` (stored procedures)
|
|
217
|
+
- `TRANSACTION`, `COMMIT`, `ROLLBACK` (manual transaction control)
|
|
218
|
+
- System procedures (`sp_`, `xp_`)
|
|
219
|
+
|
|
220
|
+
#### UI Behavior with DML
|
|
221
|
+
|
|
222
|
+
When DML is enabled and a DML query is detected:
|
|
223
|
+
- **Before execution**: A confirmation dialog appears with a clear warning about permanent data modifications
|
|
224
|
+
- User must explicitly confirm to proceed (can click "Cancel" to abort)
|
|
225
|
+
- **After execution**: An informational banner shows: "ℹ️ Data Modified: This query has modified the database"
|
|
226
|
+
- **Rows Affected** count is displayed (e.g., "3 row(s) affected") showing how many rows were inserted/updated/deleted
|
|
227
|
+
- The security banner reflects DML status
|
|
228
|
+
- All changes are permanent and logged
|
|
229
|
+
|
|
230
|
+
#### Database Support
|
|
231
|
+
|
|
232
|
+
DML operations work on all supported databases:
|
|
233
|
+
- **SQLite**: INSERT, UPDATE, DELETE
|
|
234
|
+
- **PostgreSQL**: INSERT, UPDATE, DELETE, MERGE (via INSERT ... ON CONFLICT)
|
|
235
|
+
- **MySQL**: INSERT, UPDATE, DELETE, REPLACE
|
|
236
|
+
|
|
237
|
+
#### Enhanced Audit Logging
|
|
238
|
+
|
|
239
|
+
DML queries are logged with additional metadata:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
{
|
|
243
|
+
component: "query_console",
|
|
244
|
+
actor: "user@example.com",
|
|
245
|
+
sql: "UPDATE users SET active = true WHERE id = 123",
|
|
246
|
+
duration_ms: 12.5,
|
|
247
|
+
rows: 1,
|
|
248
|
+
status: "ok",
|
|
249
|
+
query_type: "UPDATE", # NEW: Query type classification
|
|
250
|
+
is_dml: true # NEW: DML flag
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### Example DML Queries
|
|
255
|
+
|
|
256
|
+
```sql
|
|
257
|
+
-- Insert a new record
|
|
258
|
+
INSERT INTO users (name, email) VALUES ('John Doe', 'john@example.com');
|
|
259
|
+
|
|
260
|
+
-- Update existing records
|
|
261
|
+
UPDATE users SET active = true WHERE id = 123;
|
|
262
|
+
|
|
263
|
+
-- Delete specific records
|
|
264
|
+
DELETE FROM sessions WHERE expires_at < NOW();
|
|
265
|
+
|
|
266
|
+
-- PostgreSQL upsert
|
|
267
|
+
INSERT INTO settings (key, value) VALUES ('theme', 'dark')
|
|
268
|
+
ON CONFLICT (key) DO UPDATE SET value = 'dark';
|
|
269
|
+
```
|
|
132
270
|
|
|
133
271
|
## Mounting
|
|
134
272
|
|
|
@@ -166,14 +304,17 @@ Then visit: `http://localhost:3000/query_console`
|
|
|
166
304
|
- `WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users`
|
|
167
305
|
- Queries with JOINs, ORDER BY, GROUP BY, etc.
|
|
168
306
|
|
|
169
|
-
❌ **Blocked
|
|
170
|
-
- `UPDATE users SET name = 'test'`
|
|
171
|
-
- `DELETE FROM users`
|
|
172
|
-
- `INSERT INTO users VALUES (...)`
|
|
173
|
-
- `DROP TABLE users`
|
|
174
|
-
- `
|
|
307
|
+
❌ **Blocked** (by default):
|
|
308
|
+
- `UPDATE users SET name = 'test'` (unless `enable_dml = true`)
|
|
309
|
+
- `DELETE FROM users` (unless `enable_dml = true`)
|
|
310
|
+
- `INSERT INTO users VALUES (...)` (unless `enable_dml = true`)
|
|
311
|
+
- `DROP TABLE users` (always blocked)
|
|
312
|
+
- `TRUNCATE TABLE users` (always blocked)
|
|
313
|
+
- `SELECT * FROM users; DELETE FROM users` (multiple statements always blocked)
|
|
175
314
|
- Any query containing forbidden keywords
|
|
176
315
|
|
|
316
|
+
**Note**: With `config.enable_dml = true`, INSERT, UPDATE, DELETE, and MERGE queries become allowed.
|
|
317
|
+
|
|
177
318
|
## Example Queries
|
|
178
319
|
|
|
179
320
|
```sql
|
|
@@ -390,12 +531,28 @@ Created by [Johnson Gnanasekar](https://github.com/JohnsonGnanasekar)
|
|
|
390
531
|
|
|
391
532
|
## Changelog
|
|
392
533
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
-
|
|
399
|
-
-
|
|
400
|
-
-
|
|
401
|
-
- Audit
|
|
534
|
+
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
|
|
535
|
+
|
|
536
|
+
### Recent Updates
|
|
537
|
+
|
|
538
|
+
#### Latest (DML Support)
|
|
539
|
+
- ✨ **Optional DML Support**: INSERT/UPDATE/DELETE with mandatory confirmation dialogs
|
|
540
|
+
- 🎯 **Accurate Row Counts**: Proper affected rows tracking for DML operations
|
|
541
|
+
- 🔒 **Enhanced Security**: Pre-execution confirmation with detailed warnings
|
|
542
|
+
- 📝 **Enhanced Audit Logging**: Query type classification and DML flags
|
|
543
|
+
- 🗃️ **Multi-Database Support**: SQLite, PostgreSQL, MySQL compatibility
|
|
544
|
+
|
|
545
|
+
#### v0.2.0 (January 2026)
|
|
546
|
+
- 📊 **EXPLAIN Plans**: Query execution plan analysis
|
|
547
|
+
- 🗂️ **Schema Explorer**: Interactive table/column browser with quick actions
|
|
548
|
+
- 💾 **Saved Queries**: Client-side query management with import/export
|
|
549
|
+
- 🎨 **Modern UI**: Tabbed navigation and collapsible sections
|
|
550
|
+
- 🔍 **Quick Actions**: Generate queries from schema explorer
|
|
551
|
+
|
|
552
|
+
#### v0.1.0 (Initial Release)
|
|
553
|
+
- 🔒 Read-only query console with security enforcement
|
|
554
|
+
- 🚦 Environment gating and authorization hooks
|
|
555
|
+
- ✅ SQL validation and row limiting
|
|
556
|
+
- ⏱️ Query timeout protection
|
|
557
|
+
- 📜 Client-side history with localStorage
|
|
558
|
+
- ✅ Comprehensive test suite and audit logging
|
|
@@ -9,11 +9,11 @@ module QueryConsole
|
|
|
9
9
|
@result = ExplainRunner::ExplainResult.new(error: "Query cannot be empty")
|
|
10
10
|
respond_to do |format|
|
|
11
11
|
format.turbo_stream do
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
render turbo_stream: turbo_stream.replace(
|
|
13
|
+
"explain-results",
|
|
14
|
+
partial: "query_console/explain/results",
|
|
15
|
+
locals: { result: @result }
|
|
16
|
+
)
|
|
17
17
|
end
|
|
18
18
|
format.html { render "explain/_results", layout: false, locals: { result: @result } }
|
|
19
19
|
end
|
|
@@ -21,6 +21,7 @@ module QueryConsole
|
|
|
21
21
|
# Execute the query
|
|
22
22
|
runner = Runner.new(sql)
|
|
23
23
|
@result = runner.execute
|
|
24
|
+
@is_dml = @result.dml?
|
|
24
25
|
|
|
25
26
|
# Log the query execution
|
|
26
27
|
AuditLogger.log_query(
|
|
@@ -35,7 +36,7 @@ module QueryConsole
|
|
|
35
36
|
render turbo_stream: turbo_stream.replace(
|
|
36
37
|
"query-results",
|
|
37
38
|
partial: "results",
|
|
38
|
-
locals: { result: @result }
|
|
39
|
+
locals: { result: @result, is_dml: @is_dml }
|
|
39
40
|
)
|
|
40
41
|
end
|
|
41
42
|
format.html { render :_results, layout: false }
|
|
@@ -1,77 +1,214 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { EditorView, keymap } from "@codemirror/view"
|
|
3
|
+
import { EditorState } from "@codemirror/state"
|
|
4
|
+
import { sql } from "@codemirror/lang-sql"
|
|
5
|
+
import { defaultKeymap } from "@codemirror/commands"
|
|
6
|
+
import { autocompletion } from "@codemirror/autocomplete"
|
|
2
7
|
|
|
3
|
-
// Manages the SQL editor
|
|
8
|
+
// Manages the SQL editor with CodeMirror and query execution
|
|
4
9
|
// Usage: <div data-controller="editor">
|
|
5
10
|
export default class extends Controller {
|
|
6
|
-
static targets = ["
|
|
11
|
+
static targets = ["container"]
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
14
|
+
this.initializeCodeMirror()
|
|
15
|
+
|
|
9
16
|
// Listen for history load events
|
|
10
17
|
this.element.addEventListener('history:load', (event) => {
|
|
11
18
|
this.loadQuery(event.detail.sql)
|
|
12
19
|
})
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this.view) {
|
|
24
|
+
this.view.destroy()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
initializeCodeMirror() {
|
|
29
|
+
const sqlLanguage = sql()
|
|
30
|
+
|
|
31
|
+
const startState = EditorState.create({
|
|
32
|
+
doc: "SELECT * FROM users LIMIT 10;",
|
|
33
|
+
extensions: [
|
|
34
|
+
sqlLanguage.extension,
|
|
35
|
+
autocompletion(),
|
|
36
|
+
keymap.of(defaultKeymap),
|
|
37
|
+
EditorView.lineWrapping,
|
|
38
|
+
EditorView.theme({
|
|
39
|
+
"&": {
|
|
40
|
+
fontSize: "14px",
|
|
41
|
+
border: "1px solid #ddd",
|
|
42
|
+
borderRadius: "4px"
|
|
43
|
+
},
|
|
44
|
+
".cm-content": {
|
|
45
|
+
fontFamily: "'Monaco', 'Menlo', 'Courier New', monospace",
|
|
46
|
+
minHeight: "200px",
|
|
47
|
+
padding: "12px"
|
|
48
|
+
},
|
|
49
|
+
".cm-scroller": {
|
|
50
|
+
overflow: "auto"
|
|
51
|
+
},
|
|
52
|
+
"&.cm-focused": {
|
|
53
|
+
outline: "none"
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
this.view = new EditorView({
|
|
60
|
+
state: startState,
|
|
61
|
+
parent: this.containerTarget
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get SQL content from CodeMirror
|
|
66
|
+
getSql() {
|
|
67
|
+
return this.view.state.doc.toString()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set SQL content in CodeMirror
|
|
71
|
+
setSql(text) {
|
|
72
|
+
this.view.dispatch({
|
|
73
|
+
changes: {
|
|
74
|
+
from: 0,
|
|
75
|
+
to: this.view.state.doc.length,
|
|
76
|
+
insert: text
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
this.view.focus()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Insert text at cursor position
|
|
83
|
+
insertAtCursor(text) {
|
|
84
|
+
const selection = this.view.state.selection.main
|
|
85
|
+
this.view.dispatch({
|
|
86
|
+
changes: {
|
|
87
|
+
from: selection.from,
|
|
88
|
+
to: selection.to,
|
|
89
|
+
insert: text
|
|
90
|
+
},
|
|
91
|
+
selection: {
|
|
92
|
+
anchor: selection.from + text.length
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
this.view.focus()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Load query into editor (from history)
|
|
16
99
|
loadQuery(sql) {
|
|
17
|
-
this.
|
|
18
|
-
this.textareaTarget.focus()
|
|
100
|
+
this.setSql(sql)
|
|
19
101
|
|
|
20
102
|
// Scroll to editor
|
|
21
103
|
this.element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
22
104
|
}
|
|
23
105
|
|
|
24
|
-
// Clear
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
106
|
+
// Clear editor
|
|
107
|
+
clearEditor() {
|
|
108
|
+
this.setSql('')
|
|
109
|
+
|
|
110
|
+
// Clear query results
|
|
111
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
112
|
+
if (queryFrame) {
|
|
113
|
+
queryFrame.innerHTML = '<div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;"><p>Enter a query above and click "Run Query" to see results here.</p></div>'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Clear explain results
|
|
117
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
118
|
+
if (explainFrame) {
|
|
119
|
+
explainFrame.innerHTML = ''
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check if query is a DML operation
|
|
124
|
+
isDmlQuery(sql) {
|
|
125
|
+
const trimmed = sql.trim().toLowerCase()
|
|
126
|
+
return /^(insert|update|delete|merge)\b/.test(trimmed)
|
|
29
127
|
}
|
|
30
128
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
const sql = this.
|
|
34
|
-
|
|
129
|
+
// Run query
|
|
130
|
+
runQuery() {
|
|
131
|
+
const sql = this.getSql().trim()
|
|
35
132
|
if (!sql) {
|
|
36
|
-
event.preventDefault()
|
|
37
133
|
alert('Please enter a SQL query')
|
|
38
134
|
return
|
|
39
135
|
}
|
|
40
|
-
|
|
41
|
-
// Show loading state
|
|
42
|
-
this.runButtonTarget.disabled = true
|
|
43
|
-
this.runButtonTarget.textContent = 'Running...'
|
|
44
136
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
137
|
+
// Check if it's a DML query and confirm with user
|
|
138
|
+
if (this.isDmlQuery(sql)) {
|
|
139
|
+
const confirmed = confirm(
|
|
140
|
+
'⚠️ DATA MODIFICATION WARNING\n\n' +
|
|
141
|
+
'This query will INSERT, UPDATE, or DELETE data.\n\n' +
|
|
142
|
+
'• All changes are PERMANENT and cannot be undone\n' +
|
|
143
|
+
'• All operations are logged\n\n' +
|
|
144
|
+
'Do you want to proceed?'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if (!confirmed) {
|
|
148
|
+
return // User cancelled
|
|
149
|
+
}
|
|
150
|
+
}
|
|
53
151
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
152
|
+
// Clear explain results when running query
|
|
153
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
154
|
+
if (explainFrame) {
|
|
155
|
+
explainFrame.innerHTML = ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Store for history
|
|
159
|
+
window._lastExecutedSQL = sql
|
|
160
|
+
|
|
161
|
+
// Get CSRF token
|
|
162
|
+
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content
|
|
163
|
+
|
|
164
|
+
// Create form with Turbo Frame target
|
|
165
|
+
const form = document.createElement('form')
|
|
166
|
+
form.method = 'POST'
|
|
167
|
+
form.action = this.element.dataset.runPath
|
|
168
|
+
form.setAttribute('data-turbo-frame', 'query-results')
|
|
169
|
+
form.innerHTML = `
|
|
170
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
171
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
172
|
+
`
|
|
173
|
+
document.body.appendChild(form)
|
|
174
|
+
form.requestSubmit()
|
|
175
|
+
document.body.removeChild(form)
|
|
63
176
|
}
|
|
64
177
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
178
|
+
// Explain query
|
|
179
|
+
explainQuery() {
|
|
180
|
+
const sql = this.getSql().trim()
|
|
181
|
+
if (!sql) {
|
|
182
|
+
alert('Please enter a SQL query')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Clear query results when running explain
|
|
187
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
188
|
+
if (queryFrame) {
|
|
189
|
+
queryFrame.innerHTML = ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Get CSRF token
|
|
193
|
+
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content
|
|
194
|
+
|
|
195
|
+
// Create form with Turbo Frame target
|
|
196
|
+
const form = document.createElement('form')
|
|
197
|
+
form.method = 'POST'
|
|
198
|
+
form.action = this.element.dataset.explainPath
|
|
199
|
+
form.setAttribute('data-turbo-frame', 'explain-results')
|
|
200
|
+
form.innerHTML = `
|
|
201
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
202
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
203
|
+
`
|
|
204
|
+
document.body.appendChild(form)
|
|
205
|
+
form.requestSubmit()
|
|
206
|
+
document.body.removeChild(form)
|
|
69
207
|
}
|
|
70
208
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.runButtonTarget.textContent = 'Run Query'
|
|
209
|
+
escapeHtml(text) {
|
|
210
|
+
const div = document.createElement('div')
|
|
211
|
+
div.textContent = text
|
|
212
|
+
return div.innerHTML
|
|
76
213
|
}
|
|
77
214
|
}
|
|
@@ -15,7 +15,9 @@ module QueryConsole
|
|
|
15
15
|
actor: resolved_actor,
|
|
16
16
|
sql: sql.to_s.strip,
|
|
17
17
|
duration_ms: result.execution_time_ms,
|
|
18
|
-
status: result.success? ? "ok" : "error"
|
|
18
|
+
status: result.success? ? "ok" : "error",
|
|
19
|
+
query_type: determine_query_type(sql),
|
|
20
|
+
is_dml: is_dml_query?(sql)
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
# Add row count if available (for QueryResult)
|
|
@@ -36,6 +38,22 @@ module QueryConsole
|
|
|
36
38
|
Rails.logger.info(log_data.to_json)
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
def self.is_dml_query?(sql)
|
|
42
|
+
sql.to_s.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.determine_query_type(sql)
|
|
46
|
+
case sql.to_s.strip.downcase
|
|
47
|
+
when /\Aselect\b/ then "SELECT"
|
|
48
|
+
when /\Awith\b/ then "WITH"
|
|
49
|
+
when /\Ainsert\b/ then "INSERT"
|
|
50
|
+
when /\Aupdate\b/ then "UPDATE"
|
|
51
|
+
when /\Adelete\b/ then "DELETE"
|
|
52
|
+
when /\Amerge\b/ then "MERGE"
|
|
53
|
+
else "UNKNOWN"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
39
57
|
def self.determine_error_class(error_message)
|
|
40
58
|
case error_message
|
|
41
59
|
when /timeout/i
|
|
@@ -46,6 +64,10 @@ module QueryConsole
|
|
|
46
64
|
"SecurityError"
|
|
47
65
|
when /must start with/i
|
|
48
66
|
"ValidationError"
|
|
67
|
+
when /cannot delete/i, /cannot update/i, /cannot insert/i
|
|
68
|
+
"DMLError"
|
|
69
|
+
when /foreign key constraint/i, /constraint.*violated/i
|
|
70
|
+
"ConstraintError"
|
|
49
71
|
else
|
|
50
72
|
"QueryError"
|
|
51
73
|
end
|
|
@@ -3,15 +3,17 @@ require 'timeout'
|
|
|
3
3
|
module QueryConsole
|
|
4
4
|
class Runner
|
|
5
5
|
class QueryResult
|
|
6
|
-
attr_reader :columns, :rows, :execution_time_ms, :row_count_shown, :truncated, :error
|
|
6
|
+
attr_reader :columns, :rows, :execution_time_ms, :row_count_shown, :truncated, :error, :is_dml, :rows_affected
|
|
7
7
|
|
|
8
|
-
def initialize(columns: [], rows: [], execution_time_ms: 0, row_count_shown: 0, truncated: false, error: nil)
|
|
8
|
+
def initialize(columns: [], rows: [], execution_time_ms: 0, row_count_shown: 0, truncated: false, error: nil, is_dml: false, rows_affected: nil)
|
|
9
9
|
@columns = columns
|
|
10
10
|
@rows = rows
|
|
11
11
|
@execution_time_ms = execution_time_ms
|
|
12
12
|
@row_count_shown = row_count_shown
|
|
13
13
|
@truncated = truncated
|
|
14
14
|
@error = error
|
|
15
|
+
@is_dml = is_dml
|
|
16
|
+
@rows_affected = rows_affected
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def success?
|
|
@@ -25,6 +27,10 @@ module QueryConsole
|
|
|
25
27
|
def truncated?
|
|
26
28
|
@truncated
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
def dml?
|
|
32
|
+
@is_dml
|
|
33
|
+
end
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def initialize(sql, config = QueryConsole.configuration)
|
|
@@ -44,6 +50,7 @@ module QueryConsole
|
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
sanitized_sql = validation_result.sanitized_sql
|
|
53
|
+
is_dml = validation_result.dml?
|
|
47
54
|
|
|
48
55
|
# Step 2: Apply row limit
|
|
49
56
|
limiter = SqlLimiter.new(sanitized_sql, @config.max_rows, @config)
|
|
@@ -56,12 +63,20 @@ module QueryConsole
|
|
|
56
63
|
result = execute_with_timeout(final_sql)
|
|
57
64
|
execution_time = ((Time.now - start_time) * 1000).round(2)
|
|
58
65
|
|
|
66
|
+
# For DML queries, capture the number of affected rows
|
|
67
|
+
rows_affected = nil
|
|
68
|
+
if is_dml
|
|
69
|
+
rows_affected = get_affected_rows_count(result)
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
QueryResult.new(
|
|
60
73
|
columns: result.columns,
|
|
61
74
|
rows: result.rows,
|
|
62
75
|
execution_time_ms: execution_time,
|
|
63
76
|
row_count_shown: result.rows.length,
|
|
64
|
-
truncated: truncated
|
|
77
|
+
truncated: truncated,
|
|
78
|
+
is_dml: is_dml,
|
|
79
|
+
rows_affected: rows_affected
|
|
65
80
|
)
|
|
66
81
|
rescue Timeout::Error
|
|
67
82
|
QueryResult.new(
|
|
@@ -85,5 +100,43 @@ module QueryConsole
|
|
|
85
100
|
ActiveRecord::Base.connection.exec_query(sql)
|
|
86
101
|
end
|
|
87
102
|
end
|
|
103
|
+
|
|
104
|
+
# Get the number of rows affected by a DML query
|
|
105
|
+
# This is database-specific, so we try different approaches
|
|
106
|
+
def get_affected_rows_count(result)
|
|
107
|
+
conn = ActiveRecord::Base.connection
|
|
108
|
+
|
|
109
|
+
# For SQLite, use the raw connection's changes method
|
|
110
|
+
if conn.adapter_name.downcase.include?('sqlite')
|
|
111
|
+
return conn.raw_connection.changes
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# For PostgreSQL, MySQL, and others, check if result has rows_affected
|
|
115
|
+
# Note: exec_query doesn't always provide this, but we can try
|
|
116
|
+
if result.respond_to?(:rows_affected)
|
|
117
|
+
return result.rows_affected
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Fallback: try to get it from the connection's last result
|
|
121
|
+
begin
|
|
122
|
+
if conn.respond_to?(:raw_connection)
|
|
123
|
+
raw_conn = conn.raw_connection
|
|
124
|
+
|
|
125
|
+
# PostgreSQL
|
|
126
|
+
if raw_conn.respond_to?(:cmd_tuples)
|
|
127
|
+
return raw_conn.cmd_tuples
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# MySQL
|
|
131
|
+
if raw_conn.respond_to?(:affected_rows)
|
|
132
|
+
return raw_conn.affected_rows
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue
|
|
136
|
+
# If we can't determine affected rows, return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
88
141
|
end
|
|
89
142
|
end
|
|
@@ -20,6 +20,11 @@ module QueryConsole
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def apply_limit
|
|
23
|
+
# Skip limiting for DML queries (INSERT, UPDATE, DELETE, MERGE)
|
|
24
|
+
if is_dml_query?
|
|
25
|
+
return LimitResult.new(sql: @sql, truncated: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
# Check if query already has a LIMIT clause
|
|
24
29
|
if sql_has_limit?
|
|
25
30
|
LimitResult.new(sql: @sql, truncated: false)
|
|
@@ -33,6 +38,11 @@ module QueryConsole
|
|
|
33
38
|
|
|
34
39
|
attr_reader :sql, :max_rows, :config
|
|
35
40
|
|
|
41
|
+
def is_dml_query?
|
|
42
|
+
# Check if query is a DML operation (INSERT, UPDATE, DELETE, MERGE)
|
|
43
|
+
@sql.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
def sql_has_limit?
|
|
37
47
|
# Check for LIMIT clause (case-insensitive)
|
|
38
48
|
# Match: "LIMIT", " LIMIT ", etc.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
module QueryConsole
|
|
2
2
|
class SqlValidator
|
|
3
3
|
class ValidationResult
|
|
4
|
-
attr_reader :valid, :error, :sanitized_sql
|
|
4
|
+
attr_reader :valid, :error, :sanitized_sql, :is_dml
|
|
5
5
|
|
|
6
|
-
def initialize(valid:, sanitized_sql: nil, error: nil)
|
|
6
|
+
def initialize(valid:, sanitized_sql: nil, error: nil, is_dml: false)
|
|
7
7
|
@valid = valid
|
|
8
8
|
@sanitized_sql = sanitized_sql
|
|
9
9
|
@error = error
|
|
10
|
+
@is_dml = is_dml
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def valid?
|
|
@@ -16,6 +17,10 @@ module QueryConsole
|
|
|
16
17
|
def invalid?
|
|
17
18
|
!@valid
|
|
18
19
|
end
|
|
20
|
+
|
|
21
|
+
def dml?
|
|
22
|
+
@is_dml
|
|
23
|
+
end
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
def initialize(sql, config = QueryConsole.configuration)
|
|
@@ -41,16 +46,35 @@ module QueryConsole
|
|
|
41
46
|
|
|
42
47
|
# Check if query starts with allowed keywords
|
|
43
48
|
normalized_start = sanitized.downcase
|
|
44
|
-
|
|
49
|
+
|
|
50
|
+
# Define DML-specific keywords that are conditionally allowed
|
|
51
|
+
dml_keywords = %w[insert update delete merge]
|
|
52
|
+
|
|
53
|
+
# Expand allowed_starts_with if DML is enabled
|
|
54
|
+
effective_allowed = if @config.enable_dml
|
|
55
|
+
@config.allowed_starts_with + dml_keywords
|
|
56
|
+
else
|
|
57
|
+
@config.allowed_starts_with
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless effective_allowed.any? { |keyword| normalized_start.start_with?(keyword) }
|
|
45
61
|
return ValidationResult.new(
|
|
46
62
|
valid: false,
|
|
47
|
-
error: "Query must start with one of: #{
|
|
63
|
+
error: "Query must start with one of: #{effective_allowed.join(', ').upcase}"
|
|
48
64
|
)
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
# Check for forbidden keywords
|
|
52
68
|
normalized_query = sanitized.downcase
|
|
53
|
-
|
|
69
|
+
|
|
70
|
+
# Filter forbidden keywords based on DML enablement
|
|
71
|
+
effective_forbidden = if @config.enable_dml
|
|
72
|
+
@config.forbidden_keywords.reject { |kw| dml_keywords.include?(kw) || kw == 'replace' || kw == 'into' }
|
|
73
|
+
else
|
|
74
|
+
@config.forbidden_keywords
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
forbidden = effective_forbidden.find do |keyword|
|
|
54
78
|
# Match whole words to avoid false positives (e.g., "updates" table name)
|
|
55
79
|
normalized_query.match?(/\b#{Regexp.escape(keyword.downcase)}\b/)
|
|
56
80
|
end
|
|
@@ -62,7 +86,10 @@ module QueryConsole
|
|
|
62
86
|
)
|
|
63
87
|
end
|
|
64
88
|
|
|
65
|
-
|
|
89
|
+
# Detect if this is a DML query
|
|
90
|
+
is_dml_query = sanitized.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
91
|
+
|
|
92
|
+
ValidationResult.new(valid: true, sanitized_sql: sanitized, is_dml: is_dml_query)
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
private
|
|
@@ -116,10 +116,33 @@
|
|
|
116
116
|
color: #999;
|
|
117
117
|
font-style: italic;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
.dml-warning {
|
|
121
|
+
background-color: #fff3cd;
|
|
122
|
+
border-left: 4px solid #ffc107;
|
|
123
|
+
padding: 12px 16px;
|
|
124
|
+
margin-bottom: 16px;
|
|
125
|
+
border-radius: 4px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dml-warning-icon {
|
|
129
|
+
color: #ff6b6b;
|
|
130
|
+
font-weight: bold;
|
|
131
|
+
margin-right: 8px;
|
|
132
|
+
}
|
|
119
133
|
</style>
|
|
120
134
|
|
|
121
135
|
<div class="results-container">
|
|
122
136
|
<% result_to_render = local_assigns[:result] || @result %>
|
|
137
|
+
<% is_dml_result = local_assigns[:is_dml] || @is_dml %>
|
|
138
|
+
|
|
139
|
+
<% if is_dml_result %>
|
|
140
|
+
<div class="dml-warning">
|
|
141
|
+
<span class="dml-warning-icon">ℹ️</span>
|
|
142
|
+
<strong>Data Modified:</strong>
|
|
143
|
+
This query has modified the database. All changes are logged.
|
|
144
|
+
</div>
|
|
145
|
+
<% end %>
|
|
123
146
|
|
|
124
147
|
<% if result_to_render.error %>
|
|
125
148
|
<div class="error-message">
|
|
@@ -136,8 +159,13 @@
|
|
|
136
159
|
<span class="metadata-value"><%= result_to_render.execution_time_ms %>ms</span>
|
|
137
160
|
</div>
|
|
138
161
|
<div class="metadata-item">
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
<% if is_dml_result && result_to_render.rows_affected %>
|
|
163
|
+
<span class="metadata-label">Rows Affected:</span>
|
|
164
|
+
<span class="metadata-value"><%= result_to_render.rows_affected %></span>
|
|
165
|
+
<% else %>
|
|
166
|
+
<span class="metadata-label">Rows:</span>
|
|
167
|
+
<span class="metadata-value"><%= result_to_render.row_count_shown %></span>
|
|
168
|
+
<% end %>
|
|
141
169
|
</div>
|
|
142
170
|
<% if result_to_render.truncated %>
|
|
143
171
|
<div class="metadata-item">
|
|
@@ -148,7 +176,11 @@
|
|
|
148
176
|
|
|
149
177
|
<% if result_to_render.rows.empty? %>
|
|
150
178
|
<div class="empty-results">
|
|
151
|
-
|
|
179
|
+
<% if is_dml_result && result_to_render.rows_affected %>
|
|
180
|
+
<%= result_to_render.rows_affected %> row(s) affected
|
|
181
|
+
<% else %>
|
|
182
|
+
No rows returned
|
|
183
|
+
<% end %>
|
|
152
184
|
</div>
|
|
153
185
|
<% else %>
|
|
154
186
|
<div class="table-wrapper">
|
|
@@ -124,23 +124,66 @@
|
|
|
124
124
|
background: #545b62;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/*
|
|
128
|
-
.
|
|
127
|
+
/* DML Warning Styles */
|
|
128
|
+
.btn-dml {
|
|
129
|
+
background-color: #ff6b6b;
|
|
130
|
+
color: white;
|
|
131
|
+
border: none;
|
|
132
|
+
padding: 8px 16px;
|
|
133
|
+
border-radius: 4px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
font-size: 14px;
|
|
136
|
+
font-weight: 500;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.btn-dml:hover {
|
|
140
|
+
background-color: #ff5252;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.dml-warning {
|
|
144
|
+
background-color: #fff3cd;
|
|
145
|
+
border-left: 4px solid #ffc107;
|
|
146
|
+
padding: 12px 16px;
|
|
147
|
+
margin-bottom: 16px;
|
|
148
|
+
margin-top: 16px;
|
|
149
|
+
border-radius: 4px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.dml-warning-icon {
|
|
153
|
+
color: #ff6b6b;
|
|
154
|
+
font-weight: bold;
|
|
155
|
+
margin-right: 8px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* SQL Editor (CodeMirror) */
|
|
159
|
+
.sql-editor-container {
|
|
129
160
|
width: 100%;
|
|
130
161
|
min-height: 200px;
|
|
131
|
-
padding: 12px;
|
|
132
|
-
border: 1px solid #ddd;
|
|
133
162
|
border-radius: 4px;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.sql-editor-container:focus-within {
|
|
167
|
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.cm-editor {
|
|
171
|
+
height: 100%;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.cm-scroller {
|
|
175
|
+
min-height: 200px;
|
|
134
176
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
135
177
|
font-size: 14px;
|
|
136
178
|
line-height: 1.5;
|
|
137
|
-
resize: vertical;
|
|
138
179
|
}
|
|
139
180
|
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
181
|
+
.cm-content {
|
|
182
|
+
padding: 12px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.cm-focused {
|
|
186
|
+
outline: none !important;
|
|
144
187
|
}
|
|
145
188
|
|
|
146
189
|
|
|
@@ -351,7 +394,9 @@
|
|
|
351
394
|
{
|
|
352
395
|
"imports": {
|
|
353
396
|
"@hotwired/turbo-rails": "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.min.js",
|
|
354
|
-
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js"
|
|
397
|
+
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js",
|
|
398
|
+
"codemirror": "https://esm.sh/codemirror@6.0.1",
|
|
399
|
+
"@codemirror/lang-sql": "https://esm.sh/@codemirror/lang-sql@6.6.0"
|
|
355
400
|
}
|
|
356
401
|
}
|
|
357
402
|
</script>
|
|
@@ -359,6 +404,8 @@
|
|
|
359
404
|
<script type="module">
|
|
360
405
|
import * as Turbo from "@hotwired/turbo-rails"
|
|
361
406
|
import { Application, Controller } from "@hotwired/stimulus"
|
|
407
|
+
import { EditorView, basicSetup } from "codemirror"
|
|
408
|
+
import { sql } from "@codemirror/lang-sql"
|
|
362
409
|
|
|
363
410
|
const application = Application.start()
|
|
364
411
|
|
|
@@ -415,34 +462,86 @@
|
|
|
415
462
|
}
|
|
416
463
|
application.register("tabs", TabsController)
|
|
417
464
|
|
|
418
|
-
// Editor Controller (
|
|
465
|
+
// Editor Controller (CodeMirror)
|
|
419
466
|
class EditorController extends Controller {
|
|
420
|
-
static targets = ["
|
|
467
|
+
static targets = ["container"]
|
|
468
|
+
|
|
469
|
+
connect() {
|
|
470
|
+
this.initializeCodeMirror()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
disconnect() {
|
|
474
|
+
if (this.view) {
|
|
475
|
+
this.view.destroy()
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
initializeCodeMirror() {
|
|
480
|
+
try {
|
|
481
|
+
this.view = new EditorView({
|
|
482
|
+
doc: "SELECT * FROM users LIMIT 10;",
|
|
483
|
+
extensions: [
|
|
484
|
+
basicSetup,
|
|
485
|
+
sql(),
|
|
486
|
+
EditorView.lineWrapping,
|
|
487
|
+
EditorView.theme({
|
|
488
|
+
"&": {
|
|
489
|
+
fontSize: "14px"
|
|
490
|
+
},
|
|
491
|
+
".cm-content": {
|
|
492
|
+
fontFamily: "'Monaco', 'Menlo', 'Courier New', monospace",
|
|
493
|
+
minHeight: "200px",
|
|
494
|
+
padding: "12px"
|
|
495
|
+
},
|
|
496
|
+
".cm-scroller": {
|
|
497
|
+
overflow: "auto"
|
|
498
|
+
},
|
|
499
|
+
"&.cm-focused": {
|
|
500
|
+
outline: "none"
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
],
|
|
504
|
+
parent: this.containerTarget
|
|
505
|
+
})
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.error('CodeMirror initialization error:', error)
|
|
508
|
+
// Fallback to simple textarea
|
|
509
|
+
this.containerTarget.innerHTML = '<textarea class="sql-editor" style="width:100%; min-height:200px; font-family:monospace; padding:12px;">SELECT * FROM users LIMIT 10;</textarea>'
|
|
510
|
+
}
|
|
511
|
+
}
|
|
421
512
|
|
|
422
513
|
getSql() {
|
|
423
|
-
return this.
|
|
514
|
+
return this.view.state.doc.toString()
|
|
424
515
|
}
|
|
425
516
|
|
|
426
517
|
setSql(text) {
|
|
427
|
-
this.
|
|
428
|
-
|
|
518
|
+
this.view.dispatch({
|
|
519
|
+
changes: {
|
|
520
|
+
from: 0,
|
|
521
|
+
to: this.view.state.doc.length,
|
|
522
|
+
insert: text
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
this.view.focus()
|
|
429
526
|
}
|
|
430
527
|
|
|
431
528
|
insertAtCursor(text) {
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
529
|
+
const selection = this.view.state.selection.main
|
|
530
|
+
this.view.dispatch({
|
|
531
|
+
changes: {
|
|
532
|
+
from: selection.from,
|
|
533
|
+
to: selection.to,
|
|
534
|
+
insert: text
|
|
535
|
+
},
|
|
536
|
+
selection: {
|
|
537
|
+
anchor: selection.from + text.length
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
this.view.focus()
|
|
441
541
|
}
|
|
442
542
|
|
|
443
543
|
clearEditor() {
|
|
444
|
-
this.
|
|
445
|
-
this.textareaTarget.focus()
|
|
544
|
+
this.setSql('')
|
|
446
545
|
|
|
447
546
|
// Clear query results
|
|
448
547
|
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
@@ -457,13 +556,33 @@
|
|
|
457
556
|
}
|
|
458
557
|
}
|
|
459
558
|
|
|
559
|
+
isDmlQuery(sql) {
|
|
560
|
+
const trimmed = sql.trim().toLowerCase()
|
|
561
|
+
return /^(insert|update|delete|merge)\b/.test(trimmed)
|
|
562
|
+
}
|
|
563
|
+
|
|
460
564
|
runQuery() {
|
|
461
|
-
const sql = this.getSql()
|
|
462
|
-
if (!sql
|
|
565
|
+
const sql = this.getSql().trim()
|
|
566
|
+
if (!sql) {
|
|
463
567
|
alert('Please enter a SQL query')
|
|
464
568
|
return
|
|
465
569
|
}
|
|
466
570
|
|
|
571
|
+
// Check if it's a DML query and confirm with user
|
|
572
|
+
if (this.isDmlQuery(sql)) {
|
|
573
|
+
const confirmed = confirm(
|
|
574
|
+
'⚠️ DATA MODIFICATION WARNING\n\n' +
|
|
575
|
+
'This query will INSERT, UPDATE, or DELETE data.\n\n' +
|
|
576
|
+
'• All changes are PERMANENT and cannot be undone\n' +
|
|
577
|
+
'• All operations are logged\n\n' +
|
|
578
|
+
'Do you want to proceed?'
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if (!confirmed) {
|
|
582
|
+
return // User cancelled
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
467
586
|
// Clear explain results when running query
|
|
468
587
|
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
469
588
|
if (explainFrame) {
|
|
@@ -479,7 +598,7 @@
|
|
|
479
598
|
form.action = '<%= query_console.run_path %>'
|
|
480
599
|
form.setAttribute('data-turbo-frame', 'query-results')
|
|
481
600
|
form.innerHTML = `
|
|
482
|
-
<input type="hidden" name="sql" value="${
|
|
601
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
483
602
|
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
484
603
|
`
|
|
485
604
|
document.body.appendChild(form)
|
|
@@ -488,8 +607,8 @@
|
|
|
488
607
|
}
|
|
489
608
|
|
|
490
609
|
explainQuery() {
|
|
491
|
-
const sql = this.getSql()
|
|
492
|
-
if (!sql
|
|
610
|
+
const sql = this.getSql().trim()
|
|
611
|
+
if (!sql) {
|
|
493
612
|
alert('Please enter a SQL query')
|
|
494
613
|
return
|
|
495
614
|
}
|
|
@@ -506,13 +625,19 @@
|
|
|
506
625
|
form.action = '<%= query_console.explain_path %>'
|
|
507
626
|
form.setAttribute('data-turbo-frame', 'explain-results')
|
|
508
627
|
form.innerHTML = `
|
|
509
|
-
<input type="hidden" name="sql" value="${
|
|
628
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
510
629
|
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
511
630
|
`
|
|
512
631
|
document.body.appendChild(form)
|
|
513
632
|
form.requestSubmit()
|
|
514
633
|
document.body.removeChild(form)
|
|
515
634
|
}
|
|
635
|
+
|
|
636
|
+
escapeHtml(text) {
|
|
637
|
+
const div = document.createElement('div')
|
|
638
|
+
div.textContent = text
|
|
639
|
+
return div.innerHTML
|
|
640
|
+
}
|
|
516
641
|
}
|
|
517
642
|
application.register("editor", EditorController)
|
|
518
643
|
|
|
@@ -834,9 +959,16 @@
|
|
|
834
959
|
<div class="container">
|
|
835
960
|
<!-- Banner -->
|
|
836
961
|
<div class="banner" data-controller="collapsible" data-collapsible-key-value="banner">
|
|
837
|
-
<h2>🔍 Read-Only SQL Query Console <small>v0.2.0</small></h2>
|
|
962
|
+
<h2>🔍 <% if QueryConsole.configuration.enable_dml %>SQL Query Console<% else %>Read-Only SQL Query Console<% end %> <small>v0.2.0</small></h2>
|
|
838
963
|
<div class="banner-content">
|
|
839
|
-
<p
|
|
964
|
+
<p>
|
|
965
|
+
<strong>Security:</strong>
|
|
966
|
+
<% if QueryConsole.configuration.enable_dml %>
|
|
967
|
+
DML queries (INSERT, UPDATE, DELETE) are enabled. All queries are logged. Use with caution.
|
|
968
|
+
<% else %>
|
|
969
|
+
Read-only SELECT & WITH queries only. All queries are logged.
|
|
970
|
+
<% end %>
|
|
971
|
+
</p>
|
|
840
972
|
<p><strong>New in v0.2.0:</strong> EXPLAIN query plans, Interactive Schema Explorer with quick insert buttons, Saved Queries with tags, import/export, and localStorage-based Query History!</p>
|
|
841
973
|
</div>
|
|
842
974
|
<button class="section-toggle" data-action="click->collapsible#toggle" type="button">▼</button>
|
|
@@ -856,17 +988,8 @@
|
|
|
856
988
|
</div>
|
|
857
989
|
|
|
858
990
|
<div class="editor-content" data-collapsible-target="content">
|
|
859
|
-
<!-- SQL Editor -->
|
|
860
|
-
<
|
|
861
|
-
data-editor-target="textarea"
|
|
862
|
-
class="sql-editor"
|
|
863
|
-
placeholder="Enter your SELECT or WITH query here...
|
|
864
|
-
|
|
865
|
-
Examples:
|
|
866
|
-
SELECT * FROM users LIMIT 10;
|
|
867
|
-
SELECT id, name, email FROM users WHERE active = true;
|
|
868
|
-
|
|
869
|
-
Use the Schema Explorer to discover tables and columns!">SELECT * FROM users LIMIT 10;</textarea>
|
|
991
|
+
<!-- SQL Editor (CodeMirror) -->
|
|
992
|
+
<div data-editor-target="container" class="sql-editor-container"></div>
|
|
870
993
|
|
|
871
994
|
<!-- Results Area -->
|
|
872
995
|
<%= turbo_frame_tag "query-results" do %>
|
|
@@ -9,6 +9,7 @@ module QueryConsole
|
|
|
9
9
|
:allowed_starts_with,
|
|
10
10
|
:enable_explain,
|
|
11
11
|
:enable_explain_analyze,
|
|
12
|
+
:enable_dml,
|
|
12
13
|
:schema_explorer,
|
|
13
14
|
:schema_cache_seconds,
|
|
14
15
|
:schema_table_denylist,
|
|
@@ -32,6 +33,7 @@ module QueryConsole
|
|
|
32
33
|
# v0.2.0 additions
|
|
33
34
|
@enable_explain = true
|
|
34
35
|
@enable_explain_analyze = false # ANALYZE can be expensive, disabled by default
|
|
36
|
+
@enable_dml = false # DML queries disabled by default for safety
|
|
35
37
|
@schema_explorer = true
|
|
36
38
|
@schema_cache_seconds = 60
|
|
37
39
|
@schema_table_denylist = ["schema_migrations", "ar_internal_metadata"]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: query_console
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Johnson Gnanasekar
|
|
@@ -93,8 +93,10 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '2.0'
|
|
96
|
-
description: A Rails engine
|
|
97
|
-
|
|
96
|
+
description: 'A Rails engine providing a web-based SQL query console with security-first
|
|
97
|
+
design: read-only by default, optional DML (INSERT/UPDATE/DELETE) with confirmation
|
|
98
|
+
dialogs, flexible authorization, comprehensive audit logging, and query execution
|
|
99
|
+
plans.'
|
|
98
100
|
email:
|
|
99
101
|
- johnson@example.com
|
|
100
102
|
executables: []
|
|
@@ -154,5 +156,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
154
156
|
requirements: []
|
|
155
157
|
rubygems_version: 3.6.7
|
|
156
158
|
specification_version: 4
|
|
157
|
-
summary:
|
|
159
|
+
summary: Secure, mountable Rails SQL console with read-only enforcement and optional
|
|
160
|
+
DML support
|
|
158
161
|
test_files: []
|