query_console 0.1.0 → 0.2.1
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 +204 -28
- data/app/controllers/query_console/application_controller.rb +6 -3
- data/app/controllers/query_console/explain_controller.rb +47 -0
- data/app/controllers/query_console/queries_controller.rb +4 -1
- data/app/controllers/query_console/schema_controller.rb +32 -0
- data/app/javascript/query_console/controllers/editor_controller.js +182 -45
- data/app/services/query_console/audit_logger.rb +29 -3
- data/app/services/query_console/explain_runner.rb +137 -0
- data/app/services/query_console/runner.rb +56 -3
- data/app/services/query_console/schema_introspector.rb +244 -0
- 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/explain/_results.html.erb +89 -0
- data/app/views/query_console/queries/_results.html.erb +40 -4
- data/app/views/query_console/queries/new.html.erb +843 -328
- data/config/importmap.rb +8 -0
- data/config/routes.rb +5 -0
- data/lib/query_console/configuration.rb +21 -1
- data/lib/query_console/version.rb +1 -1
- metadata +16 -14
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
module QueryConsole
|
|
2
|
+
class SchemaIntrospector
|
|
3
|
+
def initialize(config = QueryConsole.configuration)
|
|
4
|
+
@config = config
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def tables
|
|
8
|
+
return [] unless @config.schema_explorer
|
|
9
|
+
|
|
10
|
+
Rails.cache.fetch(cache_key_tables, expires_in: @config.schema_cache_seconds) do
|
|
11
|
+
fetch_tables
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def table_details(table_name)
|
|
16
|
+
return nil unless @config.schema_explorer
|
|
17
|
+
return nil if table_denied?(table_name)
|
|
18
|
+
|
|
19
|
+
Rails.cache.fetch(cache_key_table(table_name), expires_in: @config.schema_cache_seconds) do
|
|
20
|
+
fetch_table_details(table_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :config
|
|
27
|
+
|
|
28
|
+
def fetch_tables
|
|
29
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name
|
|
30
|
+
|
|
31
|
+
raw_tables = case adapter_name
|
|
32
|
+
when "PostgreSQL"
|
|
33
|
+
fetch_postgresql_tables
|
|
34
|
+
when "Mysql2", "Trilogy"
|
|
35
|
+
fetch_mysql_tables
|
|
36
|
+
when "SQLite"
|
|
37
|
+
fetch_sqlite_tables
|
|
38
|
+
else
|
|
39
|
+
# Fallback for other adapters
|
|
40
|
+
ActiveRecord::Base.connection.tables.map { |name| { name: name, kind: "table" } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Apply filtering
|
|
44
|
+
filter_tables(raw_tables)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fetch_postgresql_tables
|
|
48
|
+
sql = <<~SQL
|
|
49
|
+
SELECT
|
|
50
|
+
table_name as name,
|
|
51
|
+
table_type as kind
|
|
52
|
+
FROM information_schema.tables
|
|
53
|
+
WHERE table_schema = 'public'
|
|
54
|
+
ORDER BY table_name
|
|
55
|
+
SQL
|
|
56
|
+
|
|
57
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
58
|
+
result.rows.map do |row|
|
|
59
|
+
{
|
|
60
|
+
name: row[0],
|
|
61
|
+
kind: row[1].downcase.include?("view") ? "view" : "table"
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_mysql_tables
|
|
67
|
+
sql = <<~SQL
|
|
68
|
+
SELECT
|
|
69
|
+
table_name as name,
|
|
70
|
+
table_type as kind
|
|
71
|
+
FROM information_schema.tables
|
|
72
|
+
WHERE table_schema = DATABASE()
|
|
73
|
+
ORDER BY table_name
|
|
74
|
+
SQL
|
|
75
|
+
|
|
76
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
77
|
+
result.rows.map do |row|
|
|
78
|
+
{
|
|
79
|
+
name: row[0],
|
|
80
|
+
kind: row[1].downcase.include?("view") ? "view" : "table"
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_sqlite_tables
|
|
86
|
+
sql = <<~SQL
|
|
87
|
+
SELECT name, type
|
|
88
|
+
FROM sqlite_master
|
|
89
|
+
WHERE type IN ('table', 'view')
|
|
90
|
+
AND name NOT LIKE 'sqlite_%'
|
|
91
|
+
ORDER BY name
|
|
92
|
+
SQL
|
|
93
|
+
|
|
94
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
95
|
+
result.rows.map do |row|
|
|
96
|
+
{
|
|
97
|
+
name: row[0],
|
|
98
|
+
kind: row[1]
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fetch_table_details(table_name)
|
|
104
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name
|
|
105
|
+
|
|
106
|
+
columns = case adapter_name
|
|
107
|
+
when "PostgreSQL"
|
|
108
|
+
fetch_postgresql_columns(table_name)
|
|
109
|
+
when "Mysql2", "Trilogy"
|
|
110
|
+
fetch_mysql_columns(table_name)
|
|
111
|
+
when "SQLite"
|
|
112
|
+
fetch_sqlite_columns(table_name)
|
|
113
|
+
else
|
|
114
|
+
# Fallback using ActiveRecord
|
|
115
|
+
fetch_activerecord_columns(table_name)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
name: table_name,
|
|
120
|
+
columns: columns
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fetch_postgresql_columns(table_name)
|
|
125
|
+
sql = <<~SQL
|
|
126
|
+
SELECT
|
|
127
|
+
column_name,
|
|
128
|
+
data_type,
|
|
129
|
+
is_nullable,
|
|
130
|
+
column_default
|
|
131
|
+
FROM information_schema.columns
|
|
132
|
+
WHERE table_schema = 'public'
|
|
133
|
+
AND table_name = '#{sanitize_table_name(table_name)}'
|
|
134
|
+
ORDER BY ordinal_position
|
|
135
|
+
SQL
|
|
136
|
+
|
|
137
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
138
|
+
result.rows.map do |row|
|
|
139
|
+
{
|
|
140
|
+
name: row[0],
|
|
141
|
+
db_type: row[1],
|
|
142
|
+
nullable: row[2] == "YES",
|
|
143
|
+
default: row[3]
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch_mysql_columns(table_name)
|
|
149
|
+
sql = <<~SQL
|
|
150
|
+
SELECT
|
|
151
|
+
column_name,
|
|
152
|
+
data_type,
|
|
153
|
+
is_nullable,
|
|
154
|
+
column_default
|
|
155
|
+
FROM information_schema.columns
|
|
156
|
+
WHERE table_schema = DATABASE()
|
|
157
|
+
AND table_name = '#{sanitize_table_name(table_name)}'
|
|
158
|
+
ORDER BY ordinal_position
|
|
159
|
+
SQL
|
|
160
|
+
|
|
161
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
162
|
+
result.rows.map do |row|
|
|
163
|
+
{
|
|
164
|
+
name: row[0],
|
|
165
|
+
db_type: row[1],
|
|
166
|
+
nullable: row[2] == "YES",
|
|
167
|
+
default: row[3]
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fetch_sqlite_columns(table_name)
|
|
173
|
+
sql = "PRAGMA table_info(#{sanitize_table_name(table_name)})"
|
|
174
|
+
result = ActiveRecord::Base.connection.exec_query(sql)
|
|
175
|
+
|
|
176
|
+
result.rows.map do |row|
|
|
177
|
+
# SQLite PRAGMA returns: cid, name, type, notnull, dflt_value, pk
|
|
178
|
+
{
|
|
179
|
+
name: row[1],
|
|
180
|
+
db_type: row[2],
|
|
181
|
+
nullable: row[3] == 0,
|
|
182
|
+
default: row[4]
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def fetch_activerecord_columns(table_name)
|
|
188
|
+
columns = ActiveRecord::Base.connection.columns(table_name)
|
|
189
|
+
columns.map do |col|
|
|
190
|
+
{
|
|
191
|
+
name: col.name,
|
|
192
|
+
db_type: col.sql_type,
|
|
193
|
+
nullable: col.null,
|
|
194
|
+
default: col.default
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
rescue StandardError
|
|
198
|
+
[]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def filter_tables(tables)
|
|
202
|
+
tables.select do |table|
|
|
203
|
+
# Check denylist
|
|
204
|
+
next false if @config.schema_table_denylist.include?(table[:name])
|
|
205
|
+
|
|
206
|
+
# Check allowlist (if present)
|
|
207
|
+
if @config.schema_allowlist.any?
|
|
208
|
+
next @config.schema_allowlist.include?(table[:name])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def table_denied?(table_name)
|
|
216
|
+
# Check denylist
|
|
217
|
+
return true if @config.schema_table_denylist.include?(table_name)
|
|
218
|
+
|
|
219
|
+
# Check allowlist (if present)
|
|
220
|
+
if @config.schema_allowlist.any?
|
|
221
|
+
return !@config.schema_allowlist.include?(table_name)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def sanitize_table_name(name)
|
|
228
|
+
# Basic SQL injection protection - only allow alphanumeric, underscore
|
|
229
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, '')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def cache_key_tables
|
|
233
|
+
"query_console/schema/tables/#{adapter_identifier}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def cache_key_table(table_name)
|
|
237
|
+
"query_console/schema/table/#{adapter_identifier}/#{table_name}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def adapter_identifier
|
|
241
|
+
ActiveRecord::Base.connection.adapter_name.downcase
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.explain-container {
|
|
3
|
+
margin-top: 20px;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.explain-header {
|
|
7
|
+
background: #f8f9fa;
|
|
8
|
+
padding: 12px 16px;
|
|
9
|
+
border-radius: 4px 4px 0 0;
|
|
10
|
+
border: 1px solid #dee2e6;
|
|
11
|
+
border-bottom: none;
|
|
12
|
+
display: flex;
|
|
13
|
+
justify-content: space-between;
|
|
14
|
+
align-items: center;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.explain-header h4 {
|
|
18
|
+
margin: 0;
|
|
19
|
+
font-size: 14px;
|
|
20
|
+
font-weight: 600;
|
|
21
|
+
color: #495057;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.explain-metadata {
|
|
25
|
+
font-size: 12px;
|
|
26
|
+
color: #6c757d;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.explain-content {
|
|
30
|
+
background: #fff;
|
|
31
|
+
border: 1px solid #dee2e6;
|
|
32
|
+
border-radius: 0 0 4px 4px;
|
|
33
|
+
padding: 16px;
|
|
34
|
+
max-height: 400px;
|
|
35
|
+
overflow: auto;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.explain-plan {
|
|
39
|
+
font-family: 'Courier New', Courier, monospace;
|
|
40
|
+
font-size: 13px;
|
|
41
|
+
line-height: 1.6;
|
|
42
|
+
white-space: pre-wrap;
|
|
43
|
+
word-wrap: break-word;
|
|
44
|
+
background: #f8f9fa;
|
|
45
|
+
padding: 12px;
|
|
46
|
+
border-radius: 4px;
|
|
47
|
+
color: #212529;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.explain-error {
|
|
51
|
+
background: #f8d7da;
|
|
52
|
+
color: #721c24;
|
|
53
|
+
padding: 12px 16px;
|
|
54
|
+
border-radius: 4px;
|
|
55
|
+
border: 1px solid #f5c6cb;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.explain-error-icon {
|
|
59
|
+
font-weight: bold;
|
|
60
|
+
margin-right: 8px;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
63
|
+
|
|
64
|
+
<%= turbo_frame_tag "explain-results" do %>
|
|
65
|
+
<div class="explain-container">
|
|
66
|
+
<% if result.failure? %>
|
|
67
|
+
<div class="explain-error">
|
|
68
|
+
<span class="explain-error-icon">⚠</span>
|
|
69
|
+
<strong>EXPLAIN Error:</strong> <%= result.error %>
|
|
70
|
+
</div>
|
|
71
|
+
<% else %>
|
|
72
|
+
<div class="explain-header">
|
|
73
|
+
<h4>Query Execution Plan</h4>
|
|
74
|
+
<div class="explain-metadata">
|
|
75
|
+
Execution time: <strong><%= result.execution_time_ms %>ms</strong>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="explain-content">
|
|
79
|
+
<% if result.plan_text.present? %>
|
|
80
|
+
<div class="explain-plan"><%= result.plan_text %></div>
|
|
81
|
+
<% else %>
|
|
82
|
+
<div style="color: #6c757d; text-align: center; padding: 20px;">
|
|
83
|
+
No execution plan available.
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
<% end %>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
<style>
|
|
3
3
|
.results-container {
|
|
4
4
|
margin-top: 20px;
|
|
5
|
+
width: 100%;
|
|
6
|
+
min-width: 0;
|
|
7
|
+
overflow: hidden;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
.error-message {
|
|
@@ -66,7 +69,8 @@
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
.results-table {
|
|
69
|
-
width:
|
|
72
|
+
width: max-content;
|
|
73
|
+
min-width: 100%;
|
|
70
74
|
border-collapse: collapse;
|
|
71
75
|
font-size: 14px;
|
|
72
76
|
background: white;
|
|
@@ -112,10 +116,33 @@
|
|
|
112
116
|
color: #999;
|
|
113
117
|
font-style: italic;
|
|
114
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
|
+
}
|
|
115
133
|
</style>
|
|
116
134
|
|
|
117
135
|
<div class="results-container">
|
|
118
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 %>
|
|
119
146
|
|
|
120
147
|
<% if result_to_render.error %>
|
|
121
148
|
<div class="error-message">
|
|
@@ -132,8 +159,13 @@
|
|
|
132
159
|
<span class="metadata-value"><%= result_to_render.execution_time_ms %>ms</span>
|
|
133
160
|
</div>
|
|
134
161
|
<div class="metadata-item">
|
|
135
|
-
|
|
136
|
-
|
|
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 %>
|
|
137
169
|
</div>
|
|
138
170
|
<% if result_to_render.truncated %>
|
|
139
171
|
<div class="metadata-item">
|
|
@@ -144,7 +176,11 @@
|
|
|
144
176
|
|
|
145
177
|
<% if result_to_render.rows.empty? %>
|
|
146
178
|
<div class="empty-results">
|
|
147
|
-
|
|
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 %>
|
|
148
184
|
</div>
|
|
149
185
|
<% else %>
|
|
150
186
|
<div class="table-wrapper">
|