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
|
@@ -96,6 +96,49 @@ module QueryConsole
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def execute_with_timeout(sql)
|
|
99
|
+
case @config.timeout_strategy
|
|
100
|
+
when :database
|
|
101
|
+
execute_with_database_timeout(sql)
|
|
102
|
+
when :ruby
|
|
103
|
+
execute_with_ruby_timeout(sql)
|
|
104
|
+
else
|
|
105
|
+
# Auto-detect: use database timeout for PostgreSQL, Ruby timeout otherwise
|
|
106
|
+
if postgresql_connection?
|
|
107
|
+
execute_with_database_timeout(sql)
|
|
108
|
+
else
|
|
109
|
+
execute_with_ruby_timeout(sql)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Database-level timeout (PostgreSQL only)
|
|
115
|
+
# Safer: database cancels the query cleanly, no orphan processes
|
|
116
|
+
def execute_with_database_timeout(sql)
|
|
117
|
+
conn = ActiveRecord::Base.connection
|
|
118
|
+
|
|
119
|
+
unless postgresql_connection?
|
|
120
|
+
Rails.logger.warn("[QueryConsole] Database timeout strategy requires PostgreSQL, falling back to Ruby timeout")
|
|
121
|
+
return execute_with_ruby_timeout(sql)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
conn.transaction do
|
|
125
|
+
# SET LOCAL scopes the timeout to this transaction only
|
|
126
|
+
conn.execute("SET LOCAL statement_timeout = '#{@config.timeout_ms}'")
|
|
127
|
+
conn.exec_query(sql)
|
|
128
|
+
end
|
|
129
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
130
|
+
if e.message.include?("canceling statement due to statement timeout") ||
|
|
131
|
+
e.message.include?("query_canceled")
|
|
132
|
+
raise Timeout::Error, "Query timeout: exceeded #{@config.timeout_ms}ms limit"
|
|
133
|
+
else
|
|
134
|
+
raise
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Ruby-level timeout (fallback for non-PostgreSQL databases)
|
|
139
|
+
# Warning: The database query continues running as an orphan process
|
|
140
|
+
# even after Ruby times out. Can cause resource exhaustion.
|
|
141
|
+
def execute_with_ruby_timeout(sql)
|
|
99
142
|
timeout_seconds = @config.timeout_ms / 1000.0
|
|
100
143
|
|
|
101
144
|
Timeout.timeout(timeout_seconds) do
|
|
@@ -103,6 +146,10 @@ module QueryConsole
|
|
|
103
146
|
end
|
|
104
147
|
end
|
|
105
148
|
|
|
149
|
+
def postgresql_connection?
|
|
150
|
+
ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
|
|
151
|
+
end
|
|
152
|
+
|
|
106
153
|
def format_explain_output(result)
|
|
107
154
|
# For SQLite, the result has columns like: id, parent, notused, detail
|
|
108
155
|
# For Postgres, it's usually a single "QUERY PLAN" column
|
|
@@ -3,15 +3,17 @@ require 'timeout'
|
|
|
3
3
|
module QueryConsole
|
|
4
4
|
class Runner
|
|
5
5
|
class QueryResult
|
|
6
|
-
attr_reader :columns, :rows, :execution_time_ms, :row_count_shown, :truncated, :error
|
|
6
|
+
attr_reader :columns, :rows, :execution_time_ms, :row_count_shown, :truncated, :error, :is_dml, :rows_affected
|
|
7
7
|
|
|
8
|
-
def initialize(columns: [], rows: [], execution_time_ms: 0, row_count_shown: 0, truncated: false, error: nil)
|
|
8
|
+
def initialize(columns: [], rows: [], execution_time_ms: 0, row_count_shown: 0, truncated: false, error: nil, is_dml: false, rows_affected: nil)
|
|
9
9
|
@columns = columns
|
|
10
10
|
@rows = rows
|
|
11
11
|
@execution_time_ms = execution_time_ms
|
|
12
12
|
@row_count_shown = row_count_shown
|
|
13
13
|
@truncated = truncated
|
|
14
14
|
@error = error
|
|
15
|
+
@is_dml = is_dml
|
|
16
|
+
@rows_affected = rows_affected
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def success?
|
|
@@ -25,6 +27,10 @@ module QueryConsole
|
|
|
25
27
|
def truncated?
|
|
26
28
|
@truncated
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
def dml?
|
|
32
|
+
@is_dml
|
|
33
|
+
end
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def initialize(sql, config = QueryConsole.configuration)
|
|
@@ -44,6 +50,7 @@ module QueryConsole
|
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
sanitized_sql = validation_result.sanitized_sql
|
|
53
|
+
is_dml = validation_result.dml?
|
|
47
54
|
|
|
48
55
|
# Step 2: Apply row limit
|
|
49
56
|
limiter = SqlLimiter.new(sanitized_sql, @config.max_rows, @config)
|
|
@@ -56,12 +63,20 @@ module QueryConsole
|
|
|
56
63
|
result = execute_with_timeout(final_sql)
|
|
57
64
|
execution_time = ((Time.now - start_time) * 1000).round(2)
|
|
58
65
|
|
|
66
|
+
# For DML queries, capture the number of affected rows
|
|
67
|
+
rows_affected = nil
|
|
68
|
+
if is_dml
|
|
69
|
+
rows_affected = get_affected_rows_count(result)
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
QueryResult.new(
|
|
60
73
|
columns: result.columns,
|
|
61
74
|
rows: result.rows,
|
|
62
75
|
execution_time_ms: execution_time,
|
|
63
76
|
row_count_shown: result.rows.length,
|
|
64
|
-
truncated: truncated
|
|
77
|
+
truncated: truncated,
|
|
78
|
+
is_dml: is_dml,
|
|
79
|
+
rows_affected: rows_affected
|
|
65
80
|
)
|
|
66
81
|
rescue Timeout::Error
|
|
67
82
|
QueryResult.new(
|
|
@@ -79,11 +94,96 @@ module QueryConsole
|
|
|
79
94
|
attr_reader :sql, :config
|
|
80
95
|
|
|
81
96
|
def execute_with_timeout(sql)
|
|
97
|
+
case @config.timeout_strategy
|
|
98
|
+
when :database
|
|
99
|
+
execute_with_database_timeout(sql)
|
|
100
|
+
when :ruby
|
|
101
|
+
execute_with_ruby_timeout(sql)
|
|
102
|
+
else
|
|
103
|
+
# Auto-detect: use database timeout for PostgreSQL, Ruby timeout otherwise
|
|
104
|
+
if postgresql_connection?
|
|
105
|
+
execute_with_database_timeout(sql)
|
|
106
|
+
else
|
|
107
|
+
execute_with_ruby_timeout(sql)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Database-level timeout (PostgreSQL only)
|
|
113
|
+
# Safer: database cancels the query cleanly, no orphan processes
|
|
114
|
+
def execute_with_database_timeout(sql)
|
|
115
|
+
conn = ActiveRecord::Base.connection
|
|
116
|
+
|
|
117
|
+
unless postgresql_connection?
|
|
118
|
+
Rails.logger.warn("[QueryConsole] Database timeout strategy requires PostgreSQL, falling back to Ruby timeout")
|
|
119
|
+
return execute_with_ruby_timeout(sql)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
conn.transaction do
|
|
123
|
+
# SET LOCAL scopes the timeout to this transaction only
|
|
124
|
+
conn.execute("SET LOCAL statement_timeout = '#{@config.timeout_ms}'")
|
|
125
|
+
conn.exec_query(sql)
|
|
126
|
+
end
|
|
127
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
128
|
+
if e.message.include?("canceling statement due to statement timeout") ||
|
|
129
|
+
e.message.include?("query_canceled")
|
|
130
|
+
raise Timeout::Error, "Query timeout: exceeded #{@config.timeout_ms}ms limit"
|
|
131
|
+
else
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Ruby-level timeout (fallback for non-PostgreSQL databases)
|
|
137
|
+
# Warning: The database query continues running as an orphan process
|
|
138
|
+
# even after Ruby times out. Can cause resource exhaustion.
|
|
139
|
+
def execute_with_ruby_timeout(sql)
|
|
82
140
|
timeout_seconds = @config.timeout_ms / 1000.0
|
|
83
141
|
|
|
84
142
|
Timeout.timeout(timeout_seconds) do
|
|
85
143
|
ActiveRecord::Base.connection.exec_query(sql)
|
|
86
144
|
end
|
|
87
145
|
end
|
|
146
|
+
|
|
147
|
+
def postgresql_connection?
|
|
148
|
+
ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get the number of rows affected by a DML query
|
|
152
|
+
# This is database-specific, so we try different approaches
|
|
153
|
+
def get_affected_rows_count(result)
|
|
154
|
+
conn = ActiveRecord::Base.connection
|
|
155
|
+
|
|
156
|
+
# For SQLite, use the raw connection's changes method
|
|
157
|
+
if conn.adapter_name.downcase.include?('sqlite')
|
|
158
|
+
return conn.raw_connection.changes
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# For PostgreSQL, MySQL, and others, check if result has rows_affected
|
|
162
|
+
# Note: exec_query doesn't always provide this, but we can try
|
|
163
|
+
if result.respond_to?(:rows_affected)
|
|
164
|
+
return result.rows_affected
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Fallback: try to get it from the connection's last result
|
|
168
|
+
begin
|
|
169
|
+
if conn.respond_to?(:raw_connection)
|
|
170
|
+
raw_conn = conn.raw_connection
|
|
171
|
+
|
|
172
|
+
# PostgreSQL
|
|
173
|
+
if raw_conn.respond_to?(:cmd_tuples)
|
|
174
|
+
return raw_conn.cmd_tuples
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# MySQL
|
|
178
|
+
if raw_conn.respond_to?(:affected_rows)
|
|
179
|
+
return raw_conn.affected_rows
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
rescue
|
|
183
|
+
# If we can't determine affected rows, return nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
88
188
|
end
|
|
89
189
|
end
|
|
@@ -20,6 +20,11 @@ module QueryConsole
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def apply_limit
|
|
23
|
+
# Skip limiting for DML queries (INSERT, UPDATE, DELETE, MERGE)
|
|
24
|
+
if is_dml_query?
|
|
25
|
+
return LimitResult.new(sql: @sql, truncated: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
# Check if query already has a LIMIT clause
|
|
24
29
|
if sql_has_limit?
|
|
25
30
|
LimitResult.new(sql: @sql, truncated: false)
|
|
@@ -33,6 +38,11 @@ module QueryConsole
|
|
|
33
38
|
|
|
34
39
|
attr_reader :sql, :max_rows, :config
|
|
35
40
|
|
|
41
|
+
def is_dml_query?
|
|
42
|
+
# Check if query is a DML operation (INSERT, UPDATE, DELETE, MERGE)
|
|
43
|
+
@sql.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
def sql_has_limit?
|
|
37
47
|
# Check for LIMIT clause (case-insensitive)
|
|
38
48
|
# Match: "LIMIT", " LIMIT ", etc.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
module QueryConsole
|
|
2
2
|
class SqlValidator
|
|
3
3
|
class ValidationResult
|
|
4
|
-
attr_reader :valid, :error, :sanitized_sql
|
|
4
|
+
attr_reader :valid, :error, :sanitized_sql, :is_dml
|
|
5
5
|
|
|
6
|
-
def initialize(valid:, sanitized_sql: nil, error: nil)
|
|
6
|
+
def initialize(valid:, sanitized_sql: nil, error: nil, is_dml: false)
|
|
7
7
|
@valid = valid
|
|
8
8
|
@sanitized_sql = sanitized_sql
|
|
9
9
|
@error = error
|
|
10
|
+
@is_dml = is_dml
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def valid?
|
|
@@ -16,6 +17,10 @@ module QueryConsole
|
|
|
16
17
|
def invalid?
|
|
17
18
|
!@valid
|
|
18
19
|
end
|
|
20
|
+
|
|
21
|
+
def dml?
|
|
22
|
+
@is_dml
|
|
23
|
+
end
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
def initialize(sql, config = QueryConsole.configuration)
|
|
@@ -41,16 +46,35 @@ module QueryConsole
|
|
|
41
46
|
|
|
42
47
|
# Check if query starts with allowed keywords
|
|
43
48
|
normalized_start = sanitized.downcase
|
|
44
|
-
|
|
49
|
+
|
|
50
|
+
# Define DML-specific keywords that are conditionally allowed
|
|
51
|
+
dml_keywords = %w[insert update delete merge]
|
|
52
|
+
|
|
53
|
+
# Expand allowed_starts_with if DML is enabled
|
|
54
|
+
effective_allowed = if @config.enable_dml
|
|
55
|
+
@config.allowed_starts_with + dml_keywords
|
|
56
|
+
else
|
|
57
|
+
@config.allowed_starts_with
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless effective_allowed.any? { |keyword| normalized_start.start_with?(keyword) }
|
|
45
61
|
return ValidationResult.new(
|
|
46
62
|
valid: false,
|
|
47
|
-
error: "Query must start with one of: #{
|
|
63
|
+
error: "Query must start with one of: #{effective_allowed.join(', ').upcase}"
|
|
48
64
|
)
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
# Check for forbidden keywords
|
|
52
68
|
normalized_query = sanitized.downcase
|
|
53
|
-
|
|
69
|
+
|
|
70
|
+
# Filter forbidden keywords based on DML enablement
|
|
71
|
+
effective_forbidden = if @config.enable_dml
|
|
72
|
+
@config.forbidden_keywords.reject { |kw| dml_keywords.include?(kw) || kw == 'replace' || kw == 'into' }
|
|
73
|
+
else
|
|
74
|
+
@config.forbidden_keywords
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
forbidden = effective_forbidden.find do |keyword|
|
|
54
78
|
# Match whole words to avoid false positives (e.g., "updates" table name)
|
|
55
79
|
normalized_query.match?(/\b#{Regexp.escape(keyword.downcase)}\b/)
|
|
56
80
|
end
|
|
@@ -62,7 +86,10 @@ module QueryConsole
|
|
|
62
86
|
)
|
|
63
87
|
end
|
|
64
88
|
|
|
65
|
-
|
|
89
|
+
# Detect if this is a DML query
|
|
90
|
+
is_dml_query = sanitized.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
91
|
+
|
|
92
|
+
ValidationResult.new(valid: true, sanitized_sql: sanitized, is_dml: is_dml_query)
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
private
|
|
@@ -116,10 +116,33 @@
|
|
|
116
116
|
color: #999;
|
|
117
117
|
font-style: italic;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
.dml-warning {
|
|
121
|
+
background-color: #fff3cd;
|
|
122
|
+
border-left: 4px solid #ffc107;
|
|
123
|
+
padding: 12px 16px;
|
|
124
|
+
margin-bottom: 16px;
|
|
125
|
+
border-radius: 4px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dml-warning-icon {
|
|
129
|
+
color: #ff6b6b;
|
|
130
|
+
font-weight: bold;
|
|
131
|
+
margin-right: 8px;
|
|
132
|
+
}
|
|
119
133
|
</style>
|
|
120
134
|
|
|
121
135
|
<div class="results-container">
|
|
122
136
|
<% result_to_render = local_assigns[:result] || @result %>
|
|
137
|
+
<% is_dml_result = local_assigns[:is_dml] || @is_dml %>
|
|
138
|
+
|
|
139
|
+
<% if is_dml_result %>
|
|
140
|
+
<div class="dml-warning">
|
|
141
|
+
<span class="dml-warning-icon">ℹ️</span>
|
|
142
|
+
<strong>Data Modified:</strong>
|
|
143
|
+
This query has modified the database. All changes are logged.
|
|
144
|
+
</div>
|
|
145
|
+
<% end %>
|
|
123
146
|
|
|
124
147
|
<% if result_to_render.error %>
|
|
125
148
|
<div class="error-message">
|
|
@@ -136,8 +159,13 @@
|
|
|
136
159
|
<span class="metadata-value"><%= result_to_render.execution_time_ms %>ms</span>
|
|
137
160
|
</div>
|
|
138
161
|
<div class="metadata-item">
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
<% if is_dml_result && result_to_render.rows_affected %>
|
|
163
|
+
<span class="metadata-label">Rows Affected:</span>
|
|
164
|
+
<span class="metadata-value"><%= result_to_render.rows_affected %></span>
|
|
165
|
+
<% else %>
|
|
166
|
+
<span class="metadata-label">Rows:</span>
|
|
167
|
+
<span class="metadata-value"><%= result_to_render.row_count_shown %></span>
|
|
168
|
+
<% end %>
|
|
141
169
|
</div>
|
|
142
170
|
<% if result_to_render.truncated %>
|
|
143
171
|
<div class="metadata-item">
|
|
@@ -148,7 +176,11 @@
|
|
|
148
176
|
|
|
149
177
|
<% if result_to_render.rows.empty? %>
|
|
150
178
|
<div class="empty-results">
|
|
151
|
-
|
|
179
|
+
<% if is_dml_result && result_to_render.rows_affected %>
|
|
180
|
+
<%= result_to_render.rows_affected %> row(s) affected
|
|
181
|
+
<% else %>
|
|
182
|
+
No rows returned
|
|
183
|
+
<% end %>
|
|
152
184
|
</div>
|
|
153
185
|
<% else %>
|
|
154
186
|
<div class="table-wrapper">
|