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.
@@ -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) {
@@ -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 = '<%= query_console.run_path %>'
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="${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
  }
@@ -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 = '<%= query_console.explain_path %>'
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="${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
 
@@ -526,7 +651,7 @@
526
651
 
527
652
  async loadTables() {
528
653
  try {
529
- const response = await fetch('<%= query_console.schema_tables_path %>')
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(`<%= query_console.schema_tables_path %>/${tableName}`)
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><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 %>
@@ -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"]
@@ -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
@@ -1,3 +1,3 @@
1
1
  module QueryConsole
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.6"
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.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 that provides a web-based SQL query console with read-only
97
- enforcement, authorization hooks, and audit logging.
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: Mountable Rails engine for secure read-only SQL queries
151
+ summary: Secure, mountable Rails SQL console with read-only enforcement and optional
152
+ DML support
158
153
  test_files: []