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,674 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Converts validated intent into safe SQL with parameter binding
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'arel'
|
7
|
+
require 'active_record'
|
8
|
+
rescue LoadError
|
9
|
+
end
|
10
|
+
|
11
|
+
module CodeToQuery
|
12
|
+
class Compiler
|
13
|
+
def initialize(config)
|
14
|
+
@config = config
|
15
|
+
end
|
16
|
+
|
17
|
+
def compile(intent, current_user: nil)
|
18
|
+
intent_with_policy = apply_policy_predicates(intent, current_user)
|
19
|
+
if use_arel?
|
20
|
+
compile_with_arel(intent_with_policy)
|
21
|
+
else
|
22
|
+
compile_with_string_building(intent_with_policy)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def apply_policy_predicates(intent, current_user)
|
29
|
+
return intent unless @config.policy_adapter.respond_to?(:call)
|
30
|
+
|
31
|
+
table = intent['table']
|
32
|
+
policy_info = safely_fetch_policy(table: table, current_user: current_user, intent: intent)
|
33
|
+
policy_hash = extract_enforced_predicates(policy_info)
|
34
|
+
return intent if policy_hash.nil? || policy_hash.empty?
|
35
|
+
|
36
|
+
filters = Array(intent['filters']) + policy_hash.map do |column, value|
|
37
|
+
if value.is_a?(Range) && value.begin && value.end
|
38
|
+
{
|
39
|
+
'column' => column.to_s,
|
40
|
+
'op' => 'between',
|
41
|
+
'param_start' => "policy_#{column}_start",
|
42
|
+
'param_end' => "policy_#{column}_end"
|
43
|
+
}
|
44
|
+
else
|
45
|
+
{
|
46
|
+
'column' => column.to_s,
|
47
|
+
'op' => '=',
|
48
|
+
'param' => "policy_#{column}"
|
49
|
+
}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
params = (intent['params'] || {}).dup
|
54
|
+
policy_hash.each do |column, value|
|
55
|
+
if value.is_a?(Range) && value.begin && value.end
|
56
|
+
params["policy_#{column}_start"] = value.begin
|
57
|
+
params["policy_#{column}_end"] = value.end
|
58
|
+
else
|
59
|
+
params["policy_#{column}"] = value
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
intent.merge(
|
64
|
+
'filters' => filters,
|
65
|
+
'params' => params
|
66
|
+
)
|
67
|
+
rescue StandardError => e
|
68
|
+
@config.logger.warn("[code_to_query] Policy application failed: #{e.message}")
|
69
|
+
intent
|
70
|
+
end
|
71
|
+
|
72
|
+
def safely_fetch_policy(table:, current_user:, intent: nil)
|
73
|
+
if intent
|
74
|
+
@config.policy_adapter.call(current_user, table: table, intent: intent)
|
75
|
+
else
|
76
|
+
@config.policy_adapter.call(current_user, table: table)
|
77
|
+
end
|
78
|
+
rescue ArgumentError
|
79
|
+
# Backward compatibility: some adapters may accept only current_user
|
80
|
+
@config.policy_adapter.call(current_user)
|
81
|
+
end
|
82
|
+
|
83
|
+
def extract_enforced_predicates(policy_info)
|
84
|
+
return policy_info unless policy_info.is_a?(Hash)
|
85
|
+
|
86
|
+
policy_info[:enforced_predicates] || policy_info['enforced_predicates'] ||
|
87
|
+
policy_info[:predicates] || policy_info['predicates'] || {}
|
88
|
+
end
|
89
|
+
|
90
|
+
def use_arel?
|
91
|
+
defined?(Arel) && defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
92
|
+
end
|
93
|
+
|
94
|
+
def compile_with_arel(intent)
|
95
|
+
table_name = intent.fetch('table')
|
96
|
+
|
97
|
+
table = if defined?(ActiveRecord::Base)
|
98
|
+
begin
|
99
|
+
model = infer_model_for_table(table_name)
|
100
|
+
model ? model.arel_table : Arel::Table.new(table_name)
|
101
|
+
rescue StandardError
|
102
|
+
Arel::Table.new(table_name)
|
103
|
+
end
|
104
|
+
else
|
105
|
+
Arel::Table.new(table_name)
|
106
|
+
end
|
107
|
+
|
108
|
+
columns = intent['columns'] || ['*']
|
109
|
+
parsed_functions = Array(columns).map { |c| parse_function_column(c) }.compact
|
110
|
+
if parsed_functions.any?
|
111
|
+
projections = parsed_functions.map do |fn|
|
112
|
+
func = fn[:func]
|
113
|
+
col = fn[:column]
|
114
|
+
case func
|
115
|
+
when 'count'
|
116
|
+
node = col ? table[col].count : Arel.star.count
|
117
|
+
node.as('count')
|
118
|
+
when 'sum'
|
119
|
+
next unless col
|
120
|
+
|
121
|
+
table[col].sum.as('sum')
|
122
|
+
when 'avg'
|
123
|
+
next unless col
|
124
|
+
|
125
|
+
table[col].average.as('avg')
|
126
|
+
when 'max'
|
127
|
+
next unless col
|
128
|
+
|
129
|
+
table[col].maximum.as('max')
|
130
|
+
when 'min'
|
131
|
+
next unless col
|
132
|
+
|
133
|
+
table[col].minimum.as('min')
|
134
|
+
end
|
135
|
+
end.compact
|
136
|
+
projections = [Arel.star] if projections.empty?
|
137
|
+
query = table.project(*projections)
|
138
|
+
elsif columns == ['*'] || columns.include?('*')
|
139
|
+
query = table.project(Arel.star)
|
140
|
+
else
|
141
|
+
projections = columns.map { |col| table[col] }
|
142
|
+
query = table.project(*projections)
|
143
|
+
end
|
144
|
+
|
145
|
+
if intent['distinct']
|
146
|
+
if intent['distinct_on']&.any?
|
147
|
+
# PostgreSQL DISTINCT ON
|
148
|
+
distinct_columns = intent['distinct_on'].map { |col| table[col] }
|
149
|
+
query = query.distinct(*distinct_columns)
|
150
|
+
else
|
151
|
+
query = query.distinct
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
params_hash = normalize_params_with_model(intent)
|
156
|
+
bind_spec = []
|
157
|
+
|
158
|
+
if (filters = intent['filters']).present?
|
159
|
+
where_conditions = filters.map do |filter|
|
160
|
+
build_arel_condition(table, filter, bind_spec)
|
161
|
+
end
|
162
|
+
where_conditions.compact.each do |condition|
|
163
|
+
query = query.where(condition)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
if (orders = intent['order']).present?
|
168
|
+
orders.each do |order_spec|
|
169
|
+
column = table[order_spec['column']]
|
170
|
+
direction = order_spec['dir']&.downcase == 'desc' ? :desc : :asc
|
171
|
+
query = query.order(column.send(direction))
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
if (aggregations = intent['aggregations']).present?
|
176
|
+
query = apply_arel_aggregations(query, table, aggregations)
|
177
|
+
end
|
178
|
+
|
179
|
+
if (group_columns = intent['group_by']).present?
|
180
|
+
group_columns.each do |col|
|
181
|
+
query = query.group(table[col])
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
if (limit = determine_appropriate_limit(intent))
|
186
|
+
query = query.take(limit)
|
187
|
+
end
|
188
|
+
|
189
|
+
connection = ActiveRecord::Base.connection
|
190
|
+
visitor = connection.visitor
|
191
|
+
|
192
|
+
sql = visitor.accept(query.ast, Arel::Collectors::SQLString.new).value
|
193
|
+
|
194
|
+
{ sql: sql, params: params_hash, bind_spec: bind_spec }
|
195
|
+
rescue StandardError => e
|
196
|
+
@config.logger.warn("[code_to_query] Arel compilation failed: #{e.message}")
|
197
|
+
compile_with_string_building(intent)
|
198
|
+
end
|
199
|
+
|
200
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength
|
201
|
+
# NOTE: This method is intentionally monolithic for clarity and to avoid regressions in SQL assembly.
|
202
|
+
# TODO: Extract EXISTS/NOT EXISTS handling and simple predicate building into small helpers.
|
203
|
+
def compile_with_string_building(intent)
|
204
|
+
table = intent.fetch('table')
|
205
|
+
# Detect function columns (e.g., COUNT(*), SUM(amount)) and build proper SELECT list
|
206
|
+
raw_columns = intent['columns'].presence || ['*']
|
207
|
+
function_specs = Array(raw_columns).map { |c| parse_function_column(c) }.compact
|
208
|
+
columns = if function_specs.any?
|
209
|
+
# Support single or multiple function projections
|
210
|
+
function_specs.map do |fn|
|
211
|
+
func = fn[:func]
|
212
|
+
col = fn[:column]
|
213
|
+
case func
|
214
|
+
when 'count'
|
215
|
+
col ? "COUNT(#{quote_ident(col)}) as count" : 'COUNT(*) as count'
|
216
|
+
when 'sum'
|
217
|
+
"SUM(#{quote_ident(col)}) as sum"
|
218
|
+
when 'avg'
|
219
|
+
"AVG(#{quote_ident(col)}) as avg"
|
220
|
+
when 'max'
|
221
|
+
"MAX(#{quote_ident(col)}) as max"
|
222
|
+
when 'min'
|
223
|
+
"MIN(#{quote_ident(col)}) as min"
|
224
|
+
else
|
225
|
+
quote_ident(col.to_s)
|
226
|
+
end
|
227
|
+
end.join(', ')
|
228
|
+
else
|
229
|
+
Array(raw_columns).map { |c| quote_ident(c) }.join(', ')
|
230
|
+
end
|
231
|
+
|
232
|
+
# Handle DISTINCT
|
233
|
+
distinct_clause = ''
|
234
|
+
if intent['distinct']
|
235
|
+
if intent['distinct_on']&.any?
|
236
|
+
# PostgreSQL DISTINCT ON
|
237
|
+
distinct_on_cols = intent['distinct_on'].map { |c| quote_ident(c) }.join(', ')
|
238
|
+
distinct_clause = "DISTINCT ON (#{distinct_on_cols}) "
|
239
|
+
else
|
240
|
+
distinct_clause = 'DISTINCT '
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
sql_parts = []
|
245
|
+
sql_parts << "SELECT #{distinct_clause}#{columns} FROM #{quote_ident(table)}"
|
246
|
+
|
247
|
+
params_hash = normalize_params_with_model(intent)
|
248
|
+
bind_spec = []
|
249
|
+
placeholder_index = 1
|
250
|
+
|
251
|
+
if (filters = intent['filters']).present?
|
252
|
+
where_fragments = filters.map do |f|
|
253
|
+
col = quote_ident(f['column'])
|
254
|
+
case f['op']
|
255
|
+
when '=', '>', '<', '>=', '<=', '!=', '<>'
|
256
|
+
key = f['param'] || f['column']
|
257
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
258
|
+
bind_spec << { key: key, column: f['column'], cast: nil }
|
259
|
+
fragment = "#{col} #{f['op']} #{placeholder}"
|
260
|
+
placeholder_index += 1
|
261
|
+
fragment
|
262
|
+
when 'exists'
|
263
|
+
related_table = f['related_table']
|
264
|
+
fk_column = f['fk_column']
|
265
|
+
base_column = f['base_column'] || 'id'
|
266
|
+
related_filters = Array(f['related_filters'])
|
267
|
+
|
268
|
+
raise ArgumentError, 'exists requires related_table and fk_column' if related_table.nil? || fk_column.nil?
|
269
|
+
|
270
|
+
rt = quote_ident(related_table)
|
271
|
+
fk_col = quote_ident(fk_column)
|
272
|
+
base_col = quote_ident(base_column)
|
273
|
+
|
274
|
+
sub_where = []
|
275
|
+
sub_where << "#{rt}.#{fk_col} = #{quote_ident(table)}.#{base_col}"
|
276
|
+
|
277
|
+
# Inject policy predicates for related table if available
|
278
|
+
sub_where, placeholder_index = apply_policy_in_subquery(
|
279
|
+
sub_where, bind_spec, related_table, placeholder_index
|
280
|
+
)
|
281
|
+
|
282
|
+
related_filters.each do |rf|
|
283
|
+
rcol = "#{rt}.#{quote_ident(rf['column'])}"
|
284
|
+
case rf['op']
|
285
|
+
when '=', '>', '<', '>=', '<=', '!=', '<>'
|
286
|
+
key = rf['param'] || rf['column']
|
287
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
288
|
+
bind_spec << ({ key: key, column: rf['column'], cast: nil })
|
289
|
+
sub_where << "#{rcol} #{rf['op']} #{placeholder}"
|
290
|
+
placeholder_index += 1
|
291
|
+
when 'between'
|
292
|
+
start_key = rf['param_start'] || 'start'
|
293
|
+
end_key = rf['param_end'] || 'end'
|
294
|
+
placeholder1 = placeholder_for_adapter(placeholder_index)
|
295
|
+
bind_spec << ({ key: start_key, column: rf['column'], cast: nil })
|
296
|
+
placeholder_index += 1
|
297
|
+
placeholder2 = placeholder_for_adapter(placeholder_index)
|
298
|
+
bind_spec << ({ key: end_key, column: rf['column'], cast: nil })
|
299
|
+
placeholder_index += 1
|
300
|
+
sub_where << "#{rcol} BETWEEN #{placeholder1} AND #{placeholder2}"
|
301
|
+
when 'in'
|
302
|
+
key = rf['param'] || rf['column']
|
303
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
304
|
+
bind_spec << ({ key: key, column: rf['column'], cast: :array })
|
305
|
+
placeholder_index += 1
|
306
|
+
sub_where << "#{rcol} IN (#{placeholder})"
|
307
|
+
when 'like', 'ilike'
|
308
|
+
key = rf['param'] || rf['column']
|
309
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
310
|
+
bind_spec << ({ key: key, column: rf['column'], cast: nil })
|
311
|
+
placeholder_index += 1
|
312
|
+
sub_where << "#{rcol} #{rf['op'].upcase} #{placeholder}"
|
313
|
+
else
|
314
|
+
raise ArgumentError, "Unsupported filter op in subquery: #{rf['op']}"
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
"EXISTS (SELECT 1 FROM #{rt} WHERE #{sub_where.join(' AND ')})"
|
319
|
+
when 'not_exists'
|
320
|
+
# Correlated NOT EXISTS subquery against a related table
|
321
|
+
related_table = f['related_table']
|
322
|
+
fk_column = f['fk_column']
|
323
|
+
base_column = f['base_column'] || 'id'
|
324
|
+
related_filters = Array(f['related_filters'])
|
325
|
+
|
326
|
+
raise ArgumentError, 'not_exists requires related_table and fk_column' if related_table.nil? || fk_column.nil?
|
327
|
+
|
328
|
+
rt = quote_ident(related_table)
|
329
|
+
fk_col = quote_ident(fk_column)
|
330
|
+
base_col = quote_ident(base_column)
|
331
|
+
|
332
|
+
sub_where = []
|
333
|
+
# Correlation predicate
|
334
|
+
sub_where << "#{rt}.#{fk_col} = #{quote_ident(table)}.#{base_col}"
|
335
|
+
|
336
|
+
# Inject policy predicates for related table if available
|
337
|
+
sub_where, placeholder_index = apply_policy_in_subquery(
|
338
|
+
sub_where, bind_spec, related_table, placeholder_index
|
339
|
+
)
|
340
|
+
|
341
|
+
# Additional predicates within the subquery
|
342
|
+
related_filters.each do |rf|
|
343
|
+
rcol = "#{rt}.#{quote_ident(rf['column'])}"
|
344
|
+
case rf['op']
|
345
|
+
when '=', '>', '<', '>=', '<=', '!=', '<>'
|
346
|
+
key = rf['param'] || rf['column']
|
347
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
348
|
+
bind_spec << { key: key, column: rf['column'], cast: nil }
|
349
|
+
sub_where << "#{rcol} #{rf['op']} #{placeholder}"
|
350
|
+
placeholder_index += 1
|
351
|
+
when 'between'
|
352
|
+
start_key = rf['param_start'] || 'start'
|
353
|
+
end_key = rf['param_end'] || 'end'
|
354
|
+
|
355
|
+
placeholder1 = placeholder_for_adapter(placeholder_index)
|
356
|
+
bind_spec << { key: start_key, column: rf['column'], cast: nil }
|
357
|
+
placeholder_index += 1
|
358
|
+
|
359
|
+
placeholder2 = placeholder_for_adapter(placeholder_index)
|
360
|
+
bind_spec << { key: end_key, column: rf['column'], cast: nil }
|
361
|
+
placeholder_index += 1
|
362
|
+
|
363
|
+
sub_where << "#{rcol} BETWEEN #{placeholder1} AND #{placeholder2}"
|
364
|
+
when 'in'
|
365
|
+
key = rf['param'] || rf['column']
|
366
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
367
|
+
bind_spec << { key: key, column: rf['column'], cast: :array }
|
368
|
+
placeholder_index += 1
|
369
|
+
sub_where << "#{rcol} IN (#{placeholder})"
|
370
|
+
when 'like', 'ilike'
|
371
|
+
key = rf['param'] || rf['column']
|
372
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
373
|
+
bind_spec << { key: key, column: rf['column'], cast: nil }
|
374
|
+
placeholder_index += 1
|
375
|
+
sub_where << "#{rcol} #{rf['op'].upcase} #{placeholder}"
|
376
|
+
else
|
377
|
+
raise ArgumentError, "Unsupported filter op in subquery: #{rf['op']}"
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
"NOT EXISTS (SELECT 1 FROM #{rt} WHERE #{sub_where.join(' AND ')})"
|
382
|
+
when 'between'
|
383
|
+
start_key = f['param_start'] || 'start'
|
384
|
+
end_key = f['param_end'] || 'end'
|
385
|
+
|
386
|
+
placeholder1 = placeholder_for_adapter(placeholder_index)
|
387
|
+
bind_spec << { key: start_key, column: f['column'], cast: nil }
|
388
|
+
placeholder_index += 1
|
389
|
+
|
390
|
+
placeholder2 = placeholder_for_adapter(placeholder_index)
|
391
|
+
bind_spec << { key: end_key, column: f['column'], cast: nil }
|
392
|
+
placeholder_index += 1
|
393
|
+
|
394
|
+
"#{col} BETWEEN #{placeholder1} AND #{placeholder2}"
|
395
|
+
when 'in'
|
396
|
+
key = f['param'] || f['column']
|
397
|
+
# For IN clauses, we'll need to handle arrays specially
|
398
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
399
|
+
bind_spec << { key: key, column: f['column'], cast: :array }
|
400
|
+
placeholder_index += 1
|
401
|
+
"#{col} IN (#{placeholder})"
|
402
|
+
when 'like', 'ilike'
|
403
|
+
key = f['param'] || f['column']
|
404
|
+
placeholder = placeholder_for_adapter(placeholder_index)
|
405
|
+
bind_spec << { key: key, column: f['column'], cast: nil }
|
406
|
+
placeholder_index += 1
|
407
|
+
"#{col} #{f['op'].upcase} #{placeholder}"
|
408
|
+
else
|
409
|
+
raise ArgumentError, "Unsupported filter op: #{f['op']}"
|
410
|
+
end
|
411
|
+
end
|
412
|
+
sql_parts << "WHERE #{where_fragments.join(' AND ')}" if where_fragments.any?
|
413
|
+
end
|
414
|
+
|
415
|
+
if (orders = intent['order']).present?
|
416
|
+
order_fragments = orders.map do |o|
|
417
|
+
dir = o['dir'].to_s.downcase == 'desc' ? 'DESC' : 'ASC'
|
418
|
+
"#{quote_ident(o['column'])} #{dir}"
|
419
|
+
end
|
420
|
+
sql_parts << "ORDER BY #{order_fragments.join(', ')}"
|
421
|
+
end
|
422
|
+
|
423
|
+
if (limit = determine_appropriate_limit(intent))
|
424
|
+
sql_parts << "LIMIT #{Integer(limit)}"
|
425
|
+
end
|
426
|
+
|
427
|
+
{ sql: sql_parts.join(' '), params: params_hash, bind_spec: bind_spec }
|
428
|
+
end
|
429
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength
|
430
|
+
|
431
|
+
def apply_policy_in_subquery(sub_where, bind_spec, related_table, placeholder_index)
|
432
|
+
return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call)
|
433
|
+
|
434
|
+
info = safely_fetch_policy(table: related_table, current_user: nil)
|
435
|
+
predicates = extract_enforced_predicates(info)
|
436
|
+
return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any?
|
437
|
+
|
438
|
+
predicates.each do |column, value|
|
439
|
+
rcol = "#{quote_ident(related_table)}.#{quote_ident(column)}"
|
440
|
+
if value.is_a?(Range) && value.begin && value.end
|
441
|
+
p1 = placeholder_for_adapter(placeholder_index)
|
442
|
+
bind_spec << { key: "policy_#{column}_start", column: column, cast: nil }
|
443
|
+
placeholder_index += 1
|
444
|
+
p2 = placeholder_for_adapter(placeholder_index)
|
445
|
+
bind_spec << { key: "policy_#{column}_end", column: column, cast: nil }
|
446
|
+
placeholder_index += 1
|
447
|
+
sub_where << "#{rcol} BETWEEN #{p1} AND #{p2}"
|
448
|
+
else
|
449
|
+
p = placeholder_for_adapter(placeholder_index)
|
450
|
+
bind_spec << { key: "policy_#{column}", column: column, cast: nil }
|
451
|
+
placeholder_index += 1
|
452
|
+
sub_where << "#{rcol} = #{p}"
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
[sub_where, placeholder_index]
|
457
|
+
rescue StandardError
|
458
|
+
[sub_where, placeholder_index]
|
459
|
+
end
|
460
|
+
|
461
|
+
def build_arel_condition(table, filter, bind_spec)
|
462
|
+
column = table[filter['column']]
|
463
|
+
operator = filter['op']
|
464
|
+
|
465
|
+
case operator
|
466
|
+
when '='
|
467
|
+
key = filter['param'] || filter['column']
|
468
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
469
|
+
column.eq(Arel::Nodes::BindParam.new(key))
|
470
|
+
when '!=', '<>'
|
471
|
+
key = filter['param'] || filter['column']
|
472
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
473
|
+
column.not_eq(Arel::Nodes::BindParam.new(key))
|
474
|
+
when 'exists'
|
475
|
+
# Force fallback to string builder for complex correlated subqueries
|
476
|
+
raise StandardError, 'exists Arel compilation is not implemented; falling back to string builder'
|
477
|
+
when 'not_exists'
|
478
|
+
# Force fallback to string builder for complex correlated subqueries
|
479
|
+
raise StandardError, 'not_exists Arel compilation is not implemented; falling back to string builder'
|
480
|
+
when '>'
|
481
|
+
key = filter['param'] || filter['column']
|
482
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
483
|
+
column.gt(Arel::Nodes::BindParam.new(key))
|
484
|
+
when '>='
|
485
|
+
key = filter['param'] || filter['column']
|
486
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
487
|
+
column.gteq(Arel::Nodes::BindParam.new(key))
|
488
|
+
when '<'
|
489
|
+
key = filter['param'] || filter['column']
|
490
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
491
|
+
column.lt(Arel::Nodes::BindParam.new(key))
|
492
|
+
when '<='
|
493
|
+
key = filter['param'] || filter['column']
|
494
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
495
|
+
column.lteq(Arel::Nodes::BindParam.new(key))
|
496
|
+
when 'between'
|
497
|
+
start_key = filter['param_start'] || 'start'
|
498
|
+
end_key = filter['param_end'] || 'end'
|
499
|
+
bind_spec << { key: start_key, column: filter['column'], cast: nil }
|
500
|
+
bind_spec << { key: end_key, column: filter['column'], cast: nil }
|
501
|
+
|
502
|
+
start_param = Arel::Nodes::BindParam.new(start_key)
|
503
|
+
end_param = Arel::Nodes::BindParam.new(end_key)
|
504
|
+
column.between(start_param..end_param)
|
505
|
+
when 'in'
|
506
|
+
key = filter['param'] || filter['column']
|
507
|
+
bind_spec << { key: key, column: filter['column'], cast: :array }
|
508
|
+
column.in(Arel::Nodes::BindParam.new(key))
|
509
|
+
when 'like'
|
510
|
+
key = filter['param'] || filter['column']
|
511
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
512
|
+
column.matches(Arel::Nodes::BindParam.new(key))
|
513
|
+
when 'ilike'
|
514
|
+
key = filter['param'] || filter['column']
|
515
|
+
bind_spec << { key: key, column: filter['column'], cast: nil }
|
516
|
+
# ilike is PostgreSQL-specific
|
517
|
+
Arel::Nodes::Matches.new(column, Arel::Nodes::BindParam.new(key), nil, true)
|
518
|
+
else
|
519
|
+
warn "[code_to_query] Unsupported Arel operator: #{operator}"
|
520
|
+
nil
|
521
|
+
end
|
522
|
+
end
|
523
|
+
|
524
|
+
def placeholder_for_adapter(index)
|
525
|
+
case @config.adapter
|
526
|
+
when :postgres, :postgresql
|
527
|
+
"$#{index}"
|
528
|
+
when :mysql
|
529
|
+
'?'
|
530
|
+
when :sqlite
|
531
|
+
'?'
|
532
|
+
else
|
533
|
+
'?' # Safe default
|
534
|
+
end
|
535
|
+
end
|
536
|
+
|
537
|
+
def quote_ident(name)
|
538
|
+
return name if name == '*'
|
539
|
+
|
540
|
+
case @config.adapter
|
541
|
+
when :postgres, :postgresql
|
542
|
+
%("#{name.gsub('"', '""')}")
|
543
|
+
when :mysql
|
544
|
+
"`#{name.gsub('`', '``')}`"
|
545
|
+
when :sqlite
|
546
|
+
%("#{name.gsub('"', '""')}")
|
547
|
+
else
|
548
|
+
%("#{name.gsub('"', '""')}")
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
def apply_arel_aggregations(query, table, aggregations)
|
553
|
+
aggregations.each do |agg|
|
554
|
+
case agg['type']
|
555
|
+
when 'count'
|
556
|
+
column = agg['column'] ? table[agg['column']] : Arel.star
|
557
|
+
query = query.project(column.count.as('count'))
|
558
|
+
when 'sum'
|
559
|
+
return query unless agg['column']
|
560
|
+
|
561
|
+
query = query.project(table[agg['column']].sum.as('sum'))
|
562
|
+
when 'avg'
|
563
|
+
return query unless agg['column']
|
564
|
+
|
565
|
+
query = query.project(table[agg['column']].average.as('avg'))
|
566
|
+
when 'max'
|
567
|
+
return query unless agg['column']
|
568
|
+
|
569
|
+
query = query.project(table[agg['column']].maximum.as('max'))
|
570
|
+
when 'min'
|
571
|
+
return query unless agg['column']
|
572
|
+
|
573
|
+
query = query.project(table[agg['column']].minimum.as('min'))
|
574
|
+
end
|
575
|
+
end
|
576
|
+
query
|
577
|
+
end
|
578
|
+
|
579
|
+
def determine_appropriate_limit(intent)
|
580
|
+
# Explicit limit always takes precedence
|
581
|
+
return intent['limit'] if intent['limit']
|
582
|
+
|
583
|
+
# Determine query type and apply appropriate limit
|
584
|
+
if has_count_aggregation?(intent)
|
585
|
+
@config.count_limit
|
586
|
+
elsif has_non_count_aggregations?(intent)
|
587
|
+
@config.aggregation_limit
|
588
|
+
elsif has_exists_checks?(intent)
|
589
|
+
@config.exists_limit
|
590
|
+
elsif intent['distinct']
|
591
|
+
@config.distinct_limit
|
592
|
+
else
|
593
|
+
@config.default_limit
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
def has_count_aggregation?(intent)
|
598
|
+
Array(intent['aggregations']).any? { |a| a['type'].to_s.downcase == 'count' } ||
|
599
|
+
Array(intent['columns']).any? { |c| c.to_s.match?(/\bcount\s*\(/i) }
|
600
|
+
end
|
601
|
+
|
602
|
+
def has_non_count_aggregations?(intent)
|
603
|
+
Array(intent['aggregations']).any? { |a| %w[sum avg max min].include?(a['type'].to_s.downcase) } ||
|
604
|
+
Array(intent['columns']).any? { |c| c.to_s.match?(/\b(sum|avg|max|min)\s*\(/i) }
|
605
|
+
end
|
606
|
+
|
607
|
+
def has_exists_checks?(intent)
|
608
|
+
intent['filters']&.any? { |filter| %w[exists not_exists].include?(filter['op']) }
|
609
|
+
end
|
610
|
+
|
611
|
+
def parse_function_column(expr)
|
612
|
+
return nil unless expr
|
613
|
+
|
614
|
+
s = expr.to_s.strip
|
615
|
+
return nil unless s.include?('(') && s.end_with?(')')
|
616
|
+
|
617
|
+
if (m = s.match(/\A\s*(count|sum|avg|max|min)\s*\(\s*(\*|[a-zA-Z0-9_\.]+)\s*\)\s*\z/i))
|
618
|
+
func = m[1].downcase
|
619
|
+
col = m[2] == '*' ? nil : m[2]
|
620
|
+
{ func: func, column: col }
|
621
|
+
end
|
622
|
+
end
|
623
|
+
|
624
|
+
def normalize_params_with_model(intent)
|
625
|
+
params = (intent['params'] || {}).dup
|
626
|
+
return params unless defined?(ActiveRecord::Base)
|
627
|
+
|
628
|
+
table_name = intent['table']
|
629
|
+
model = infer_model_for_table(table_name)
|
630
|
+
return params unless model
|
631
|
+
|
632
|
+
enum_map = model.respond_to?(:defined_enums) ? model.defined_enums : {}
|
633
|
+
Array(intent['filters']).each do |f|
|
634
|
+
col = f['column']
|
635
|
+
key = f['param'] || col
|
636
|
+
next unless key
|
637
|
+
|
638
|
+
raw = params[key.to_s] || params[key.to_sym]
|
639
|
+
next if raw.nil?
|
640
|
+
|
641
|
+
# Map Rails enum string to integer
|
642
|
+
mapping = enum_map[col] || enum_map[col.to_s]
|
643
|
+
if mapping.is_a?(Hash) && raw.is_a?(String)
|
644
|
+
val = mapping[raw] || mapping[raw.downcase]
|
645
|
+
params[key.to_s] = Integer(val) if val
|
646
|
+
end
|
647
|
+
end
|
648
|
+
|
649
|
+
params
|
650
|
+
rescue StandardError
|
651
|
+
intent['params'] || {}
|
652
|
+
end
|
653
|
+
|
654
|
+
def infer_model_for_table(table_name)
|
655
|
+
return nil unless defined?(ActiveRecord::Base)
|
656
|
+
return nil unless table_name
|
657
|
+
|
658
|
+
possible_class_names = [
|
659
|
+
table_name.singularize.camelize,
|
660
|
+
table_name.camelize,
|
661
|
+
table_name.singularize.camelize.gsub(/s$/, '')
|
662
|
+
]
|
663
|
+
|
664
|
+
possible_class_names.each do |class_name|
|
665
|
+
model = class_name.constantize
|
666
|
+
return model if model < ActiveRecord::Base && model.table_name == table_name
|
667
|
+
rescue NameError
|
668
|
+
next
|
669
|
+
end
|
670
|
+
|
671
|
+
nil
|
672
|
+
end
|
673
|
+
end
|
674
|
+
end
|