code_to_query 0.1.0
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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +23 -0
- data/README.md +167 -0
- data/lib/code_to_query/compiler.rb +674 -0
- data/lib/code_to_query/configuration.rb +92 -0
- data/lib/code_to_query/context/builder.rb +1087 -0
- data/lib/code_to_query/context/pack.rb +36 -0
- data/lib/code_to_query/errors.rb +5 -0
- data/lib/code_to_query/guardrails/explain_gate.rb +229 -0
- data/lib/code_to_query/guardrails/sql_linter.rb +335 -0
- data/lib/code_to_query/llm_client.rb +46 -0
- data/lib/code_to_query/performance/cache.rb +250 -0
- data/lib/code_to_query/performance/optimizer.rb +396 -0
- data/lib/code_to_query/planner.rb +289 -0
- data/lib/code_to_query/policies/pundit_adapter.rb +71 -0
- data/lib/code_to_query/providers/base.rb +173 -0
- data/lib/code_to_query/providers/local.rb +84 -0
- data/lib/code_to_query/providers/openai.rb +581 -0
- data/lib/code_to_query/query.rb +385 -0
- data/lib/code_to_query/railtie.rb +16 -0
- data/lib/code_to_query/runner.rb +188 -0
- data/lib/code_to_query/validator.rb +203 -0
- data/lib/code_to_query/version.rb +6 -0
- data/lib/code_to_query.rb +90 -0
- data/tasks/code_to_query.rake +326 -0
- metadata +225 -0
@@ -0,0 +1,385 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
end
|
7
|
+
|
8
|
+
module CodeToQuery
|
9
|
+
class Query
|
10
|
+
attr_reader :sql, :params, :intent, :metrics
|
11
|
+
|
12
|
+
def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:)
|
13
|
+
@sql = sql
|
14
|
+
@params = params || {}
|
15
|
+
@bind_spec = bind_spec || []
|
16
|
+
@intent = intent || {}
|
17
|
+
@allow_tables = allow_tables
|
18
|
+
@config = config
|
19
|
+
@safety_checked = false
|
20
|
+
@safety_result = nil
|
21
|
+
@metrics = extract_metrics_from_intent(@intent)
|
22
|
+
end
|
23
|
+
|
24
|
+
def binds
|
25
|
+
return [] unless defined?(ActiveRecord::Base)
|
26
|
+
|
27
|
+
connection = if @config.readonly_role && ActiveRecord.respond_to?(:connected_to)
|
28
|
+
ActiveRecord::Base.connected_to(role: @config.readonly_role) do
|
29
|
+
ActiveRecord::Base.connection
|
30
|
+
end
|
31
|
+
else
|
32
|
+
ActiveRecord::Base.connection
|
33
|
+
end
|
34
|
+
|
35
|
+
@bind_spec.map do |bind_info|
|
36
|
+
key = bind_info[:key]
|
37
|
+
column_name = bind_info[:column]
|
38
|
+
|
39
|
+
# Get parameter value (check both string and symbol keys)
|
40
|
+
value = @params[key.to_s] || @params[key.to_sym]
|
41
|
+
|
42
|
+
# Determine the correct ActiveRecord type
|
43
|
+
type = infer_column_type(connection, @intent['table'], column_name, bind_info[:cast])
|
44
|
+
|
45
|
+
ActiveRecord::Relation::QueryAttribute.new(column_name.to_s, value, type)
|
46
|
+
end
|
47
|
+
rescue StandardError => e
|
48
|
+
@config.logger.warn("[code_to_query] Failed to build binds: #{e.message}")
|
49
|
+
[]
|
50
|
+
end
|
51
|
+
|
52
|
+
def safe?
|
53
|
+
return @safety_result if @safety_checked
|
54
|
+
|
55
|
+
@safety_checked = true
|
56
|
+
@safety_result = perform_safety_checks
|
57
|
+
end
|
58
|
+
|
59
|
+
def explain
|
60
|
+
return 'EXPLAIN unavailable (ActiveRecord not loaded)' unless defined?(ActiveRecord::Base)
|
61
|
+
|
62
|
+
explain_sql = case @config.adapter
|
63
|
+
when :postgres, :postgresql
|
64
|
+
"EXPLAIN (ANALYZE false, VERBOSE false, BUFFERS false) #{@sql}"
|
65
|
+
when :mysql
|
66
|
+
"EXPLAIN #{@sql}"
|
67
|
+
when :sqlite
|
68
|
+
"EXPLAIN QUERY PLAN #{@sql}"
|
69
|
+
else
|
70
|
+
"EXPLAIN #{@sql}"
|
71
|
+
end
|
72
|
+
|
73
|
+
result = if @config.readonly_role && ActiveRecord.respond_to?(:connected_to)
|
74
|
+
ActiveRecord::Base.connected_to(role: @config.readonly_role) do
|
75
|
+
ActiveRecord::Base.connection.execute(explain_sql)
|
76
|
+
end
|
77
|
+
else
|
78
|
+
ActiveRecord::Base.connection.execute(explain_sql)
|
79
|
+
end
|
80
|
+
|
81
|
+
format_explain_result(result)
|
82
|
+
rescue StandardError => e
|
83
|
+
"EXPLAIN failed: #{e.message}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_relation
|
87
|
+
return nil unless defined?(ActiveRecord::Base)
|
88
|
+
return nil unless @intent['type'] == 'select'
|
89
|
+
|
90
|
+
table_name = @intent['table']
|
91
|
+
model = infer_model_for_table(table_name)
|
92
|
+
return nil unless model
|
93
|
+
|
94
|
+
scope = model.all
|
95
|
+
|
96
|
+
# Apply WHERE conditions
|
97
|
+
Array(@intent['filters']).each do |filter|
|
98
|
+
scope = apply_filter_to_scope(scope, filter)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Apply ORDER BY
|
102
|
+
Array(@intent['order']).each do |order_spec|
|
103
|
+
column = order_spec['column']
|
104
|
+
direction = order_spec['dir']&.downcase == 'asc' ? :asc : :desc
|
105
|
+
scope = scope.order(column => direction)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Apply LIMIT (intelligent based on query type)
|
109
|
+
limit = determine_appropriate_limit
|
110
|
+
scope.limit(limit) if limit
|
111
|
+
rescue StandardError => e
|
112
|
+
@config.logger.warn("[code_to_query] Failed to build relation: #{e.message}")
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
|
116
|
+
def to_active_record
|
117
|
+
to_relation
|
118
|
+
end
|
119
|
+
|
120
|
+
def relationable?
|
121
|
+
return false unless defined?(ActiveRecord::Base)
|
122
|
+
return false unless @intent['type'] == 'select'
|
123
|
+
|
124
|
+
!!infer_model_for_table(@intent['table'])
|
125
|
+
end
|
126
|
+
|
127
|
+
def to_relation!
|
128
|
+
rel = to_relation
|
129
|
+
return rel if rel
|
130
|
+
|
131
|
+
raise CodeToQuery::NotRelationConvertibleError, 'Query cannot be expressed as ActiveRecord::Relation'
|
132
|
+
end
|
133
|
+
|
134
|
+
def run
|
135
|
+
Runner.new(@config).run(sql: @sql, binds: binds)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def extract_metrics_from_intent(intent)
|
141
|
+
data = intent.is_a?(Hash) ? intent['_metrics'] : nil
|
142
|
+
return {} unless data.is_a?(Hash)
|
143
|
+
|
144
|
+
{
|
145
|
+
prompt_tokens: data[:prompt_tokens] || data['prompt_tokens'],
|
146
|
+
completion_tokens: data[:completion_tokens] || data['completion_tokens'],
|
147
|
+
total_tokens: data[:total_tokens] || data['total_tokens'],
|
148
|
+
elapsed_s: data[:elapsed_s] || data['elapsed_s']
|
149
|
+
}.compact
|
150
|
+
end
|
151
|
+
|
152
|
+
def perform_safety_checks
|
153
|
+
# Basic SQL structure checks
|
154
|
+
Guardrails::SqlLinter.new(@config, allow_tables: @allow_tables).check!(@sql)
|
155
|
+
|
156
|
+
# EXPLAIN-based performance checks
|
157
|
+
return false if @config.enable_explain_gate && !Guardrails::ExplainGate.new(@config).allowed?(@sql)
|
158
|
+
|
159
|
+
# Policy enforcement
|
160
|
+
return false if @config.policy_adapter && !check_policy_compliance
|
161
|
+
|
162
|
+
true
|
163
|
+
rescue SecurityError => e
|
164
|
+
@config.logger.warn("[code_to_query] Security check failed: #{e.message}")
|
165
|
+
false
|
166
|
+
rescue StandardError => e
|
167
|
+
@config.logger.warn("[code_to_query] Safety check failed: #{e.message}")
|
168
|
+
false
|
169
|
+
end
|
170
|
+
|
171
|
+
def check_policy_compliance
|
172
|
+
# Predicates are injected at compile time with proper binds.
|
173
|
+
# Verify via bind_spec or params keys rather than scanning SQL text.
|
174
|
+
return true unless @config.policy_adapter
|
175
|
+
|
176
|
+
policy_in_binds = Array(@bind_spec).any? do |bind|
|
177
|
+
key = bind[:key]
|
178
|
+
key.to_s.start_with?('policy_')
|
179
|
+
end
|
180
|
+
|
181
|
+
policy_in_params = @params.keys.any? { |k| k.to_s.start_with?('policy_') }
|
182
|
+
|
183
|
+
policy_in_binds || policy_in_params
|
184
|
+
end
|
185
|
+
|
186
|
+
def infer_column_type(connection, table_name, column_name, explicit_cast)
|
187
|
+
return explicit_cast if explicit_cast
|
188
|
+
|
189
|
+
# Try to get column info from ActiveRecord
|
190
|
+
if defined?(ActiveRecord::Base) && table_name
|
191
|
+
begin
|
192
|
+
model = infer_model_for_table(table_name)
|
193
|
+
return model.column_for_attribute(column_name)&.type if model&.column_names&.include?(column_name.to_s)
|
194
|
+
rescue StandardError
|
195
|
+
# Fall through to connection-based lookup
|
196
|
+
end
|
197
|
+
|
198
|
+
# Fallback to direct connection query
|
199
|
+
begin
|
200
|
+
columns = connection.columns(table_name)
|
201
|
+
column = columns.find { |c| c.name == column_name.to_s }
|
202
|
+
return connection.lookup_cast_type_from_column(column) if column
|
203
|
+
rescue StandardError
|
204
|
+
# Fall through to type inference
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Ultimate fallback: infer from parameter value
|
209
|
+
infer_type_from_value(@params[column_name] || @params[column_name.to_sym])
|
210
|
+
end
|
211
|
+
|
212
|
+
def infer_type_from_value(value)
|
213
|
+
case value
|
214
|
+
when Integer
|
215
|
+
ActiveRecord::Type::Integer.new
|
216
|
+
when Float
|
217
|
+
ActiveRecord::Type::Decimal.new
|
218
|
+
when Date
|
219
|
+
ActiveRecord::Type::Date.new
|
220
|
+
when Time, DateTime
|
221
|
+
ActiveRecord::Type::DateTime.new
|
222
|
+
when TrueClass, FalseClass
|
223
|
+
ActiveRecord::Type::Boolean.new
|
224
|
+
else
|
225
|
+
ActiveRecord::Type::String.new
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
def infer_model_for_table(table_name)
|
230
|
+
return nil unless defined?(ActiveRecord::Base)
|
231
|
+
return nil unless table_name
|
232
|
+
|
233
|
+
# Try different naming conventions
|
234
|
+
possible_class_names = [
|
235
|
+
table_name.singularize.camelize,
|
236
|
+
table_name.camelize,
|
237
|
+
table_name.singularize.camelize.gsub(/s$/, '')
|
238
|
+
]
|
239
|
+
|
240
|
+
possible_class_names.each do |class_name|
|
241
|
+
model = class_name.constantize
|
242
|
+
return model if model < ActiveRecord::Base && model.table_name == table_name
|
243
|
+
rescue NameError
|
244
|
+
next
|
245
|
+
end
|
246
|
+
|
247
|
+
nil
|
248
|
+
end
|
249
|
+
|
250
|
+
def apply_filter_to_scope(scope, filter)
|
251
|
+
column = filter['column']
|
252
|
+
operator = filter['op']
|
253
|
+
|
254
|
+
case operator
|
255
|
+
when '='
|
256
|
+
param_key = filter['param'] || column
|
257
|
+
value = @params[param_key.to_s] || @params[param_key.to_sym]
|
258
|
+
scope.where(column => value)
|
259
|
+
when '!=', '<>'
|
260
|
+
param_key = filter['param'] || column
|
261
|
+
value = @params[param_key.to_s] || @params[param_key.to_sym]
|
262
|
+
scope.where.not(column => value)
|
263
|
+
when '>', '>=', '<', '<='
|
264
|
+
param_key = filter['param'] || column
|
265
|
+
value = @params[param_key.to_s] || @params[param_key.to_sym]
|
266
|
+
scope.where("#{scope.connection.quote_column_name(column)} #{operator} ?", value)
|
267
|
+
when 'between'
|
268
|
+
start_key = filter['param_start'] || 'start'
|
269
|
+
end_key = filter['param_end'] || 'end'
|
270
|
+
start_value = @params[start_key.to_s] || @params[start_key.to_sym]
|
271
|
+
end_value = @params[end_key.to_s] || @params[end_key.to_sym]
|
272
|
+
scope.where(column => start_value..end_value)
|
273
|
+
when 'in'
|
274
|
+
param_key = filter['param'] || column
|
275
|
+
values = @params[param_key.to_s] || @params[param_key.to_sym]
|
276
|
+
scope.where(column => Array(values))
|
277
|
+
when 'like', 'ilike'
|
278
|
+
param_key = filter['param'] || column
|
279
|
+
value = @params[param_key.to_s] || @params[param_key.to_sym]
|
280
|
+
scope.where("#{scope.connection.quote_column_name(column)} #{operator.upcase} ?", value)
|
281
|
+
when 'exists', 'not_exists'
|
282
|
+
related_table = filter['related_table']
|
283
|
+
fk_column = filter['fk_column']
|
284
|
+
base_column = filter['base_column'] || 'id'
|
285
|
+
related_filters = Array(filter['related_filters'])
|
286
|
+
|
287
|
+
unless related_table && fk_column
|
288
|
+
warn "[code_to_query] Unsupported filter operator: #{operator}"
|
289
|
+
return scope
|
290
|
+
end
|
291
|
+
|
292
|
+
# Use EXISTS subquery via where clause
|
293
|
+
table_name = scope.klass.table_name
|
294
|
+
subquery = scope.klass.unscoped
|
295
|
+
.from(related_table)
|
296
|
+
.where("#{related_table}.#{fk_column} = #{table_name}.#{base_column}")
|
297
|
+
|
298
|
+
related_filters.each do |rf|
|
299
|
+
rcol = rf['column']
|
300
|
+
rop = rf['op']
|
301
|
+
rkey = rf['param'] || rcol
|
302
|
+
rval = @params[rkey.to_s] || @params[rkey.to_sym]
|
303
|
+
next if rcol.nil? || rop.nil?
|
304
|
+
|
305
|
+
case rop
|
306
|
+
when '=', '>', '<', '>=', '<=', '!=', '<>'
|
307
|
+
subquery = if %w[!= <>].include?(rop)
|
308
|
+
subquery.where.not("#{related_table}.#{rcol} = ?", rval)
|
309
|
+
else
|
310
|
+
subquery.where("#{related_table}.#{rcol} #{rop} ?", rval)
|
311
|
+
end
|
312
|
+
when 'between'
|
313
|
+
start_key = rf['param_start'] || 'start'
|
314
|
+
end_key = rf['param_end'] || 'end'
|
315
|
+
start_val = @params[start_key.to_s] || @params[start_key.to_sym]
|
316
|
+
end_val = @params[end_key.to_s] || @params[end_key.to_sym]
|
317
|
+
subquery = subquery.where("#{related_table}.#{rcol} BETWEEN ? AND ?", start_val, end_val)
|
318
|
+
when 'in'
|
319
|
+
vals = Array(rval)
|
320
|
+
subquery = subquery.where("#{related_table}.#{rcol} IN (?)", vals)
|
321
|
+
when 'like', 'ilike'
|
322
|
+
subquery = subquery.where("#{related_table}.#{rcol} #{rop.upcase} ?", rval)
|
323
|
+
else
|
324
|
+
warn "[code_to_query] Unsupported filter op in subquery: #{rop}"
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
exists_sql = "EXISTS (#{subquery.select('1').to_sql})"
|
329
|
+
if operator == 'not_exists'
|
330
|
+
scope.where("NOT #{exists_sql}")
|
331
|
+
else
|
332
|
+
scope.where(exists_sql)
|
333
|
+
end
|
334
|
+
else
|
335
|
+
warn "[code_to_query] Unsupported filter operator: #{operator}"
|
336
|
+
scope
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
def determine_appropriate_limit
|
341
|
+
# Explicit limit always takes precedence
|
342
|
+
return @intent['limit'] if @intent['limit']
|
343
|
+
|
344
|
+
# Determine query type and apply appropriate limit
|
345
|
+
if has_aggregations?
|
346
|
+
@config.aggregation_limit
|
347
|
+
elsif has_exists_checks?
|
348
|
+
@config.exists_limit
|
349
|
+
elsif @intent['distinct']
|
350
|
+
@config.distinct_limit
|
351
|
+
else
|
352
|
+
@config.default_limit
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def has_aggregations?
|
357
|
+
@intent['aggregations']&.any? ||
|
358
|
+
@intent['columns']&.any? { |col| col.to_s.match?(/count\(|sum\(|avg\(|max\(|min\(/i) }
|
359
|
+
end
|
360
|
+
|
361
|
+
def has_exists_checks?
|
362
|
+
@intent['filters']&.any? { |filter| %w[exists not_exists].include?(filter['op']) }
|
363
|
+
end
|
364
|
+
|
365
|
+
def format_explain_result(result)
|
366
|
+
case result
|
367
|
+
when Array
|
368
|
+
result.map do |row|
|
369
|
+
case row
|
370
|
+
when Hash
|
371
|
+
row.values.join(' | ')
|
372
|
+
when Array
|
373
|
+
row.join(' | ')
|
374
|
+
else
|
375
|
+
row.to_s
|
376
|
+
end
|
377
|
+
end.join("\n")
|
378
|
+
when String
|
379
|
+
result
|
380
|
+
else
|
381
|
+
result.to_s
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
385
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/railtie'
|
4
|
+
|
5
|
+
module CodeToQuery
|
6
|
+
class Railtie < ::Rails::Railtie
|
7
|
+
rake_tasks do
|
8
|
+
tasks_path = File.expand_path('../../tasks/code_to_query.rake', __dir__)
|
9
|
+
load tasks_path if File.exist?(tasks_path)
|
10
|
+
end
|
11
|
+
|
12
|
+
initializer 'code_to_query.configure' do
|
13
|
+
CodeToQuery.config
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
require 'timeout'
|
6
|
+
rescue LoadError
|
7
|
+
end
|
8
|
+
|
9
|
+
module CodeToQuery
|
10
|
+
class Runner
|
11
|
+
DEFAULT_TIMEOUT = 30
|
12
|
+
MAX_ROWS_RETURNED = 10_000
|
13
|
+
|
14
|
+
def initialize(config)
|
15
|
+
@config = config
|
16
|
+
end
|
17
|
+
|
18
|
+
def run(sql:, binds: [])
|
19
|
+
validate_execution_context!
|
20
|
+
|
21
|
+
result = execute_with_timeout(sql, binds)
|
22
|
+
format_result(result)
|
23
|
+
rescue StandardError => e
|
24
|
+
handle_execution_error(e, sql)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def validate_execution_context!
|
30
|
+
unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
31
|
+
raise ConnectionError, 'ActiveRecord not available or not connected'
|
32
|
+
end
|
33
|
+
|
34
|
+
return unless @config.readonly_role && !supports_readonly_role?
|
35
|
+
|
36
|
+
CodeToQuery.config.logger.warn('[code_to_query] Readonly role specified but not supported in this Rails version')
|
37
|
+
end
|
38
|
+
|
39
|
+
def execute_with_timeout(sql, binds)
|
40
|
+
timeout = @config.query_timeout || DEFAULT_TIMEOUT
|
41
|
+
|
42
|
+
Timeout.timeout(timeout) do
|
43
|
+
if @config.readonly_role && supports_readonly_role?
|
44
|
+
execute_with_readonly_role(sql, binds)
|
45
|
+
else
|
46
|
+
execute_with_regular_connection(sql, binds)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
rescue Timeout::Error
|
50
|
+
raise ExecutionError, "Query timed out after #{timeout} seconds"
|
51
|
+
end
|
52
|
+
|
53
|
+
def execute_with_readonly_role(sql, binds)
|
54
|
+
ActiveRecord::Base.connected_to(role: @config.readonly_role) do
|
55
|
+
connection = ActiveRecord::Base.connection
|
56
|
+
|
57
|
+
verify_readonly_connection(connection)
|
58
|
+
|
59
|
+
connection.exec_query(sql, 'CodeToQuery', binds)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def execute_with_regular_connection(sql, binds)
|
64
|
+
connection = ActiveRecord::Base.connection
|
65
|
+
|
66
|
+
set_session_readonly(connection)
|
67
|
+
|
68
|
+
begin
|
69
|
+
connection.exec_query(sql, 'CodeToQuery', binds)
|
70
|
+
ensure
|
71
|
+
reset_session_readonly(connection) if @config.reset_session_after_query
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def verify_readonly_connection(connection)
|
76
|
+
case @config.adapter
|
77
|
+
when :postgres, :postgresql
|
78
|
+
result = connection.execute('SHOW transaction_read_only')
|
79
|
+
readonly = result.first['transaction_read_only']
|
80
|
+
unless readonly == 'on'
|
81
|
+
CodeToQuery.config.logger.warn("[code_to_query] Warning: Connection may not be read-only (transaction_read_only: #{readonly})")
|
82
|
+
end
|
83
|
+
when :mysql
|
84
|
+
# MySQL doesn't have a direct equivalent, but we can check user privileges
|
85
|
+
# This is more complex and would require additional setup
|
86
|
+
end
|
87
|
+
rescue StandardError => e
|
88
|
+
CodeToQuery.config.logger.warn("[code_to_query] Could not verify readonly status: #{e.message}")
|
89
|
+
end
|
90
|
+
|
91
|
+
def set_session_readonly(connection)
|
92
|
+
return unless @config.force_readonly_session
|
93
|
+
|
94
|
+
case @config.adapter
|
95
|
+
when :postgres, :postgresql
|
96
|
+
connection.execute('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY')
|
97
|
+
when :mysql
|
98
|
+
connection.execute('SET SESSION TRANSACTION READ ONLY')
|
99
|
+
end
|
100
|
+
rescue StandardError => e
|
101
|
+
CodeToQuery.config.logger.warn("[code_to_query] Could not set session to readonly: #{e.message}")
|
102
|
+
end
|
103
|
+
|
104
|
+
def reset_session_readonly(connection)
|
105
|
+
case @config.adapter
|
106
|
+
when :postgres, :postgresql
|
107
|
+
connection.execute('SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE')
|
108
|
+
when :mysql
|
109
|
+
connection.execute('SET SESSION TRANSACTION READ WRITE')
|
110
|
+
end
|
111
|
+
rescue StandardError => e
|
112
|
+
CodeToQuery.config.logger.warn("[code_to_query] Could not reset session readonly state: #{e.message}")
|
113
|
+
end
|
114
|
+
|
115
|
+
def format_result(result)
|
116
|
+
return stub_result unless result
|
117
|
+
|
118
|
+
# Limit result size for safety
|
119
|
+
if result.respond_to?(:rows) && result.rows.length > MAX_ROWS_RETURNED
|
120
|
+
CodeToQuery.config.logger.warn("[code_to_query] Result truncated to #{MAX_ROWS_RETURNED} rows")
|
121
|
+
limited_rows = result.rows.first(MAX_ROWS_RETURNED)
|
122
|
+
|
123
|
+
if defined?(ActiveRecord::Result)
|
124
|
+
ActiveRecord::Result.new(result.columns, limited_rows, result.column_types)
|
125
|
+
else
|
126
|
+
{ columns: result.columns, rows: limited_rows, truncated: true }
|
127
|
+
end
|
128
|
+
else
|
129
|
+
result
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def handle_execution_error(error, sql)
|
134
|
+
error_info = {
|
135
|
+
type: error.class.name,
|
136
|
+
message: error.message,
|
137
|
+
sql_preview: sql[0..100] + (sql.length > 100 ? '...' : ''),
|
138
|
+
timestamp: Time.now
|
139
|
+
}
|
140
|
+
|
141
|
+
case error
|
142
|
+
when ActiveRecord::StatementInvalid
|
143
|
+
log_execution_error('Database error', error_info)
|
144
|
+
raise ExecutionError, "Database error: #{error.message}"
|
145
|
+
|
146
|
+
when ActiveRecord::RecordNotFound
|
147
|
+
log_execution_error('Record not found', error_info)
|
148
|
+
raise ExecutionError, "Query returned no results: #{error.message}"
|
149
|
+
|
150
|
+
when Timeout::Error
|
151
|
+
log_execution_error('Query timeout', error_info)
|
152
|
+
raise ExecutionError, 'Query execution timed out'
|
153
|
+
|
154
|
+
when ConnectionError, ExecutionError
|
155
|
+
log_execution_error('Execution error', error_info)
|
156
|
+
raise error
|
157
|
+
|
158
|
+
else
|
159
|
+
log_execution_error('Unexpected error', error_info)
|
160
|
+
raise ExecutionError, "Unexpected error during query execution: #{error.message}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def log_execution_error(category, error_info)
|
165
|
+
if defined?(Rails) && Rails.logger
|
166
|
+
Rails.logger.error "[code_to_query] #{category}: #{error_info}"
|
167
|
+
else
|
168
|
+
CodeToQuery.config.logger.warn("[code_to_query] #{category}: #{error_info[:message]}")
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def supports_readonly_role?
|
173
|
+
ActiveRecord.respond_to?(:connected_to) && ActiveRecord::Base.respond_to?(:connected_to)
|
174
|
+
end
|
175
|
+
|
176
|
+
def stub_result
|
177
|
+
if defined?(ActiveRecord::Result)
|
178
|
+
ActiveRecord::Result.new([], [])
|
179
|
+
else
|
180
|
+
{ columns: [], rows: [], message: 'No database connection available' }
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Custom exception classes for better error handling
|
186
|
+
class ConnectionError < StandardError; end
|
187
|
+
class ExecutionError < StandardError; end
|
188
|
+
end
|