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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c73054ef91ed2d89682d757f0ec545db4f43273b32df46b9a1f57b8f72d92a2
4
- data.tar.gz: 753f12bc25e012af3233b96d0c7326f7df307219aea79740f232d9e7dda41def
3
+ metadata.gz: 9ea5d214eb67f13c153578fce1f001109518b6935409774d9ae343de3ee87258
4
+ data.tar.gz: e7f2c6c7a528ababbef441b2d548cc635222701f5903cbdd1f06b9ecbefd3bc8
5
5
  SHA512:
6
- metadata.gz: 0a0eff39813e9f070ef5df3b2965b0d6c39c4bb34c958efb2511bfec614c40fed59d76406b9b119b78a8f9e9e41914810aab439f2f9ea4555843f785ac787cdf
7
- data.tar.gz: d3ef86f6fd078fa1844b38212acf27def791c8031886c9132f6b476b06db344687d37b1c97930cd514f487dd25b048263f04927c9f19c71e1e4c5013253f9840
6
+ metadata.gz: 5780f01ddfc86f3f998ea7f67b449ff312c3e4e305a6a5c454593a9aec6d691879837171d0567d2db8def6b70503d77203eecf708e4878947463b9f0443ba5eb
7
+ data.tar.gz: 99f44e0e10f2c06cb428936bfeaf8757bef817353d535ef1088761c6df7ea6a546905ce12c076207a5252103ce7df37a487ff56846a7e89d92c9e37e376de813
data/README.md CHANGED
@@ -1,26 +1,76 @@
1
1
  # QueryConsole
2
2
 
3
- A Rails engine that provides a secure, mountable web interface for running read-only SQL queries against your application's database.
3
+ [![Gem Version](https://badge.fury.io/rb/query_console.svg)](https://badge.fury.io/rb/query_console)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg)](https://www.ruby-lang.org)
6
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-red.svg)](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
+ ![Query Console Interface](docs/images/query-execution.png)
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
- ### Core Features (v0.1.0)
8
- - 🔒 **Security First**: Read-only queries enforced at multiple levels
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
- - 💾 **Client-Side History**: Query history stored in browser localStorage
15
- - **Hotwire-Powered**: Uses Turbo Frames and Stimulus for smooth, SPA-like experience
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
- ### New in v0.2.0 🚀
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
- - 💾 **Saved Queries**: Save, organize, import/export your important queries (client-side)
22
- - 🎨 **Tabbed UI**: Switch between History and Schema views seamlessly
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
+ ![Query Execution](docs/images/query-execution.png)
57
+ *Execute SQL queries with real-time results, execution time, and row counts displayed in a clean, scrollable table*
58
+
59
+ ### Schema Explorer
60
+ ![Schema Browser](docs/images/schema-explorer.png)
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
+ ![DML Results Banner](docs/images/dml-results.png)
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
+ ![Query History](docs/images/query-history.png)
69
+ *Access recent queries with timestamps - click any query to load it into the editor instantly*
70
+
71
+ ### Saved Queries Management
72
+ ![Saved Queries](docs/images/saved-queries.png)
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. **SQL Validation**: Only SELECT and WITH (CTE) queries allowed
32
- 4. **Keyword Blocking**: Blocks all write operations (UPDATE, DELETE, INSERT, DROP, etc.)
33
- 5. **Statement Isolation**: Prevents multiple statement execution
34
- 6. **Row Limiting**: Automatic result limiting to prevent resource exhaustion
35
- 7. **Query Timeout**: Configurable timeout to prevent long-running queries
36
- 8. **Audit Trail**: All queries logged with structured data
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
- # v0.2.0+ Features
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
- - `SELECT * FROM users; DELETE FROM users` (multiple statements)
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
- ### 0.1.0 (Initial Release)
394
-
395
- - Basic query console with read-only enforcement
396
- - Environment gating and authorization hooks
397
- - SQL validation and row limiting
398
- - Query timeout protection
399
- - Client-side history with localStorage
400
- - Comprehensive test suite
401
- - Audit logging
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
- render turbo_stream: turbo_stream.replace(
13
- "explain-results",
14
- partial: "explain/results",
15
- locals: { result: @result }
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 textarea and query execution
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 = ["textarea", "runButton", "clearButton", "results"]
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
- // Load query into textarea (from history)
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.textareaTarget.value = sql
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 textarea
25
- clear(event) {
26
- event.preventDefault()
27
- this.textareaTarget.value = ''
28
- this.textareaTarget.focus()
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
- // Handle form submission
32
- submit(event) {
33
- const sql = this.textareaTarget.value.trim()
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
- // After Turbo completes the request, we'll handle success/error
46
- }
47
-
48
- // Called after successful query execution (via Turbo)
49
- querySuccess(event) {
50
- // Re-enable button
51
- this.runButtonTarget.disabled = false
52
- this.runButtonTarget.textContent = 'Run Query'
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
- // Dispatch event to add to history
55
- const sql = this.textareaTarget.value.trim()
56
- this.dispatch('executed', {
57
- detail: {
58
- sql: sql,
59
- timestamp: new Date().toISOString()
60
- },
61
- target: document.querySelector('[data-controller="history"]')
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
- // Called after failed query execution
66
- queryError(event) {
67
- this.runButtonTarget.disabled = false
68
- this.runButtonTarget.textContent = 'Run Query'
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
- // Handle Turbo Frame errors
72
- turboFrameError(event) {
73
- console.error('Turbo Frame error:', event.detail)
74
- this.runButtonTarget.disabled = false
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
- unless @config.allowed_starts_with.any? { |keyword| normalized_start.start_with?(keyword) }
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: #{@config.allowed_starts_with.join(', ').upcase}"
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
- forbidden = @config.forbidden_keywords.find do |keyword|
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
- ValidationResult.new(valid: true, sanitized_sql: sanitized)
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
- <span class="metadata-label">Rows:</span>
140
- <span class="metadata-value"><%= result_to_render.row_count_shown %></span>
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
- No rows returned
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
- /* SQL Editor Textarea */
128
- .sql-editor {
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
- .sql-editor:focus {
141
- outline: none;
142
- border-color: #007bff;
143
- box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
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 (Simple Textarea)
465
+ // Editor Controller (CodeMirror)
419
466
  class EditorController extends Controller {
420
- static targets = ["textarea"]
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.textareaTarget.value
514
+ return this.view.state.doc.toString()
424
515
  }
425
516
 
426
517
  setSql(text) {
427
- this.textareaTarget.value = text
428
- this.textareaTarget.focus()
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 textarea = this.textareaTarget
433
- const start = textarea.selectionStart
434
- const end = textarea.selectionEnd
435
- const before = textarea.value.substring(0, start)
436
- const after = textarea.value.substring(end)
437
-
438
- textarea.value = before + text + after
439
- textarea.selectionStart = textarea.selectionEnd = start + text.length
440
- textarea.focus()
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.textareaTarget.value = ''
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.trim()) {
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="${sql.replace(/"/g, '&quot;')}">
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.trim()) {
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="${sql.replace(/"/g, '&quot;')}">
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><strong>Security:</strong> Read-only SELECT & WITH queries only. All queries are logged.</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
- <textarea
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"]
@@ -1,3 +1,3 @@
1
1
  module QueryConsole
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
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.0
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 that provides a web-based SQL query console with read-only
97
- enforcement, authorization hooks, and audit logging.
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: Mountable Rails engine for secure read-only SQL queries
159
+ summary: Secure, mountable Rails SQL console with read-only enforcement and optional
160
+ DML support
158
161
  test_files: []