query_console 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,14 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
  <head>
4
- <title>Query Console</title>
4
+ <title>Query Console v0.2.0</title>
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <%= csrf_meta_tags %>
7
7
  <meta name="turbo-refresh-method" content="morph">
8
8
  <meta name="turbo-refresh-scroll" content="preserve">
9
9
 
10
10
  <style>
11
- * {
12
- box-sizing: border-box;
13
- }
11
+ * { box-sizing: border-box; }
14
12
 
15
13
  body {
16
14
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
@@ -21,10 +19,11 @@
21
19
  }
22
20
 
23
21
  .container {
24
- max-width: 1400px;
22
+ max-width: 1600px;
25
23
  margin: 0 auto;
26
24
  }
27
25
 
26
+ /* Banner */
28
27
  .banner {
29
28
  background: #fff3cd;
30
29
  border: 1px solid #ffc107;
@@ -33,79 +32,51 @@
33
32
  margin-bottom: 20px;
34
33
  color: #856404;
35
34
  position: relative;
36
- transition: all 0.3s ease;
37
- }
38
-
39
- .banner.collapsed {
40
- padding: 10px 15px;
41
- }
42
-
43
- .banner.collapsed .banner-content {
44
- display: none;
45
- }
46
-
47
- .banner.collapsed h2 {
48
- margin: 0;
49
35
  }
50
36
 
37
+ .banner.collapsed .banner-content { display: none; }
51
38
  .banner h2 {
52
39
  margin: 0 0 10px 0;
53
40
  font-size: 18px;
54
41
  padding-right: 30px;
55
42
  }
56
-
57
43
  .banner p {
58
44
  margin: 5px 0;
59
45
  font-size: 14px;
60
46
  }
61
47
 
