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,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