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.
@@ -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
- unless @config.allowed_starts_with.any? { |keyword| normalized_start.start_with?(keyword) }
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: #{@config.allowed_starts_with.join(', ').upcase}"
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
- forbidden = @config.forbidden_keywords.find do |keyword|
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
- ValidationResult.new(valid: true, sanitized_sql: sanitized)
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
- <span class="metadata-label">Rows:</span>
140
- <span class="metadata-value"><%= result_to_render.row_count_shown %></span>
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
- No rows returned
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">