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.
@@ -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 textarea and query execution
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 = ["textarea", "runButton", "clearButton", "results"]
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
- // Load query into textarea (from history)
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.textareaTarget.value = sql
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 textarea
25
- clear(event) {
26
- event.preventDefault()
27
- this.textareaTarget.value = ''
28
- this.textareaTarget.focus()
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
- // Handle form submission
32
- submit(event) {
33
- const sql = this.textareaTarget.value.trim()
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
- // After Turbo completes the request, we'll handle success/error
46
- }
47
-
48
- // Called after successful query execution (via Turbo)
49
- querySuccess(event) {
50
- // Re-enable button
51
- this.runButtonTarget.disabled = false
52
- this.runButtonTarget.textContent = 'Run Query'
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
- // Dispatch event to add to history
55
- const sql = this.textareaTarget.value.trim()
56
- this.dispatch('executed', {
57
- detail: {
58
- sql: sql,
59
- timestamp: new Date().toISOString()
60
- },
61
- target: document.querySelector('[data-controller="history"]')
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
- // Called after failed query execution
66
- queryError(event) {
67
- this.runButtonTarget.disabled = false
68
- this.runButtonTarget.textContent = 'Run Query'
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
- // Handle Turbo Frame errors
72
- turboFrameError(event) {
73
- console.error('Turbo Frame error:', event.detail)
74
- this.runButtonTarget.disabled = false
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
- rows: result.row_count_shown,
19
- status: result.success? ? "ok" : "error"
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