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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +382 -0
- data/Rakefile +11 -0
- data/app/controllers/query_console/application_controller.rb +38 -0
- data/app/controllers/query_console/queries_controller.rb +43 -0
- data/app/javascript/query_console/application.js +20 -0
- data/app/javascript/query_console/controllers/collapsible_controller.js +42 -0
- data/app/javascript/query_console/controllers/editor_controller.js +77 -0
- data/app/javascript/query_console/controllers/history_controller.js +124 -0
- data/app/services/query_console/audit_logger.rb +50 -0
- data/app/services/query_console/runner.rb +89 -0
- data/app/services/query_console/sql_limiter.rb +48 -0
- data/app/services/query_console/sql_validator.rb +72 -0
- data/app/views/query_console/queries/_results.html.erb +191 -0
- data/app/views/query_console/queries/new.html.erb +565 -0
- data/config/importmap.rb +13 -0
- data/config/routes.rb +4 -0
- data/lib/generators/query_console/install/README +28 -0
- data/lib/generators/query_console/install/install_generator.rb +19 -0
- data/lib/generators/query_console/install/templates/query_console.rb +61 -0
- data/lib/query_console/configuration.rb +41 -0
- data/lib/query_console/engine.rb +29 -0
- data/lib/query_console/version.rb +3 -0
- data/lib/query_console.rb +7 -0
- metadata +159 -0
|
@@ -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 %>
|