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.
@@ -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