62
- .banner-toggle {
48
+ .section-toggle {
63
49
  position: absolute;
64
- top: 15px;
50
+ top: 50%;
65
51
  right: 15px;
52
+ transform: translateY(-50%);
66
53
  background: transparent;
67
54
  border: none;
68
- color: #856404;
69
- font-size: 20px;
70
55
  cursor: pointer;
71
- padding: 0;
72
- width: 24px;
73
- height: 24px;
74
- display: flex;
75
- align-items: center;
76
- justify-content: center;
77
- transition: transform 0.3s ease;
78
- }
79
-
80
- .banner-toggle:hover {
81
- transform: scale(1.2);
82
- }
83
-
84
- .banner.collapsed .banner-toggle {
85
- transform: rotate(180deg);
56
+ padding: 4px 8px;
57
+ font-size: 16px;
86
58
  }
87
59
 
60
+ /* Main Layout */
88
61
  .main-layout {
89
62
  display: grid;
90
- grid-template-columns: 1fr 300px;
63
+ grid-template-columns: 1fr 400px;
91
64
  gap: 20px;
65
+ align-items: start;
92
66
  }
93
67
 
68
+ /* Editor Section */
94
69
  .editor-section {
95
70
  background: white;
96
71
  border-radius: 8px;
97
72
  padding: 20px;
98
73
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
99
- transition: all 0.3s ease;
100
- }
101
-
102
- .editor-section.collapsed {
103
- padding: 15px 20px;
104
- }
105
-
106
- .editor-section.collapsed .editor-content {
107
- display: none;
74
+ min-width: 0; /* Allow grid to constrain this column */
75
+ width: 100%; /* Take full grid column width */
76
+ position: relative;
108
77
  }
78
+
79
+ .editor-section.collapsed .editor-content { display: none; }
109
80
 
110
81
  .editor-header {
111
82
  display: flex;
@@ -113,37 +84,12 @@
113
84
  align-items: center;
114
85
  margin-bottom: 15px;
115
86
  position: relative;
116
- }
117
-
118
- .editor-section.collapsed .editor-header {
119
- margin-bottom: 0;
87
+ padding-right: 40px; /* Increased from 30px to give more space for toggle button */
120
88
  }
121
89
 
122
90
  .editor-header h3 {
123
91
  margin: 0;
124
- font-size: 20px;
125
- flex-grow: 1;
126
- }
127
-
128
- .section-toggle {
129
- background: transparent;
130
- border: none;
131
- color: #6c757d;
132
- font-size: 18px;
133
- cursor: pointer;
134
- padding: 5px 10px;
135
- margin-left: 10px;
136
- transition: transform 0.3s ease, color 0.2s;
137
- order: 1;
138
- }
139
-
140
- .section-toggle:hover {
141
- color: #007bff;
142
- transform: scale(1.2);
143
- }
144
-
145
- .collapsed .section-toggle {
146
- transform: rotate(180deg);
92
+ font-size: 16px;
147
93
  }
148
94
 
149
95
  .button-group {
@@ -151,13 +97,13 @@
151
97
  gap: 10px;
152
98
  }
153
99
 
154
- .btn-primary, .btn-secondary, .btn-danger {
155
- padding: 10px 20px;
100
+ .btn-primary, .btn-secondary {
101
+ padding: 8px 16px;
156
102
  border: none;
157
103
  border-radius: 4px;
158
- font-size: 14px;
159
104
  cursor: pointer;
160
- transition: all 0.2s;
105
+ font-size: 14px;
106
+ font-weight: 500;
161
107
  }
162
108
 
163
109
  .btn-primary {
@@ -165,15 +111,10 @@
165
111
  color: white;
166
112
  }
167
113
 
168
- .btn-primary:hover:not(:disabled) {
114
+ .btn-primary:hover {
169
115
  background: #0056b3;
170
116
  }
171
117
 
172
- .btn-primary:disabled {
173
- background: #6c757d;
174
- cursor: not-allowed;
175
- }
176
-
177
118
  .btn-secondary {
178
119
  background: #6c757d;
179
120
  color: white;
@@ -183,191 +124,229 @@
183
124
  background: #545b62;
184
125
  }
185
126
 
186
- .btn-danger {
187
- background: #dc3545;
188
- color: white;
189
- font-size: 12px;
190
- padding: 5px 10px;
191
- }
192
-
193
- .btn-danger:hover {
194
- background: #c82333;
195
- }
196
-
197
- textarea {
127
+ /* SQL Editor Textarea */
128
+ .sql-editor {
198
129
  width: 100%;
199
130
  min-height: 200px;
200
- padding: 15px;
131
+ padding: 12px;
201
132
  border: 1px solid #ddd;
202
133
  border-radius: 4px;
203
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
134
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
204
135
  font-size: 14px;
136
+ line-height: 1.5;
205
137
  resize: vertical;
206
- margin-bottom: 15px;
207
138
  }
208
139
 
209
- textarea:focus {
140
+ .sql-editor:focus {
210
141
  outline: none;
211
142
  border-color: #007bff;
212
- box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
143
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
144
+ }
145
+
146
+
147
+ /* Results Containers - prevent horizontal expansion */
148
+ turbo-frame#query-results,
149
+ turbo-frame#explain-results {
150
+ display: block;
151
+ width: 100%;
152
+ min-width: 0;
153
+ overflow-x: auto;
213
154
  }
214
155
 
215
- .history-section {
156
+ /* Right Panel */
157
+ .right-panel {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 20px;
161
+ }
162
+
163
+ /* Tabbed Section */
164
+ .tabbed-section {
216
165
  background: white;
217
166
  border-radius: 8px;
218
- padding: 20px;
219
167
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
220
- max-height: 800px;
221
- overflow-y: auto;
222
- transition: all 0.3s ease;
168
+ overflow: hidden;
223
169
  }
170
+
171
+ .tabbed-section.collapsed .tab-content { display: none; }
224
172
 
225
- .history-section.collapsed {
226
- padding: 15px 20px;
227
- max-height: none;
228
- overflow: visible;
173
+ .tabs {
174
+ display: flex;
175
+ border-bottom: 1px solid #dee2e6;
176
+ background: #f8f9fa;
229
177
  }
230
178
 
231
- .history-section.collapsed .history-content {
232
- display: none;
179
+ .tab {
180
+ padding: 12px 20px;
181
+ cursor: pointer;
182
+ border: none;
183
+ background: transparent;
184
+ border-bottom: 2px solid transparent;
185
+ font-size: 14px;
186
+ font-weight: 500;
233
187
  }
234
188
 
235
- .history-header {
236
- display: flex;
237
- justify-content: space-between;
238
- align-items: center;
239
- margin-bottom: 15px;
240
- position: relative;
189
+ .tab.active {
190
+ border-bottom-color: #007bff;
191
+ color: #007bff;
241
192
  }
242
193
 
243
- .history-section.collapsed .history-header {
244
- margin-bottom: 0;
194
+ .tab-content {
195
+ padding: 15px;
196
+ max-height: 400px;
197
+ overflow-y: auto;
245
198
  }
246
199
 
247
- .history-header h3 {
248
- margin: 0;
249
- font-size: 18px;
250
- flex-grow: 1;
200
+ .tab-pane {
201
+ display: none;
251
202
  }
252
203
 
253
- .history-list {
204
+ .tab-pane.active {
205
+ display: block;
206
+ }
207
+
208
+ /* History & Schema Lists */
209
+ ul.item-list {
254
210
  list-style: none;
255
211
  padding: 0;
256
212
  margin: 0;
257
213
  }
258
214
 
259
- .history-item {
260
- margin-bottom: 10px;
215
+ ul.item-list li {
216
+ padding: 10px;
217
+ border-bottom: 1px solid #eee;
218
+ cursor: pointer;
261
219
  }
262
220
 
263
- .history-item-button {
264
- width: 100%;
221
+ ul.item-list li:hover {
265
222
  background: #f8f9fa;
223
+ }
224
+
225
+ /* Saved Queries Section */
226
+ .saved-section {
227
+ background: white;
228
+ border-radius: 8px;
229
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
230
+ padding: 15px;
231
+ }
232
+
233
+ .saved-section.collapsed .saved-content {
234
+ display: none;
235
+ }
236
+
237
+ .saved-header {
238
+ display: flex;
239
+ justify-content: space-between;
240
+ align-items: center;
241
+ margin-bottom: 10px;
242
+ }
243
+
244
+ .saved-header h4 {
245
+ margin: 0;
246
+ font-size: 14px;
247
+ }
248
+
249
+ .saved-query-item {
250
+ padding: 10px;
266
251
  border: 1px solid #dee2e6;
267
252
  border-radius: 4px;
268
- padding: 10px;
269
- text-align: left;
253
+ margin-bottom: 8px;
254
+ }
255
+
256
+ .saved-query-item:hover {
257
+ background: #f8f9fa;
258
+ }
259
+
260
+ /* Schema Explorer */
261
+ .schema-search {
262
+ width: 100%;
263
+ padding: 8px;
264
+ border: 1px solid #ddd;
265
+ border-radius: 4px;
266
+ margin-bottom: 10px;
267
+ }
268
+
269
+ .table-item {
270
+ padding: 8px;
270
271
  cursor: pointer;
271
- transition: all 0.2s;
272
+ border-bottom: 1px solid #eee;
272
273
  }
273
274
 
274
- .history-item-button:hover {
275
- background: #e9ecef;
276
- border-color: #007bff;
277
- transform: translateX(2px);
275
+ .table-item:hover {
276
+ background: #f8f9fa;
278
277
  }
279
278
 
280
- .history-item-sql {
281
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
282
- font-size: 12px;
283
- color: #495057;
284
- margin-bottom: 5px;
285
- white-space: nowrap;
286
- overflow: hidden;
287
- text-overflow: ellipsis;
279
+ .columns-list {
280
+ margin-top: 10px;
281
+ padding-left: 15px;
288
282
  }
289
283
 
290
- .history-item-time {
284
+ .column-item {
285
+ padding: 6px;
286
+ font-size: 13px;
287
+ border-left: 2px solid #007bff;
288
+ margin-bottom: 4px;
289
+ background: #f8f9fa;
290
+ }
291
+
292
+ /* Quick Actions */
293
+ .quick-actions {
294
+ display: flex;
295
+ gap: 6px;
296
+ margin-top: 6px;
297
+ }
298
+
299
+ .quick-action-btn {
300
+ padding: 4px 8px;
291
301
  font-size: 11px;
292
- color: #6c757d;
302
+ background: #e9ecef;
303
+ border: 1px solid #dee2e6;
304
+ border-radius: 3px;
305
+ cursor: pointer;
293
306
  }
294
307
 
295
- .empty-history {
296
- color: #6c757d;
297
- font-style: italic;
298
- text-align: center;
299
- padding: 20px;
308
+ .quick-action-btn:hover {
309
+ background: #dee2e6;
300
310
  }
301
311
 
302
- @media (max-width: 768px) {
303
- .main-layout {
304
- grid-template-columns: 1fr;
305
- }
312
+ /* Results */
313
+ .results-container {
314
+ margin-top: 20px;
306
315
  }
307
- </style>
308
- </head>
309
- <body>
310
- <div class="container">
311
- <!-- Security Banner - Collapsible -->
312
- <div class="banner" id="security-banner" data-controller="collapsible" data-collapsible-key-value="banner">
313
- <button class="banner-toggle" data-action="click->collapsible#toggle" title="Toggle banner">▼</button>
314
- <h2>🔍 Read-Only SQL Query Console</h2>
315
- <div class="banner-content">
316
- <p><strong>Security Notice:</strong> This console is enabled only in development by default.</p>
317
- <p>All queries are logged and restricted to read-only operations (SELECT statements only).</p>
318
- <p>Multiple statements and write operations are blocked for security.</p>
319
- </div>
320
- </div>
321
316
 
322
- <div class="main-layout">
323
- <!-- SQL Editor Section - Collapsible -->
324
- <%= form_with url: query_console.run_path, method: :post, data: {
325
- turbo_frame: "query-results",
326
- controller: "editor",
327
- action: "turbo:submit-end->editor#querySuccess turbo:frame-render->editor#querySuccess submit->editor#handleSubmit"
328
- } do |f| %>
329
- <div class="editor-section" id="editor-section" data-controller="collapsible" data-collapsible-key-value="editor">
330
- <div class="editor-header">
331
- <h3>SQL Editor</h3>
332
- <div class="button-group">
333
- <button type="button" data-action="click->editor#clear" data-editor-target="clearButton" class="btn-secondary">Clear</button>
334
- <%= f.submit "Run Query", class: "btn-primary", data: { editor_target: "runButton" } %>
335
- </div>
336
- <button type="button" class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor">▼</button>
337
- </div>
317
+ .table-wrapper {
318
+ overflow: auto;
319
+ border: 1px solid #dee2e6;
320
+ border-radius: 4px;
321
+ max-height: 500px;
322
+ }
338
323
 
339
- <div class="editor-content">
340
- <%= f.text_area :sql,
341
- placeholder: "Enter your SELECT query here...\n\nExample:\nSELECT * FROM users LIMIT 10;",
342
- data: { editor_target: "textarea" } %>
324
+ .results-table {
325
+ width: 100%;
326
+ border-collapse: collapse;
327
+ }
343
328
 
344
- <!-- Turbo Frame for Results -->
345
- <%= turbo_frame_tag "query-results", data: { editor_target: "results" } do %>
346
- <div style="color: #6c757d; text-align: center; padding: 40px;">
347
- <p>Enter a query above and click "Run Query" to see results here.</p>
348
- </div>
349
- <% end %>
350
- </div>
351
- </div>
352
- <% end %>
353
-
354
- <!-- Query History Section - Collapsible -->
355
- <div class="history-section" id="history-section" data-controller="history collapsible" data-collapsible-key-value="history_section">
356
- <div class="history-header">
357
- <h3>Query History</h3>
358
- <button data-action="click->history#clear" class="btn-danger">Clear</button>
359
- <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle history">▼</button>
360
- </div>
361
- <div class="history-content">
362
- <ul class="history-list" data-history-target="list">
363
- <li class="empty-history">No query history yet</li>
364
- </ul>
365
- </div>
366
- </div>
367
- </div>
368
- </div>
329
+ .results-table thead {
330
+ position: sticky;
331
+ top: 0;
332
+ background: #f8f9fa;
333
+ z-index: 10;
334
+ }
335
+
336
+ .results-table th,
337
+ .results-table td {
338
+ padding: 8px 12px;
339
+ text-align: left;
340
+ border: 1px solid #dee2e6;
341
+ white-space: nowrap;
342
+ }
343
+
344
+ .results-table th {
345
+ font-weight: 600;
346
+ background: #e9ecef;
347
+ }
348
+ </style>
369
349
 
370
- <!-- Load Hotwire and Stimulus from CDN -->
371
350
  <script type="importmap">
372
351
  {
373
352
  "imports": {
@@ -377,45 +356,281 @@
377
356
  }
378
357
  </script>
379
358
 
380
- <!-- Load Stimulus controllers inline -->
381
359
  <script type="module">
382
360
  import * as Turbo from "@hotwired/turbo-rails"
383
361
  import { Application, Controller } from "@hotwired/stimulus"
384
-
385
- // Make Turbo available globally
386
- window.Turbo = Turbo
387
-
362
+
388
363
  const application = Application.start()
389
- window.Stimulus = application
390
-
364
+
391
365
  // Collapsible Controller
392
366
  class CollapsibleController extends Controller {
393
367
  static values = { key: String }
394
368
 
395
369
  connect() {
396
- this.storageKey = `query_console.${this.keyValue}_collapsed`
397
370
  this.loadState()
398
371
  }
399
372
 
400
- toggle(event) {
401
- event.preventDefault()
373
+ toggle() {
402
374
  this.element.classList.toggle('collapsed')
403
-
404
- const isCollapsed = this.element.classList.contains('collapsed')
405
- localStorage.setItem(this.storageKey, isCollapsed ? 'true' : 'false')
406
- event.target.textContent = isCollapsed ? '▲' : '▼'
375
+ this.saveState()
407
376
  }
408
377
 
409
378
  loadState() {
410
- const isCollapsed = localStorage.getItem(this.storageKey) === 'true'
411
- if (isCollapsed) {
379
+ const key = this.keyValue
380
+ const collapsed = localStorage.getItem(`qc.collapsed.${key}`) === 'true'
381
+ if (collapsed) {
412
382
  this.element.classList.add('collapsed')
413
- const button = this.element.querySelector('.section-toggle, .banner-toggle')
414
- if (button) button.textContent = '▲'
415
383
  }
416
384
  }
385
+
386
+ saveState() {
387
+ const key = this.keyValue
388
+ const collapsed = this.element.classList.contains('collapsed')
389
+ localStorage.setItem(`qc.collapsed.${key}`, collapsed)
390
+ }
417
391
  }
418
-
392
+ application.register("collapsible", CollapsibleController)
393
+
394
+ // Tabs Controller
395
+ class TabsController extends Controller {
396
+ static targets = ["tab", "pane"]
397
+
398
+ connect() {
399
+ this.showTab(0)
400
+ }
401
+
402
+ select(event) {
403
+ const index = this.tabTargets.indexOf(event.currentTarget)
404
+ this.showTab(index)
405
+ }
406
+
407
+ showTab(index) {
408
+ this.tabTargets.forEach((tab, i) => {
409
+ tab.classList.toggle('active', i === index)
410
+ })
411
+ this.paneTargets.forEach((pane, i) => {
412
+ pane.classList.toggle('active', i === index)
413
+ })
414
+ }
415
+ }
416
+ application.register("tabs", TabsController)
417
+
418
+ // Editor Controller (Simple Textarea)
419
+ class EditorController extends Controller {
420
+ static targets = ["textarea"]
421
+
422
+ getSql() {
423
+ return this.textareaTarget.value
424
+ }
425
+
426
+ setSql(text) {
427
+ this.textareaTarget.value = text
428
+ this.textareaTarget.focus()
429
+ }
430
+
431
+ 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()
441
+ }
442
+
443
+ clearEditor() {
444
+ this.textareaTarget.value = ''
445
+ this.textareaTarget.focus()
446
+
447
+ // Clear query results
448
+ const queryFrame = document.querySelector('turbo-frame#query-results')
449
+ if (queryFrame) {
450
+ 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>'
451
+ }
452
+
453
+ // Clear explain results
454
+ const explainFrame = document.querySelector('turbo-frame#explain-results')
455
+ if (explainFrame) {
456
+ explainFrame.innerHTML = ''
457
+ }
458
+ }
459
+
460
+ runQuery() {
461
+ const sql = this.getSql()
462
+ if (!sql.trim()) {
463
+ alert('Please enter a SQL query')
464
+ return
465
+ }
466
+
467
+ // Clear explain results when running query
468
+ const explainFrame = document.querySelector('turbo-frame#explain-results')
469
+ if (explainFrame) {
470
+ explainFrame.innerHTML = ''
471
+ }
472
+
473
+ // Store for history
474
+ window._lastExecutedSQL = sql
475
+
476
+ // Create form with Turbo Frame target
477
+ const form = document.createElement('form')
478
+ form.method = 'POST'
479
+ form.action = '<%= query_console.run_path %>'
480
+ form.setAttribute('data-turbo-frame', 'query-results')
481
+ form.innerHTML = `
482
+ <input type="hidden" name="sql" value="${sql.replace(/"/g, '&quot;')}">
483
+ <input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
484
+ `
485
+ document.body.appendChild(form)
486
+ form.requestSubmit()
487
+ document.body.removeChild(form)
488
+ }
489
+
490
+ explainQuery() {
491
+ const sql = this.getSql()
492
+ if (!sql.trim()) {
493
+ alert('Please enter a SQL query')
494
+ return
495
+ }
496
+
497
+ // Clear query results when running explain
498
+ const queryFrame = document.querySelector('turbo-frame#query-results')
499
+ if (queryFrame) {
500
+ queryFrame.innerHTML = ''
501
+ }
502
+
503
+ // Create form with Turbo Frame target
504
+ const form = document.createElement('form')
505
+ form.method = 'POST'
506
+ form.action = '<%= query_console.explain_path %>'
507
+ form.setAttribute('data-turbo-frame', 'explain-results')
508
+ form.innerHTML = `
509
+ <input type="hidden" name="sql" value="${sql.replace(/"/g, '&quot;')}">
510
+ <input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
511
+ `
512
+ document.body.appendChild(form)
513
+ form.requestSubmit()
514
+ document.body.removeChild(form)
515
+ }
516
+ }
517
+ application.register("editor", EditorController)
518
+
519
+ // Schema Controller
520
+ class SchemaController extends Controller {
521
+ static targets = ["search", "tablesList", "details"]
522
+
523
+ connect() {
524
+ this.loadTables()
525
+ }
526
+
527
+ async loadTables() {
528
+ try {
529
+ const response = await fetch('<%= query_console.schema_tables_path %>')
530
+ this.tables = await response.json()
531
+ this.renderTables()
532
+ } catch (error) {
533
+ console.error('Failed to load tables:', error)
534
+ }
535
+ }
536
+
537
+ filterTables(event) {
538
+ const query = event.target.value.toLowerCase()
539
+ this.renderTables(query)
540
+ }
541
+
542
+ renderTables(filter = '') {
543
+ const filtered = filter ?
544
+ this.tables.filter(t => t.name.toLowerCase().includes(filter)) :
545
+ this.tables
546
+
547
+ this.tablesListTarget.innerHTML = filtered.map(table =>
548
+ `<div class="table-item" data-action="click->schema#selectTable" data-table-name="${table.name}">
549
+ 📊 ${table.name} <small>(${table.kind})</small>
550
+ </div>`
551
+ ).join('')
552
+ }
553
+
554
+ async selectTable(event) {
555
+ const tableName = event.currentTarget.dataset.tableName
556
+
557
+ try {
558
+ const response = await fetch(`<%= query_console.schema_tables_path %>/${tableName}`)
559
+ const tableData = await response.json()
560
+ this.renderTableDetails(tableData)
561
+ } catch (error) {
562
+ console.error('Failed to load table details:', error)
563
+ }
564
+ }
565
+
566
+ renderTableDetails(table) {
567
+ const editor = this.application.getControllerForElementAndIdentifier(
568
+ document.querySelector('[data-controller="editor"]'),
569
+ 'editor'
570
+ )
571
+
572
+ this.detailsTarget.innerHTML = `
573
+ <h5>${table.name}</h5>
574
+ <div class="quick-actions">
575
+ <button class="quick-action-btn" data-action="click->schema#insertSelectAll" data-table="${table.name}">
576
+ SELECT * FROM ${table.name}
577
+ </button>
578
+ <button class="quick-action-btn" data-action="click->schema#copyTableName" data-table="${table.name}">
579
+ 📋 Copy Table Name
580
+ </button>
581
+ </div>
582
+ <div class="columns-list">
583
+ ${table.columns.map(col => `
584
+ <div class="column-item">
585
+ <strong>${col.name}</strong> <code>${col.db_type}</code>
586
+ ${col.nullable ? '<span>NULL</span>' : '<span>NOT NULL</span>'}
587
+ <div class="quick-actions">
588
+ <button class="quick-action-btn" data-action="click->schema#insertColumn" data-column="${col.name}">
589
+ Insert
590
+ </button>
591
+ <button class="quick-action-btn" data-action="click->schema#insertWhere" data-column="${col.name}">
592
+ WHERE
593
+ </button>
594
+ </div>
595
+ </div>
596
+ `).join('')}
597
+ </div>
598
+ `
599
+ }
600
+
601
+ insertSelectAll(event) {
602
+ const table = event.currentTarget.dataset.table
603
+ const editor = this.getEditor()
604
+ editor.setSql(`SELECT * FROM ${table} LIMIT 100;`)
605
+ }
606
+
607
+ insertColumn(event) {
608
+ const column = event.currentTarget.dataset.column
609
+ const editor = this.getEditor()
610
+ editor.insertAtCursor(column)
611
+ }
612
+
613
+ insertWhere(event) {
614
+ const column = event.currentTarget.dataset.column
615
+ const editor = this.getEditor()
616
+ editor.insertAtCursor(`WHERE ${column} = `)
617
+ }
618
+
619
+ copyTableName(event) {
620
+ const table = event.currentTarget.dataset.table
621
+ navigator.clipboard.writeText(table)
622
+ alert(`Copied: ${table}`)
623
+ }
624
+
625
+ getEditor() {
626
+ return this.application.getControllerForElementAndIdentifier(
627
+ document.querySelector('[data-controller~="editor"]'),
628
+ 'editor'
629
+ )
630
+ }
631
+ }
632
+ application.register("schema", SchemaController)
633
+
419
634
  // History Controller
420
635
  class HistoryController extends Controller {
421
636
  static targets = ["list"]
@@ -425,38 +640,60 @@
425
640
  }
426
641
 
427
642
  connect() {
428
- this.loadHistory()
429
- document.addEventListener('editor:executed', (e) => this.add(e))
643
+ this.render()
644
+ document.addEventListener('editor:executed', (e) => this.addQuery(e.detail.sql))
430
645
  }
431
646
 
432
- add(event) {
647
+ addQuery(sql) {
433
648
  const history = this.getHistory()
434
649
  history.unshift({
435
- sql: event.detail.sql.trim(),
436
- timestamp: event.detail.timestamp
650
+ sql: sql,
651
+ timestamp: new Date().toISOString()
437
652
  })
438
653
 
439
- const trimmed = history.slice(0, this.maxItemsValue)
440
- localStorage.setItem(this.storageKeyValue, JSON.stringify(trimmed))
441
- this.renderHistory(trimmed)
654
+ if (history.length > this.maxItemsValue) {
655
+ history.pop()
656
+ }
657
+
658
+ this.saveHistory(history)
659
+ this.render()
442
660
  }
443
661
 
444
- load(event) {
445
- event.preventDefault()
446
- const sql = event.currentTarget.dataset.sql
447
- document.dispatchEvent(new CustomEvent('history:load', { detail: { sql } }))
662
+ render() {
663
+ const history = this.getHistory()
664
+ this.listTarget.innerHTML = history.length ?
665
+ history.map((item, index) => `
666
+ <li data-action="click->history#load" data-index="${index}">
667
+ <div style="font-size: 12px; color: #6c757d;">${new Date(item.timestamp).toLocaleString()}</div>
668
+ <div style="font-size: 13px; margin-top: 4px;">${this.truncate(item.sql, 100)}</div>
669
+ </li>
670
+ `).join('') :
671
+ '<li style="color: #6c757d; text-align: center; padding: 20px;">No query history</li>'
448
672
  }
449
673
 
450
- clear(event) {
451
- event.preventDefault()
452
- if (confirm("Clear all query history?")) {
453
- localStorage.removeItem(this.storageKeyValue)
454
- this.renderHistory([])
674
+ load(event) {
675
+ const index = parseInt(event.currentTarget.dataset.index)
676
+ const history = this.getHistory()
677
+ const query = history[index]
678
+
679
+ if (query) {
680
+ const editor = this.getEditor()
681
+ editor.setSql(query.sql)
455
682
  }
456
683
  }
457
684
 
458
- loadHistory() {
459
- this.renderHistory(this.getHistory())
685
+ getEditor() {
686
+ return this.application.getControllerForElementAndIdentifier(
687
+ document.querySelector('[data-controller~="editor"]'),
688
+ 'editor'
689
+ )
690
+ }
691
+
692
+ clear() {
693
+ if (confirm('Clear all query history?')) {
694
+ localStorage.removeItem(this.storageKeyValue)
695
+ this.render()
696
+ }
460
697
  }
461
698
 
462
699
  getHistory() {
@@ -464,102 +701,257 @@
464
701
  return stored ? JSON.parse(stored) : []
465
702
  }
466
703
 
467
- renderHistory(history) {
468
- if (history.length === 0) {
469
- this.listTarget.innerHTML = '<li class="empty-history">No query history yet</li>'
470
- return
471
- }
472
-
473
- this.listTarget.innerHTML = history.map(item => `
474
- <li class="history-item">
475
- <button type="button" class="history-item-button"
476
- data-action="click->history#load"
477
- data-sql="${this.escapeHtml(item.sql)}">
478
- <div class="history-item-sql">${this.escapeHtml(this.truncate(item.sql, 100))}</div>
479
- <div class="history-item-time">${this.formatTime(item.timestamp)}</div>
480
- </button>
481
- </li>
482
- `).join('')
704
+ saveHistory(history) {
705
+ localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
483
706
  }
484
707
 
485
708
  truncate(str, length) {
486
709
  return str.length > length ? str.substring(0, length) + '...' : str
487
710
  }
488
-
489
- formatTime(timestamp) {
490
- const date = new Date(timestamp)
491
- const diff = Date.now() - date
492
- if (diff < 60000) return 'just now'
493
- if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`
494
- if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`
495
- return date.toLocaleDateString()
496
- }
497
-
498
- escapeHtml(text) {
499
- const div = document.createElement('div')
500
- div.textContent = text
501
- return div.innerHTML
502
- }
503
711
  }
504
-
505
- // Editor Controller
506
- class EditorController extends Controller {
507
- static targets = ["textarea", "runButton"]
508
-
509
- connect() {
510
- document.addEventListener('history:load', (e) => this.loadQuery(e.detail.sql))
511
- }
512
-
513
- loadQuery(sql) {
514
- this.textareaTarget.value = sql
515
- this.textareaTarget.focus()
516
- this.element.scrollIntoView({ behavior: 'smooth' })
712
+ application.register("history", HistoryController)
713
+
714
+ // Saved Queries Controller
715
+ class SavedController extends Controller {
716
+ static targets = ["list"]
717
+ static values = {
718
+ storageKey: { type: String, default: "query_console.saved.v1" }
517
719
  }
518
720
 
519
- clear(event) {
520
- event.preventDefault()
521
- this.textareaTarget.value = ''
522
- this.textareaTarget.focus()
721
+ connect() {
722
+ this.render()
523
723
  }
524
724
 
525
- handleSubmit(event) {
526
- const sql = this.textareaTarget.value.trim()
725
+ save() {
726
+ const editor = this.getEditor()
727
+ const sql = editor.getSql()
527
728
 
528
- if (!sql) {
529
- event.preventDefault()
530
- alert('Please enter a SQL query')
729
+ if (!sql.trim()) {
730
+ alert('Nothing to save')
531
731
  return
532
732
  }
533
733
 
534
- // Store SQL for after execution
535
- window._lastExecutedSQL = sql
734
+ const name = prompt('Query name:')
735
+ if (!name) return
736
+
737
+ const tags = prompt('Tags (comma-separated, optional):')
536
738
 
537
- // Show loading state
538
- this.runButtonTarget.disabled = true
539
- this.runButtonTarget.value = 'Running...'
739
+ const saved = this.getSaved()
740
+ saved.push({
741
+ id: Date.now().toString(),
742
+ name: name,
743
+ tags: tags ? tags.split(',').map(t => t.trim()) : [],
744
+ sql: sql,
745
+ createdAt: new Date().toISOString(),
746
+ updatedAt: new Date().toISOString()
747
+ })
540
748
 
541
- // Let form submit naturally (Turbo will intercept it)
749
+ this.saveSaved(saved)
750
+ this.render()
542
751
  }
543
752
 
544
- querySuccess() {
545
- this.runButtonTarget.disabled = false
546
- this.runButtonTarget.value = 'Run Query'
753
+ load(event) {
754
+ const id = event.currentTarget.dataset.id
755
+ const saved = this.getSaved()
756
+ const query = saved.find(q => q.id === id)
547
757
 
548
- if (window._lastExecutedSQL) {
549
- document.dispatchEvent(new CustomEvent('editor:executed', {
550
- detail: {
551
- sql: window._lastExecutedSQL,
552
- timestamp: new Date().toISOString()
553
- }
554
- }))
555
- delete window._lastExecutedSQL
758
+ if (query) {
759
+ const editor = this.getEditor()
760
+ editor.setSql(query.sql)
556
761
  }
557
762
  }
763
+
764
+ delete(event) {
765
+ if (!confirm('Delete this saved query?')) return
766
+
767
+ const id = event.currentTarget.dataset.id
768
+ const saved = this.getSaved().filter(q => q.id !== id)
769
+ this.saveSaved(saved)
770
+ this.render()
771
+ }
772
+
773
+ exportJSON() {
774
+ const saved = this.getSaved()
775
+ const json = JSON.stringify(saved, null, 2)
776
+ navigator.clipboard.writeText(json)
777
+ alert('Saved queries copied to clipboard!')
778
+ }
779
+
780
+ importJSON() {
781
+ const json = prompt('Paste saved queries JSON:')
782
+ if (!json) return
783
+
784
+ try {
785
+ const imported = JSON.parse(json)
786
+ const saved = this.getSaved()
787
+ this.saveSaved([...saved, ...imported])
788
+ this.render()
789
+ alert(`Imported ${imported.length} queries`)
790
+ } catch (error) {
791
+ alert('Invalid JSON: ' + error.message)
792
+ }
793
+ }
794
+
795
+ render() {
796
+ const saved = this.getSaved()
797
+ this.listTarget.innerHTML = saved.length ?
798
+ saved.map(query => `
799
+ <div class="saved-query-item">
800
+ <strong>${query.name}</strong>
801
+ ${query.tags.length ? `<div><small>🏷 ${query.tags.join(', ')}</small></div>` : ''}
802
+ <div style="font-size: 12px; color: #6c757d; margin: 4px 0;">
803
+ ${new Date(query.updatedAt).toLocaleString()}
804
+ </div>
805
+ <div class="quick-actions">
806
+ <button class="quick-action-btn" data-action="click->saved#load" data-id="${query.id}">Load</button>
807
+ <button class="quick-action-btn" data-action="click->saved#delete" data-id="${query.id}">Delete</button>
808
+ </div>
809
+ </div>
810
+ `).join('') :
811
+ '<div style="color: #6c757d; text-align: center; padding: 20px;">No saved queries</div>'
812
+ }
813
+
814
+ getSaved() {
815
+ const stored = localStorage.getItem(this.storageKeyValue)
816
+ return stored ? JSON.parse(stored) : []
817
+ }
818
+
819
+ saveSaved(saved) {
820
+ localStorage.setItem(this.storageKeyValue, JSON.stringify(saved))
821
+ }
822
+
823
+ getEditor() {
824
+ return this.application.getControllerForElementAndIdentifier(
825
+ document.querySelector('[data-controller~="editor"]'),
826
+ 'editor'
827
+ )
828
+ }
558
829
  }
559
-
560
- application.register("collapsible", CollapsibleController)
561
- application.register("history", HistoryController)
562
- application.register("editor", EditorController)
830
+ application.register("saved", SavedController)
831
+ </script>
832
+ </head>
833
+ <body>
834
+ <div class="container">
835
+ <!-- Banner -->
836
+ <div class="banner" data-controller="collapsible" data-collapsible-key-value="banner">
837
+ <h2>🔍 Read-Only SQL Query Console <small>v0.2.0</small></h2>
838
+ <div class="banner-content">
839
+ <p><strong>Security:</strong> Read-only SELECT & WITH queries only. All queries are logged.</p>
840
+ <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
+ </div>
842
+ <button class="section-toggle" data-action="click->collapsible#toggle" type="button">▼</button>
843
+ </div>
844
+
845
+ <div class="main-layout">
846
+ <!-- Editor Section -->
847
+ <div class="editor-section" data-controller="editor collapsible" data-collapsible-key-value="editor_section">
848
+ <div class="editor-header">
849
+ <h3>SQL Editor</h3>
850
+ <div class="button-group">
851
+ <button class="btn-secondary" data-action="click->editor#clearEditor" type="button">Clear</button>
852
+ <button class="btn-secondary" data-action="click->editor#explainQuery" type="button">⚡ Explain</button>
853
+ <button class="btn-primary" data-action="click->editor#runQuery" type="button">▶ Run Query</button>
854
+ </div>
855
+ <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor" type="button">▼</button>
856
+ </div>
857
+
858
+ <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>
870
+
871
+ <!-- Results Area -->
872
+ <%= turbo_frame_tag "query-results" do %>
873
+ <div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;">
874
+ <p>Enter a query above and click "Run Query" to see results here.</p>
875
+ </div>
876
+ <% end %>
877
+
878
+ <!-- Explain Results Area -->
879
+ <%= turbo_frame_tag "explain-results" do %>
880
+ <% end %>
881
+ </div>
882
+ </div>
883
+
884
+ <!-- Right Panel -->
885
+ <div class="right-panel">
886
+ <!-- Tabbed Section (History / Schema / Saved Queries) -->
887
+ <div class="tabbed-section" data-controller="tabs collapsible" data-collapsible-key-value="tabs_section">
888
+ <div class="tabs" style="position: relative;">
889
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📜 History</button>
890
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📊 Schema</button>
891
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">💾 Saved</button>
892
+ <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle tabs" type="button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; padding: 4px 8px; font-size: 14px;">▼</button>
893
+ </div>
894
+
895
+ <div class="tab-content" data-collapsible-target="content">
896
+ <!-- History Tab -->
897
+ <div class="tab-pane" data-tabs-target="pane">
898
+ <div data-controller="history">
899
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
900
+ <h4 style="margin: 0; font-size: 14px;">Recent Queries</h4>
901
+ <button class="quick-action-btn" data-action="click->history#clear" type="button">Clear</button>
902
+ </div>
903
+ <ul class="item-list" data-history-target="list"></ul>
904
+ </div>
905
+ </div>
906
+
907
+ <!-- Schema Tab -->
908
+ <div class="tab-pane" data-tabs-target="pane">
909
+ <div data-controller="schema">
910
+ <input
911
+ type="text"
912
+ class="schema-search"
913
+ placeholder="🔍 Search tables..."
914
+ data-schema-target="search"
915
+ data-action="input->schema#filterTables">
916
+ <div data-schema-target="tablesList"></div>
917
+ <div data-schema-target="details" style="margin-top: 15px;"></div>
918
+ </div>
919
+ </div>
920
+
921
+ <!-- Saved Queries Tab -->
922
+ <div class="tab-pane" data-tabs-target="pane">
923
+ <div data-controller="saved">
924
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
925
+ <h4 style="margin: 0; font-size: 14px;">Saved Queries</h4>
926
+ <div style="display: flex; gap: 5px;">
927
+ <button class="quick-action-btn" data-action="click->saved#save" type="button">💾 Save</button>
928
+ <button class="quick-action-btn" data-action="click->saved#exportJSON" type="button">📤 Export</button>
929
+ <button class="quick-action-btn" data-action="click->saved#importJSON" type="button">📥 Import</button>
930
+ </div>
931
+ </div>
932
+ <div data-saved-target="list"></div>
933
+ </div>
934
+ </div>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ </div>
939
+ </div>
940
+ </div>
941
+
942
+ <script>
943
+ // Track query execution for history
944
+ document.addEventListener('turbo:submit-end', (event) => {
945
+ if (window._lastExecutedSQL) {
946
+ document.dispatchEvent(new CustomEvent('editor:executed', {
947
+ detail: {
948
+ sql: window._lastExecutedSQL,
949
+ timestamp: new Date().toISOString()
950
+ }
951
+ }))
952
+ delete window._lastExecutedSQL
953
+ }
954
+ })
563
955
  </script>
564
956
  </body>
565
957
  </html>