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
|
@@ -1,77 +1,214 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { EditorView, keymap } from "@codemirror/view"
|
|
3
|
+
import { EditorState } from "@codemirror/state"
|
|
4
|
+
import { sql } from "@codemirror/lang-sql"
|
|
5
|
+
import { defaultKeymap } from "@codemirror/commands"
|
|
6
|
+
import { autocompletion } from "@codemirror/autocomplete"
|
|
2
7
|
|
|
3
|
-
// Manages the SQL editor
|
|
8
|
+
// Manages the SQL editor with CodeMirror and query execution
|
|
4
9
|
// Usage: <div data-controller="editor">
|
|
5
10
|
export default class extends Controller {
|
|
6
|
-
static targets = ["
|
|
11
|
+
static targets = ["container"]
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
14
|
+
this.initializeCodeMirror()
|
|
15
|
+
|
|
9
16
|
// Listen for history load events
|
|
10
17
|
this.element.addEventListener('history:load', (event) => {
|
|
11
18
|
this.loadQuery(event.detail.sql)
|
|
12
19
|
})
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this.view) {
|
|
24
|
+
this.view.destroy()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
initializeCodeMirror() {
|
|
29
|
+
const sqlLanguage = sql()
|
|
30
|
+
|
|
31
|
+
const startState = EditorState.create({
|
|
32
|
+
doc: "SELECT * FROM users LIMIT 10;",
|
|
33
|
+
extensions: [
|
|
34
|
+
sqlLanguage.extension,
|
|
35
|
+
autocompletion(),
|
|
36
|
+
keymap.of(defaultKeymap),
|
|
37
|
+
EditorView.lineWrapping,
|
|
38
|
+
EditorView.theme({
|
|
39
|
+
"&": {
|
|
40
|
+
fontSize: "14px",
|
|
41
|
+
border: "1px solid #ddd",
|
|
42
|
+
borderRadius: "4px"
|
|
43
|
+
},
|
|
44
|
+
".cm-content": {
|
|
45
|
+
fontFamily: "'Monaco', 'Menlo', 'Courier New', monospace",
|
|
46
|
+
minHeight: "200px",
|
|
47
|
+
padding: "12px"
|
|
48
|
+
},
|
|
49
|
+
".cm-scroller": {
|
|
50
|
+
overflow: "auto"
|
|
51
|
+
},
|
|
52
|
+
"&.cm-focused": {
|
|
53
|
+
outline: "none"
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
this.view = new EditorView({
|
|
60
|
+
state: startState,
|
|
61
|
+
parent: this.containerTarget
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get SQL content from CodeMirror
|
|
66
|
+
getSql() {
|
|
67
|
+
return this.view.state.doc.toString()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set SQL content in CodeMirror
|
|
71
|
+
setSql(text) {
|
|
72
|
+
this.view.dispatch({
|
|
73
|
+
changes: {
|
|
74
|
+
from: 0,
|
|
75
|
+
to: this.view.state.doc.length,
|
|
76
|
+
insert: text
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
this.view.focus()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Insert text at cursor position
|
|
83
|
+
insertAtCursor(text) {
|
|
84
|
+
const selection = this.view.state.selection.main
|
|
85
|
+
this.view.dispatch({
|
|
86
|
+
changes: {
|
|
87
|
+
from: selection.from,
|
|
88
|
+
to: selection.to,
|
|
89
|
+
insert: text
|
|
90
|
+
},
|
|
91
|
+
selection: {
|
|
92
|
+
anchor: selection.from + text.length
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
this.view.focus()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Load query into editor (from history)
|
|
16
99
|
loadQuery(sql) {
|
|
17
|
-
this.
|
|
18
|
-
this.textareaTarget.focus()
|
|
100
|
+
this.setSql(sql)
|
|
19
101
|
|
|
20
102
|
// Scroll to editor
|
|
21
103
|
this.element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
22
104
|
}
|
|
23
105
|
|
|
24
|
-
// Clear
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
106
|
+
// Clear editor
|
|
107
|
+
clearEditor() {
|
|
108
|
+
this.setSql('')
|
|
109
|
+
|
|
110
|
+
// Clear query results
|
|
111
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
112
|
+
if (queryFrame) {
|
|
113
|
+
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>'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Clear explain results
|
|
117
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
118
|
+
if (explainFrame) {
|
|
119
|
+
explainFrame.innerHTML = ''
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check if query is a DML operation
|
|
124
|
+
isDmlQuery(sql) {
|
|
125
|
+
const trimmed = sql.trim().toLowerCase()
|
|
126
|
+
return /^(insert|update|delete|merge)\b/.test(trimmed)
|
|
29
127
|
}
|
|
30
128
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
const sql = this.
|
|
34
|
-
|
|
129
|
+
// Run query
|
|
130
|
+
runQuery() {
|
|
131
|
+
const sql = this.getSql().trim()
|
|
35
132
|
if (!sql) {
|
|
36
|
-
event.preventDefault()
|
|
37
133
|
alert('Please enter a SQL query')
|
|
38
134
|
return
|
|
39
135
|
}
|
|
40
|
-
|
|
41
|
-
// Show loading state
|
|
42
|
-
this.runButtonTarget.disabled = true
|
|
43
|
-
this.runButtonTarget.textContent = 'Running...'
|
|
44
136
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
137
|
+
// Check if it's a DML query and confirm with user
|
|
138
|
+
if (this.isDmlQuery(sql)) {
|
|
139
|
+
const confirmed = confirm(
|
|
140
|
+
'⚠️ DATA MODIFICATION WARNING\n\n' +
|
|
141
|
+
'This query will INSERT, UPDATE, or DELETE data.\n\n' +
|
|
142
|
+
'• All changes are PERMANENT and cannot be undone\n' +
|
|
143
|
+
'• All operations are logged\n\n' +
|
|
144
|
+
'Do you want to proceed?'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if (!confirmed) {
|
|
148
|
+
return // User cancelled
|
|
149
|
+
}
|
|
150
|
+
}
|
|
53
151
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
152
|
+
// Clear explain results when running query
|
|
153
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
154
|
+
if (explainFrame) {
|
|
155
|
+
explainFrame.innerHTML = ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Store for history
|
|
159
|
+
window._lastExecutedSQL = sql
|
|
160
|
+
|
|
161
|
+
// Get CSRF token
|
|
162
|
+
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content
|
|
163
|
+
|
|
164
|
+
// Create form with Turbo Frame target
|
|
165
|
+
const form = document.createElement('form')
|
|
166
|
+
form.method = 'POST'
|
|
167
|
+
form.action = this.element.dataset.runPath
|
|
168
|
+
form.setAttribute('data-turbo-frame', 'query-results')
|
|
169
|
+
form.innerHTML = `
|
|
170
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
171
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
172
|
+
`
|
|
173
|
+
document.body.appendChild(form)
|
|
174
|
+
form.requestSubmit()
|
|
175
|
+
document.body.removeChild(form)
|
|
63
176
|
}
|
|
64
177
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
178
|
+
// Explain query
|
|
179
|
+
explainQuery() {
|
|
180
|
+
const sql = this.getSql().trim()
|
|
181
|
+
if (!sql) {
|
|
182
|
+
alert('Please enter a SQL query')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Clear query results when running explain
|
|
187
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
188
|
+
if (queryFrame) {
|
|
189
|
+
queryFrame.innerHTML = ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Get CSRF token
|
|
193
|
+
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content
|
|
194
|
+
|
|
195
|
+
// Create form with Turbo Frame target
|
|
196
|
+
const form = document.createElement('form')
|
|
197
|
+
form.method = 'POST'
|
|
198
|
+
form.action = this.element.dataset.explainPath
|
|
199
|
+
form.setAttribute('data-turbo-frame', 'explain-results')
|
|
200
|
+
form.innerHTML = `
|
|
201
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
202
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
203
|
+
`
|
|
204
|
+
document.body.appendChild(form)
|
|
205
|
+
form.requestSubmit()
|
|
206
|
+
document.body.removeChild(form)
|
|
69
207
|
}
|
|
70
208
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.runButtonTarget.textContent = 'Run Query'
|
|
209
|
+
escapeHtml(text) {
|
|
210
|
+
const div = document.createElement('div')
|
|
211
|
+
div.textContent = text
|
|
212
|
+
return div.innerHTML
|
|
76
213
|
}
|
|
77
214
|
}
|
|
@@ -15,16 +15,22 @@ module QueryConsole
|
|
|
15
15
|
actor: resolved_actor,
|
|
16
16
|
sql: sql.to_s.strip,
|
|
17
17
|
duration_ms: result.execution_time_ms,
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
status: result.success? ? "ok" : "error",
|
|
19
|
+
query_type: determine_query_type(sql),
|
|
20
|
+
is_dml: is_dml_query?(sql)
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
# Add row count if available (for QueryResult)
|
|
24
|
+
if result.respond_to?(:row_count_shown)
|
|
25
|
+
log_data[:rows] = result.row_count_shown
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
if result.failure?
|
|
23
29
|
log_data[:error] = result.error
|
|
24
30
|
log_data[:error_class] = determine_error_class(result.error)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
|
-
if result.truncated
|
|
33
|
+
if result.respond_to?(:truncated) && result.truncated
|
|
28
34
|
log_data[:truncated] = true
|
|
29
35
|
log_data[:max_rows] = config.max_rows
|
|
30
36
|
end
|
|
@@ -32,6 +38,22 @@ module QueryConsole
|
|
|
32
38
|
Rails.logger.info(log_data.to_json)
|
|
33
39
|
end
|
|
34
40
|
|
|
41
|
+
def self.is_dml_query?(sql)
|
|
42
|
+
sql.to_s.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.determine_query_type(sql)
|
|
46
|
+
case sql.to_s.strip.downcase
|
|
47
|
+
when /\Aselect\b/ then "SELECT"
|
|
48
|
+
when /\Awith\b/ then "WITH"
|
|
49
|
+
when /\Ainsert\b/ then "INSERT"
|
|
50
|
+
when /\Aupdate\b/ then "UPDATE"
|
|
51
|
+
when /\Adelete\b/ then "DELETE"
|
|
52
|
+
when /\Amerge\b/ then "MERGE"
|
|
53
|
+
else "UNKNOWN"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
def self.determine_error_class(error_message)
|
|
36
58
|
case error_message
|
|
37
59
|
when /timeout/i
|
|
@@ -42,6 +64,10 @@ module QueryConsole
|
|
|
42
64
|
"SecurityError"
|
|
43
65
|
when /must start with/i
|
|
44
66
|
"ValidationError"
|
|
67
|
+
when /cannot delete/i, /cannot update/i, /cannot insert/i
|
|
68
|
+
"DMLError"
|
|
69
|
+
when /foreign key constraint/i, /constraint.*violated/i
|
|
70
|
+
"ConstraintError"
|
|
45
71
|
else
|
|
46
72
|
"QueryError"
|
|
47
73
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require 'timeout'
|
|
2
|
+
|
|
3
|
+
module QueryConsole
|
|
4
|
+
class ExplainRunner
|
|
5
|
+
class ExplainResult
|
|
6
|
+
attr_reader :plan_text, :execution_time_ms, :error
|
|
7
|
+
|
|
8
|
+
def initialize(plan_text: nil, execution_time_ms: 0, error: nil)
|
|
9
|
+
@plan_text = plan_text
|
|
10
|
+
@execution_time_ms = execution_time_ms
|
|
11
|
+
@error = error
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success?
|
|
15
|
+
@error.nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failure?
|
|
19
|
+
!success?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(sql, config = QueryConsole.configuration)
|
|
24
|
+
@sql = sql
|
|
25
|
+
@config = config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute
|
|
29
|
+
return ExplainResult.new(error: "EXPLAIN feature is disabled") unless @config.enable_explain
|
|
30
|
+
|
|
31
|
+
start_time = Time.now
|
|
32
|
+
|
|
33
|
+
# Step 1: Validate SQL (same as regular runner)
|
|
34
|
+
validator = SqlValidator.new(@sql, @config)
|
|
35
|
+
validation_result = validator.validate
|
|
36
|
+
|
|
37
|
+
if validation_result.invalid?
|
|
38
|
+
return ExplainResult.new(error: validation_result.error)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sanitized_sql = validation_result.sanitized_sql
|
|
42
|
+
|
|
43
|
+
# Step 2: Build EXPLAIN query based on adapter
|
|
44
|
+
explain_sql = build_explain_query(sanitized_sql)
|
|
45
|
+
|
|
46
|
+
# Step 3: Execute with timeout
|
|
47
|
+
begin
|
|
48
|
+
result = execute_with_timeout(explain_sql)
|
|
49
|
+
execution_time = ((Time.now - start_time) * 1000).round(2)
|
|
50
|
+
|
|
51
|
+
# Format the result as plain text
|
|
52
|
+
plan_text = format_explain_output(result)
|
|
53
|
+
|
|
54
|
+
ExplainResult.new(
|
|
55
|
+
plan_text: plan_text,
|
|
56
|
+
execution_time_ms: execution_time
|
|
57
|
+
)
|
|
58
|
+
rescue Timeout::Error
|
|
59
|
+
ExplainResult.new(
|
|
60
|
+
error: "EXPLAIN timeout: exceeded #{@config.timeout_ms}ms limit"
|
|
61
|
+
)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
ExplainResult.new(
|
|
64
|
+
error: "EXPLAIN error: #{e.message}"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
attr_reader :sql, :config
|
|
72
|
+
|
|
73
|
+
def build_explain_query(sql)
|
|
74
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name
|
|
75
|
+
|
|
76
|
+
case adapter_name
|
|
77
|
+
when "PostgreSQL"
|
|
78
|
+
if @config.enable_explain_analyze
|
|
79
|
+
"EXPLAIN (ANALYZE, FORMAT TEXT) #{sql}"
|
|
80
|
+
else
|
|
81
|
+
"EXPLAIN (FORMAT TEXT) #{sql}"
|
|
82
|
+
end
|
|
83
|
+
when "Mysql2", "Trilogy"
|
|
84
|
+
if @config.enable_explain_analyze
|
|
85
|
+
"EXPLAIN ANALYZE #{sql}"
|
|
86
|
+
else
|
|
87
|
+
"EXPLAIN #{sql}"
|
|
88
|
+
end
|
|
89
|
+
when "SQLite"
|
|
90
|
+
# SQLite doesn't support ANALYZE in EXPLAIN
|
|
91
|
+
"EXPLAIN QUERY PLAN #{sql}"
|
|
92
|
+
else
|
|
93
|
+
# Fallback for other adapters
|
|
94
|
+
"EXPLAIN #{sql}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_with_timeout(sql)
|
|
99
|
+
timeout_seconds = @config.timeout_ms / 1000.0
|
|
100
|
+
|
|
101
|
+
Timeout.timeout(timeout_seconds) do
|
|
102
|
+
ActiveRecord::Base.connection.exec_query(sql)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def format_explain_output(result)
|
|
107
|
+
# For SQLite, the result has columns like: id, parent, notused, detail
|
|
108
|
+
# For Postgres, it's usually a single "QUERY PLAN" column
|
|
109
|
+
# For MySQL, it varies by version
|
|
110
|
+
|
|
111
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name
|
|
112
|
+
|
|
113
|
+
case adapter_name
|
|
114
|
+
when "SQLite"
|
|
115
|
+
# SQLite EXPLAIN QUERY PLAN format
|
|
116
|
+
lines = result.rows.map do |row|
|
|
117
|
+
# row is [id, parent, notused, detail]
|
|
118
|
+
detail = row[3] || row.last
|
|
119
|
+
detail.to_s
|
|
120
|
+
end
|
|
121
|
+
lines.join("\n")
|
|
122
|
+
when "PostgreSQL"
|
|
123
|
+
# Postgres returns single column with plan text
|
|
124
|
+
result.rows.map { |row| row[0].to_s }.join("\n")
|
|
125
|
+
when "Mysql2", "Trilogy"
|
|
126
|
+
# MySQL returns multiple columns, format as table
|
|
127
|
+
header = result.columns.join(" | ")
|
|
128
|
+
separator = "-" * header.length
|
|
129
|
+
rows = result.rows.map { |row| row.map(&:to_s).join(" | ") }
|
|
130
|
+
([header, separator] + rows).join("\n")
|
|
131
|
+
else
|
|
132
|
+
# Generic fallback
|
|
133
|
+
result.rows.map { |row| row.join(" | ") }.join("\n")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -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(
|
|
@@ -85,5 +100,43 @@ module QueryConsole
|
|
|
85
100
|
ActiveRecord::Base.connection.exec_query(sql)
|
|
86
101
|
end
|
|
87
102
|
end
|
|
103
|
+
|
|
104
|
+
# Get the number of rows affected by a DML query
|
|
105
|
+
# This is database-specific, so we try different approaches
|
|
106
|
+
def get_affected_rows_count(result)
|
|
107
|
+
conn = ActiveRecord::Base.connection
|
|
108
|
+
|
|
109
|
+
# For SQLite, use the raw connection's changes method
|
|
110
|
+
if conn.adapter_name.downcase.include?('sqlite')
|
|
111
|
+
return conn.raw_connection.changes
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# For PostgreSQL, MySQL, and others, check if result has rows_affected
|
|
115
|
+
# Note: exec_query doesn't always provide this, but we can try
|
|
116
|
+
if result.respond_to?(:rows_affected)
|
|
117
|
+
return result.rows_affected
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Fallback: try to get it from the connection's last result
|
|
121
|
+
begin
|
|
122
|
+
if conn.respond_to?(:raw_connection)
|
|
123
|
+
raw_conn = conn.raw_connection
|
|
124
|
+
|
|
125
|
+
# PostgreSQL
|
|
126
|
+
if raw_conn.respond_to?(:cmd_tuples)
|
|
127
|
+
return raw_conn.cmd_tuples
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# MySQL
|
|
131
|
+
if raw_conn.respond_to?(:affected_rows)
|
|
132
|
+
return raw_conn.affected_rows
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue
|
|
136
|
+
# If we can't determine affected rows, return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
88
141
|
end
|
|
89
142
|
end
|