query_console 0.1.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.
@@ -0,0 +1,124 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Manages query history stored in localStorage
4
+ // Usage: <div data-controller="history">
5
+ export default class extends Controller {
6
+ static targets = ["list", "item", "emptyMessage"]
7
+ static values = {
8
+ storageKey: { type: String, default: "query_console.history.v1" },
9
+ maxItems: { type: Number, default: 20 }
10
+ }
11
+
12
+ connect() {
13
+ this.loadHistory()
14
+ }
15
+
16
+ // Add query to history after successful execution
17
+ add(event) {
18
+ const sql = event.detail.sql
19
+ const timestamp = event.detail.timestamp || new Date().toISOString()
20
+
21
+ const history = this.getHistory()
22
+
23
+ // Add new item to beginning
24
+ history.unshift({
25
+ sql: sql.trim(),
26
+ timestamp: timestamp
27
+ })
28
+
29
+ // Keep only max items
30
+ const trimmed = history.slice(0, this.maxItemsValue)
31
+ this.saveHistory(trimmed)
32
+ this.renderHistory(trimmed)
33
+ }
34
+
35
+ // Load query from history into editor
36
+ load(event) {
37
+ event.preventDefault()
38
+ const sql = event.currentTarget.dataset.sql
39
+
40
+ // Dispatch custom event that editor controller will listen to
41
+ this.dispatch("load", { detail: { sql } })
42
+ }
43
+
44
+ // Clear all history
45
+ clear(event) {
46
+ event.preventDefault()
47
+
48
+ if (confirm("Clear all query history?")) {
49
+ localStorage.removeItem(this.storageKeyValue)
50
+ this.renderHistory([])
51
+ }
52
+ }
53
+
54
+ // Private methods
55
+
56
+ loadHistory() {
57
+ const history = this.getHistory()
58
+ this.renderHistory(history)
59
+ }
60
+
61
+ getHistory() {
62
+ const stored = localStorage.getItem(this.storageKeyValue)
63
+ return stored ? JSON.parse(stored) : []
64
+ }
65
+
66
+ saveHistory(history) {
67
+ localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
68
+ }
69
+
70
+ renderHistory(history) {
71
+ if (history.length === 0) {
72
+ this.listTarget.innerHTML = '<li class="empty-history">No query history yet</li>'
73
+ return
74
+ }
75
+
76
+ this.listTarget.innerHTML = history.map((item, index) => `
77
+ <li class="history-item">
78
+ <button
79
+ type="button"
80
+ class="history-item-button"
81
+ data-action="click->history#load"
82
+ data-sql="${this.escapeHtml(item.sql)}"
83
+ title="${this.escapeHtml(item.sql)}">
84
+ <div class="history-item-sql">${this.escapeHtml(this.truncate(item.sql, 100))}</div>
85
+ <div class="history-item-time">${this.formatTime(item.timestamp)}</div>
86
+ </button>
87
+ </li>
88
+ `).join('')
89
+ }
90
+
91
+ truncate(str, length) {
92
+ return str.length > length ? str.substring(0, length) + '...' : str
93
+ }
94
+
95
+ formatTime(timestamp) {
96
+ const date = new Date(timestamp)
97
+ const now = new Date()
98
+ const diff = now - date
99
+
100
+ // Less than 1 minute
101
+ if (diff < 60000) return 'just now'
102
+
103
+ // Less than 1 hour
104
+ if (diff < 3600000) {
105
+ const minutes = Math.floor(diff / 60000)
106
+ return `${minutes}m ago`
107
+ }
108
+
109
+ // Less than 24 hours
110
+ if (diff < 86400000) {
111
+ const hours = Math.floor(diff / 3600000)
112
+ return `${hours}h ago`
113
+ }
114
+
115
+ // More than 24 hours
116
+ return date.toLocaleDateString()
117
+ }
118
+
119
+ escapeHtml(text) {
120
+ const div = document.createElement('div')
121
+ div.textContent = text
122
+ return div.innerHTML
123
+ }
124
+ }
@@ -0,0 +1,50 @@
1
+ module QueryConsole
2
+ class AuditLogger
3
+ def self.log_query(sql:, result:, actor: "unknown", controller: nil)
4
+ config = QueryConsole.configuration
5
+
6
+ # Resolve actor from config if controller is provided
7
+ resolved_actor = if controller && config.current_actor.respond_to?(:call)
8
+ config.current_actor.call(controller)
9
+ else
10
+ actor
11
+ end
12
+
13
+ log_data = {
14
+ component: "query_console",
15
+ actor: resolved_actor,
16
+ sql: sql.to_s.strip,
17
+ duration_ms: result.execution_time_ms,
18
+ rows: result.row_count_shown,
19
+ status: result.success? ? "ok" : "error"
20
+ }
21
+
22
+ if result.failure?
23
+ log_data[:error] = result.error
24
+ log_data[:error_class] = determine_error_class(result.error)
25
+ end
26
+
27
+ if result.truncated?
28
+ log_data[:truncated] = true
29
+ log_data[:max_rows] = config.max_rows
30
+ end
31
+
32
+ Rails.logger.info(log_data.to_json)
33
+ end
34
+
35
+ def self.determine_error_class(error_message)
36
+ case error_message
37
+ when /timeout/i
38
+ "TimeoutError"
39
+ when /forbidden keyword/i
40
+ "SecurityError"
41
+ when /multiple statements/i
42
+ "SecurityError"
43
+ when /must start with/i
44
+ "ValidationError"
45
+ else
46
+ "QueryError"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,89 @@
1
+ require 'timeout'
2
+
3
+ module QueryConsole
4
+ class Runner
5
+ class QueryResult
6
+ attr_reader :columns, :rows, :execution_time_ms, :row_count_shown, :truncated, :error
7
+
8
+ def initialize(columns: [], rows: [], execution_time_ms: 0, row_count_shown: 0, truncated: false, error: nil)
9
+ @columns = columns
10
+ @rows = rows
11
+ @execution_time_ms = execution_time_ms
12
+ @row_count_shown = row_count_shown
13
+ @truncated = truncated
14
+ @error = error
15
+ end
16
+
17
+ def success?
18
+ @error.nil?
19
+ end
20
+
21
+ def failure?
22
+ !success?
23
+ end
24
+
25
+ def truncated?
26
+ @truncated
27
+ end
28
+ end
29
+
30
+ def initialize(sql, config = QueryConsole.configuration)
31
+ @sql = sql
32
+ @config = config
33
+ end
34
+
35
+ def execute
36
+ start_time = Time.now
37
+
38
+ # Step 1: Validate SQL
39
+ validator = SqlValidator.new(@sql, @config)
40
+ validation_result = validator.validate
41
+
42
+ if validation_result.invalid?
43
+ return QueryResult.new(error: validation_result.error)
44
+ end
45
+
46
+ sanitized_sql = validation_result.sanitized_sql
47
+
48
+ # Step 2: Apply row limit
49
+ limiter = SqlLimiter.new(sanitized_sql, @config.max_rows, @config)
50
+ limit_result = limiter.apply_limit
51
+ final_sql = limit_result.sql
52
+ truncated = limit_result.truncated?
53
+
54
+ # Step 3: Execute query with timeout
55
+ begin
56
+ result = execute_with_timeout(final_sql)
57
+ execution_time = ((Time.now - start_time) * 1000).round(2)
58
+
59
+ QueryResult.new(
60
+ columns: result.columns,
61
+ rows: result.rows,
62
+ execution_time_ms: execution_time,
63
+ row_count_shown: result.rows.length,
64
+ truncated: truncated
65
+ )
66
+ rescue Timeout::Error
67
+ QueryResult.new(
68
+ error: "Query timeout: exceeded #{@config.timeout_ms}ms limit"
69
+ )
70
+ rescue StandardError => e
71
+ QueryResult.new(
72
+ error: "Query error: #{e.message}"
73
+ )
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :sql, :config
80
+
81
+ def execute_with_timeout(sql)
82
+ timeout_seconds = @config.timeout_ms / 1000.0
83
+
84
+ Timeout.timeout(timeout_seconds) do
85
+ ActiveRecord::Base.connection.exec_query(sql)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,48 @@
1
+ module QueryConsole
2
+ class SqlLimiter
3
+ class LimitResult
4
+ attr_reader :sql, :truncated
5
+
6
+ def initialize(sql:, truncated:)
7
+ @sql = sql
8
+ @truncated = truncated
9
+ end
10
+
11
+ def truncated?
12
+ @truncated
13
+ end
14
+ end
15
+
16
+ def initialize(sql, max_rows, config = QueryConsole.configuration)
17
+ @sql = sql
18
+ @max_rows = max_rows
19
+ @config = config
20
+ end
21
+
22
+ def apply_limit
23
+ # Check if query already has a LIMIT clause
24
+ if sql_has_limit?
25
+ LimitResult.new(sql: @sql, truncated: false)
26
+ else
27
+ wrapped_sql = wrap_with_limit(@sql, @max_rows)
28
+ LimitResult.new(sql: wrapped_sql, truncated: true)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :sql, :max_rows, :config
35
+
36
+ def sql_has_limit?
37
+ # Check for LIMIT clause (case-insensitive)
38
+ # Match: "LIMIT", " LIMIT ", etc.
39
+ @sql.match?(/\bLIMIT\b/i)
40
+ end
41
+
42
+ def wrap_with_limit(sql, limit)
43
+ # Wrap the query in a subquery with LIMIT
44
+ # This preserves most SQL semantics including ORDER BY, etc.
45
+ "SELECT * FROM (#{sql}) qc_subquery LIMIT #{limit}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,72 @@
1
+ module QueryConsole
2
+ class SqlValidator
3
+ class ValidationResult
4
+ attr_reader :valid, :error, :sanitized_sql
5
+
6
+ def initialize(valid:, sanitized_sql: nil, error: nil)
7
+ @valid = valid
8
+ @sanitized_sql = sanitized_sql
9
+ @error = error
10
+ end
11
+
12
+ def valid?
13
+ @valid
14
+ end
15
+
16
+ def invalid?
17
+ !@valid
18
+ end
19
+ end
20
+
21
+ def initialize(sql, config = QueryConsole.configuration)
22
+ @sql = sql
23
+ @config = config
24
+ end
25
+
26
+ def validate
27
+ return ValidationResult.new(valid: false, error: "Query cannot be empty") if sql.nil? || sql.strip.empty?
28
+
29
+ sanitized = sql.strip
30
+
31
+ # Remove a single trailing semicolon if present
32
+ sanitized = sanitized.sub(/;\s*\z/, '')
33
+
34
+ # Check for multiple statements (any remaining semicolons)
35
+ if sanitized.include?(';')
36
+ return ValidationResult.new(
37
+ valid: false,
38
+ error: "Multiple statements are not allowed. Only single SELECT or WITH queries are permitted."
39
+ )
40
+ end
41
+
42
+ # Check if query starts with allowed keywords
43
+ normalized_start = sanitized.downcase
44
+ unless @config.allowed_starts_with.any? { |keyword| normalized_start.start_with?(keyword) }
45
+ return ValidationResult.new(
46
+ valid: false,
47
+ error: "Query must start with one of: #{@config.allowed_starts_with.join(', ').upcase}"
48
+ )
49
+ end
50
+
51
+ # Check for forbidden keywords
52
+ normalized_query = sanitized.downcase
53
+ forbidden = @config.forbidden_keywords.find do |keyword|
54
+ # Match whole words to avoid false positives (e.g., "updates" table name)
55
+ normalized_query.match?(/\b#{Regexp.escape(keyword.downcase)}\b/)
56
+ end
57
+
58
+ if forbidden
59
+ return ValidationResult.new(
60
+ valid: false,
61
+ error: "Forbidden keyword detected: #{forbidden.upcase}. Only read-only SELECT queries are allowed."
62
+ )
63
+ end
64
+
65
+ ValidationResult.new(valid: true, sanitized_sql: sanitized)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :sql, :config
71
+ end
72
+ end
@@ -0,0 +1,191 @@
1
+ <%= turbo_frame_tag "query-results" do %>
2
+ <style>
3
+ .results-container {
4
+ margin-top: 20px;
5
+ }
6
+
7
+ .error-message {
8
+ background: #f8d7da;
9
+ color: #721c24;
10
+ padding: 15px;
11
+ border-radius: 4px;
12
+ border: 1px solid #f5c6cb;
13
+ margin-bottom: 15px;
14
+ }
15
+
16
+ .success-message {
17
+ background: #d4edda;
18
+ color: #155724;
19
+ padding: 15px;
20
+ border-radius: 4px;
21
+ border: 1px solid #c3e6cb;
22
+ margin-bottom: 15px;
23
+ }
24
+
25
+ .metadata {
26
+ background: #e7f3ff;
27
+ padding: 12px;
28
+ border-radius: 4px;
29
+ margin-bottom: 15px;
30
+ font-size: 14px;
31
+ display: flex;
32
+ gap: 20px;
33
+ flex-wrap: wrap;
34
+ }
35
+
36
+ .metadata-item {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 5px;
40
+ }
41
+
42
+ .metadata-label {
43
+ font-weight: 600;
44
+ color: #495057;
45
+ }
46
+
47
+ .metadata-value {
48
+ color: #007bff;
49
+ }
50
+
51
+ .warning-badge {
52
+ background: #fff3cd;
53
+ color: #856404;
54
+ padding: 4px 8px;
55
+ border-radius: 3px;
56
+ font-size: 12px;
57
+ font-weight: 600;
58
+ }
59
+
60
+ .table-wrapper {
61
+ overflow: auto;
62
+ border: 1px solid #dee2e6;
63
+ border-radius: 4px;
64
+ max-height: 500px;
65
+ position: relative;
66
+ }
67
+
68
+ .results-table {
69
+ width: 100%;
70
+ border-collapse: collapse;
71
+ font-size: 14px;
72
+ background: white;
73
+ }
74
+
75
+ .results-table thead {
76
+ background: #f8f9fa;
77
+ position: sticky;
78
+ top: 0;
79
+ z-index: 10;
80
+ }
81
+
82
+ .results-table th {
83
+ padding: 12px;
84
+ text-align: left;
85
+ font-weight: 600;
86
+ color: #495057;
87
+ border-bottom: 2px solid #dee2e6;
88
+ white-space: nowrap;
89
+ }
90
+
91
+ .results-table td {
92
+ padding: 10px 12px;
93
+ border-bottom: 1px solid #dee2e6;
94
+ color: #212529;
95
+ }
96
+
97
+ .results-table tbody tr:hover {
98
+ background: #f8f9fa;
99
+ }
100
+
101
+ .results-table tbody tr:last-child td {
102
+ border-bottom: none;
103
+ }
104
+
105
+ .empty-results {
106
+ text-align: center;
107
+ padding: 40px;
108
+ color: #6c757d;
109
+ }
110
+
111
+ .null-value {
112
+ color: #999;
113
+ font-style: italic;
114
+ }
115
+ </style>
116
+
117
+ <div class="results-container">
118
+ <% result_to_render = local_assigns[:result] || @result %>
119
+
120
+ <% if result_to_render.error %>
121
+ <div class="error-message">
122
+ <strong>Error:</strong> <%= result_to_render.error %>
123
+ </div>
124
+ <% else %>
125
+ <div class="success-message">
126
+ ✓ Query executed successfully
127
+ </div>
128
+
129
+ <div class="metadata">
130
+ <div class="metadata-item">
131
+ <span class="metadata-label">Execution Time:</span>
132
+ <span class="metadata-value"><%= result_to_render.execution_time_ms %>ms</span>
133
+ </div>
134
+ <div class="metadata-item">
135
+ <span class="metadata-label">Rows:</span>
136
+ <span class="metadata-value"><%= result_to_render.row_count_shown %></span>
137
+ </div>
138
+ <% if result_to_render.truncated %>
139
+ <div class="metadata-item">
140
+ <span class="warning-badge">⚠ Results limited to <%= QueryConsole.configuration.max_rows %> rows</span>
141
+ </div>
142
+ <% end %>
143
+ </div>
144
+
145
+ <% if result_to_render.rows.empty? %>
146
+ <div class="empty-results">
147
+ No rows returned
148
+ </div>
149
+ <% else %>
150
+ <div class="table-wrapper">
151
+ <table class="results-table">
152
+ <thead>
153
+ <tr>
154
+ <% result_to_render.columns.each do |column| %>
155
+ <th><%= column %></th>
156
+ <% end %>
157
+ </tr>
158
+ </thead>
159
+ <tbody>
160
+ <% result_to_render.rows.each do |row| %>
161
+ <tr>
162
+ <% row.each do |value| %>
163
+ <td>
164
+ <% if value.nil? %>
165
+ <span class="null-value">NULL</span>
166
+ <% else %>
167
+ <%= value %>
168
+ <% end %>
169
+ </td>
170
+ <% end %>
171
+ </tr>
172
+ <% end %>
173
+ </tbody>
174
+ </table>
175
+ </div>
176
+ <% end %>
177
+ <% end %>
178
+ </div>
179
+
180
+ <script type="module">
181
+ // After results are rendered, dispatch event to add to history
182
+ const sql = new URLSearchParams(window.location.search).get('sql')
183
+ if (sql) {
184
+ const event = new CustomEvent('editor:executed', {
185
+ detail: { sql: sql, timestamp: new Date().toISOString() },
186
+ bubbles: true
187
+ })
188
+ document.dispatchEvent(event)
189
+ }
190
+ </script>
191
+ <% end %>