query_console 0.2.0 → 0.2.6
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 -7
- data/app/controllers/query_console/queries_controller.rb +2 -3
- 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/explain_runner.rb +47 -0
- data/app/services/query_console/runner.rb +103 -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 +172 -49
- data/lib/query_console/configuration.rb +4 -0
- data/lib/query_console/engine.rb +1 -1
- data/lib/query_console/version.rb +1 -1
- metadata +13 -18
|
@@ -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) {
|
|
@@ -476,10 +595,10 @@
|
|
|
476
595
|
// Create form with Turbo Frame target
|
|
477
596
|
const form = document.createElement('form')
|
|
478
597
|
form.method = 'POST'
|
|
479
|
-
form.action = '<%=
|
|
598
|
+
form.action = '<%= 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
|
}
|
|
@@ -503,16 +622,22 @@
|
|
|
503
622
|
// Create form with Turbo Frame target
|
|
504
623
|
const form = document.createElement('form')
|
|
505
624
|
form.method = 'POST'
|
|
506
|
-
form.action = '<%=
|
|
625
|
+
form.action = '<%= 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
|
|
|
@@ -526,7 +651,7 @@
|
|
|
526
651
|
|
|
527
652
|
async loadTables() {
|
|
528
653
|
try {
|
|
529
|
-
const response = await fetch('<%=
|
|
654
|
+
const response = await fetch('<%= schema_tables_path %>')
|
|
530
655
|
this.tables = await response.json()
|
|
531
656
|
this.renderTables()
|
|
532
657
|
} catch (error) {
|
|
@@ -555,7 +680,7 @@
|
|
|
555
680
|
const tableName = event.currentTarget.dataset.tableName
|
|
556
681
|
|
|
557
682
|
try {
|
|
558
|
-
const response = await fetch(`<%=
|
|
683
|
+
const response = await fetch(`<%= schema_tables_path %>/${tableName}`)
|
|
559
684
|
const tableData = await response.json()
|
|
560
685
|
this.renderTableDetails(tableData)
|
|
561
686
|
} catch (error) {
|
|
@@ -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 %>
|
|
@@ -3,12 +3,14 @@ module QueryConsole
|
|
|
3
3
|
attr_accessor :enabled_environments,
|
|
4
4
|
:max_rows,
|
|
5
5
|
:timeout_ms,
|
|
6
|
+
:timeout_strategy,
|
|
6
7
|
:authorize,
|
|
7
8
|
:current_actor,
|
|
8
9
|
:forbidden_keywords,
|
|
9
10
|
:allowed_starts_with,
|
|
10
11
|
:enable_explain,
|
|
11
12
|
:enable_explain_analyze,
|
|
13
|
+
:enable_dml,
|
|
12
14
|
:schema_explorer,
|
|
13
15
|
:schema_cache_seconds,
|
|
14
16
|
:schema_table_denylist,
|
|
@@ -20,6 +22,7 @@ module QueryConsole
|
|
|
20
22
|
@enabled_environments = ["development"]
|
|
21
23
|
@max_rows = 500
|
|
22
24
|
@timeout_ms = 3000
|
|
25
|
+
@timeout_strategy = :database # :database (safer, PostgreSQL only) or :ruby (fallback, but can leave orphan queries)
|
|
23
26
|
@authorize = nil # nil means deny by default
|
|
24
27
|
@current_actor = -> (_controller) { "unknown" }
|
|
25
28
|
@forbidden_keywords = %w[
|
|
@@ -32,6 +35,7 @@ module QueryConsole
|
|
|
32
35
|
# v0.2.0 additions
|
|
33
36
|
@enable_explain = true
|
|
34
37
|
@enable_explain_analyze = false # ANALYZE can be expensive, disabled by default
|
|
38
|
+
@enable_dml = false # DML queries disabled by default for safety
|
|
35
39
|
@schema_explorer = true
|
|
36
40
|
@schema_cache_seconds = 60
|
|
37
41
|
@schema_table_denylist = ["schema_migrations", "ar_internal_metadata"]
|
data/lib/query_console/engine.rb
CHANGED
|
@@ -11,7 +11,7 @@ module QueryConsole
|
|
|
11
11
|
|
|
12
12
|
# Load Hotwire (Turbo & Stimulus)
|
|
13
13
|
initializer "query_console.importmap", before: "importmap" do |app|
|
|
14
|
-
if app.config.respond_to?(:importmap)
|
|
14
|
+
if defined?(Importmap) && app.config.respond_to?(:importmap)
|
|
15
15
|
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
16
16
|
end
|
|
17
17
|
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.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Johnson Gnanasekar
|
|
@@ -16,6 +16,9 @@ dependencies:
|
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: 7.0.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -23,6 +26,9 @@ dependencies:
|
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: 7.0.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: turbo-rails
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,20 +57,6 @@ dependencies:
|
|
|
51
57
|
- - "~>"
|
|
52
58
|
- !ruby/object:Gem::Version
|
|
53
59
|
version: '1.3'
|
|
54
|
-
- !ruby/object:Gem::Dependency
|
|
55
|
-
name: importmap-rails
|
|
56
|
-
requirement: !ruby/object:Gem::Requirement
|
|
57
|
-
requirements:
|
|
58
|
-
- - "~>"
|
|
59
|
-
- !ruby/object:Gem::Version
|
|
60
|
-
version: '2.0'
|
|
61
|
-
type: :runtime
|
|
62
|
-
prerelease: false
|
|
63
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
-
requirements:
|
|
65
|
-
- - "~>"
|
|
66
|
-
- !ruby/object:Gem::Version
|
|
67
|
-
version: '2.0'
|
|
68
60
|
- !ruby/object:Gem::Dependency
|
|
69
61
|
name: rspec-rails
|
|
70
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,8 +85,10 @@ dependencies:
|
|
|
93
85
|
- - "~>"
|
|
94
86
|
- !ruby/object:Gem::Version
|
|
95
87
|
version: '2.0'
|
|
96
|
-
description: A Rails engine
|
|
97
|
-
|
|
88
|
+
description: 'A Rails engine providing a web-based SQL query console with security-first
|
|
89
|
+
design: read-only by default, optional DML (INSERT/UPDATE/DELETE) with confirmation
|
|
90
|
+
dialogs, flexible authorization, comprehensive audit logging, and query execution
|
|
91
|
+
plans.'
|
|
98
92
|
email:
|
|
99
93
|
- johnson@example.com
|
|
100
94
|
executables: []
|
|
@@ -154,5 +148,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
154
148
|
requirements: []
|
|
155
149
|
rubygems_version: 3.6.7
|
|
156
150
|
specification_version: 4
|
|
157
|
-
summary:
|
|
151
|
+
summary: Secure, mountable Rails SQL console with read-only enforcement and optional
|
|
152
|
+
DML support
|
|
158
153
|
test_files: []
|