bharat_filter_engine 0.1.2 → 0.2.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.
@@ -2,9 +2,8 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class Engine
5
-
5
+ NUMERIC_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
6
6
  class << self
7
-
8
7
  def apply(scope:, config:, params:)
9
8
  new(
10
9
  scope,
@@ -16,7 +15,7 @@ module BharatFilterEngine
16
15
  def filter_values(scope:, config:, params:)
17
16
  field =
18
17
  params[:filter_field] ||
19
- params["filter_field"]
18
+ params['filter_field']
20
19
 
21
20
  rule =
22
21
  config[field&.to_sym]
@@ -33,25 +32,35 @@ module BharatFilterEngine
33
32
  rule[:dbcolumn].to_s
34
33
  end
35
34
 
35
+ term =
36
+ params[:filter_term] ||
37
+ params['filter_term']
38
+
39
+ limit =
40
+ params[:filter_limit] ||
41
+ params['filter_limit']
42
+
36
43
  DynamicFilterValues.new(
37
44
  scope: scope,
38
- field: field_name
45
+ field: field_name,
46
+ term: term,
47
+ limit: limit
39
48
  ).call || []
40
49
  end
41
-
42
50
  end
43
51
 
44
52
  def initialize(scope, config = {}, params = {})
45
- @scope = scope
46
- @config = config || {}
47
- @params = normalize_params(params)
53
+ @scope = scope
54
+ @config = config || {}
55
+ @params = normalize_params(params)
48
56
  end
49
57
 
50
58
  def call
51
59
  scope = @scope
60
+ @tracker = JoinTracker.new(scope.klass)
61
+ @subquery_groups = {}
52
62
 
53
63
  @config.each do |key, raw_rule|
54
-
55
64
  value =
56
65
  @params[key.to_sym]
57
66
 
@@ -70,6 +79,10 @@ module BharatFilterEngine
70
79
  )
71
80
  end
72
81
 
82
+ @subquery_groups.each_value do |group|
83
+ scope = apply_subquery_group(scope, group)
84
+ end
85
+
73
86
  scope
74
87
  end
75
88
 
@@ -104,7 +117,25 @@ module BharatFilterEngine
104
117
  association
105
118
  )
106
119
 
107
- return scope unless result
120
+ unless result
121
+ raise InvalidAssociationError,
122
+ 'BharatFilterEngine: could not resolve association ' \
123
+ "#{association.inspect} configured for filter " \
124
+ "#{rule[:dbcolumn].inspect}. Check the `association:` key " \
125
+ 'in your filter config.'
126
+ end
127
+
128
+ if @tracker.claim(result) == :subquery
129
+ group =
130
+ (@subquery_groups[result[:associations]] ||= {
131
+ result: result,
132
+ entries: []
133
+ })
134
+
135
+ group[:entries] << [rule, value]
136
+
137
+ return scope
138
+ end
108
139
 
109
140
  joined_scope =
110
141
  resolver.join(
@@ -116,17 +147,37 @@ module BharatFilterEngine
116
147
  joined_scope,
117
148
  rule,
118
149
  value,
119
- table_name: result[:table_name]
150
+ table_name: result[:table_name],
151
+ klass: result[:klass]
120
152
  ).distinct
121
153
  end
122
154
 
155
+ def apply_subquery_group(scope, group)
156
+ result = group[:result]
157
+
158
+ subquery =
159
+ AssociationResolver.new(scope).subquery(result[:associations]) do |relation|
160
+ group[:entries].reduce(relation) do |current, (rule, value)|
161
+ apply_filter_by_type(
162
+ current,
163
+ rule,
164
+ value,
165
+ table_name: result[:table_name],
166
+ klass: result[:klass]
167
+ )
168
+ end
169
+ end
170
+
171
+ scope.where(scope.klass.primary_key => subquery)
172
+ end
173
+
123
174
  def apply_filter_by_type(
124
175
  scope,
125
176
  rule,
126
177
  value,
127
- table_name: nil
178
+ table_name: nil,
179
+ klass: nil
128
180
  )
129
-
130
181
  column =
131
182
  rule[:dbcolumn]
132
183
 
@@ -137,7 +188,8 @@ module BharatFilterEngine
137
188
  scope,
138
189
  column,
139
190
  value,
140
- table_name
191
+ table_name,
192
+ negate: rule[:negate]
141
193
  )
142
194
 
143
195
  when :boolean
@@ -145,14 +197,16 @@ module BharatFilterEngine
145
197
  scope,
146
198
  column,
147
199
  value,
148
- table_name
200
+ table_name,
201
+ negate: rule[:negate]
149
202
  )
150
203
 
151
204
  when :integer
152
205
  apply_numeric_filter(
153
206
  scope,
154
207
  rule,
155
- value.to_i,
208
+ value,
209
+ :to_i,
156
210
  table_name: table_name
157
211
  )
158
212
 
@@ -160,7 +214,8 @@ module BharatFilterEngine
160
214
  apply_numeric_filter(
161
215
  scope,
162
216
  rule,
163
- value.to_f,
217
+ value,
218
+ :to_f,
164
219
  table_name: table_name
165
220
  )
166
221
 
@@ -169,7 +224,8 @@ module BharatFilterEngine
169
224
  scope,
170
225
  column,
171
226
  value,
172
- table_name
227
+ table_name,
228
+ negate: rule[:negate]
173
229
  )
174
230
 
175
231
  when :daterange
@@ -180,10 +236,20 @@ module BharatFilterEngine
180
236
  table_name: table_name
181
237
  )
182
238
 
239
+ when :presence
240
+ apply_presence_filter(
241
+ scope,
242
+ column,
243
+ value,
244
+ table_name,
245
+ klass: klass
246
+ )
247
+
183
248
  when :search
184
249
  SearchBuilder.new(
185
250
  scope: scope,
186
- allowed_columns: rule[:dbcolumns]
251
+ allowed_columns: rule[:dbcolumns],
252
+ tracker: @tracker
187
253
  ).apply(value)
188
254
 
189
255
  else
@@ -195,14 +261,15 @@ module BharatFilterEngine
195
261
  scope,
196
262
  column,
197
263
  value,
198
- table_name
264
+ table_name,
265
+ negate: false
199
266
  )
200
-
201
267
  apply_condition(
202
268
  scope,
203
269
  column,
204
270
  Array(value),
205
- table_name
271
+ table_name,
272
+ negate: negate
206
273
  )
207
274
  end
208
275
 
@@ -210,19 +277,20 @@ module BharatFilterEngine
210
277
  scope,
211
278
  column,
212
279
  value,
213
- table_name
280
+ table_name,
281
+ negate: false
214
282
  )
215
-
216
283
  cast_value =
217
284
  ActiveModel::Type::Boolean
218
- .new
219
- .cast(value)
285
+ .new
286
+ .cast(value)
220
287
 
221
288
  apply_condition(
222
289
  scope,
223
290
  column,
224
291
  cast_value,
225
- table_name
292
+ table_name,
293
+ negate: negate
226
294
  )
227
295
  end
228
296
 
@@ -230,41 +298,82 @@ module BharatFilterEngine
230
298
  scope,
231
299
  column,
232
300
  value,
233
- table_name
301
+ table_name,
302
+ negate: false
234
303
  )
235
-
236
304
  apply_condition(
237
305
  scope,
238
306
  column,
239
307
  value,
240
- table_name
308
+ table_name,
309
+ negate: negate
241
310
  )
242
311
  end
243
312
 
244
- # Shared by array/boolean/string/exact-numeric filters: applies a
245
- # plain equality (or IN, for arrays) condition, qualifying it by
246
- # table_name when the filter comes from a joined association.
247
- def apply_condition(scope, column, value, table_name)
248
- if table_name
249
- scope.where(
250
- table_name => {
251
- column => value
252
- }
253
- )
313
+ def apply_presence_filter(scope, column, value, table_name, klass: nil)
314
+ present = ActiveModel::Type::Boolean.new.cast(value)
315
+ klass ||= scope.klass
316
+ arel_column =
317
+ if table_name
318
+ Arel::Table.new(table_name)[column]
319
+ else
320
+ klass.arel_table[column]
321
+ end
322
+
323
+ if present
324
+ scope.where(ColumnHelper.present_predicate(arel_column, klass, column))
254
325
  else
255
- scope.where(
256
- column => value
326
+ scope.where(ColumnHelper.blank_predicate(arel_column, klass, column))
327
+ end
328
+ end
329
+
330
+ def apply_condition(scope, column, value, table_name, negate: false)
331
+ if negate && value.is_a?(Array)
332
+ return apply_negated_array_condition(
333
+ scope,
334
+ column,
335
+ value,
336
+ table_name
257
337
  )
258
338
  end
339
+
340
+ condition =
341
+ if table_name
342
+ { table_name => { column => value } }
343
+ else
344
+ { column => value }
345
+ end
346
+
347
+ negate ? scope.where.not(condition) : scope.where(condition)
348
+ end
349
+
350
+ def apply_negated_array_condition(scope, column, values, table_name)
351
+ arel_column =
352
+ if table_name
353
+ Arel::Table.new(table_name)[column]
354
+ else
355
+ scope.klass.arel_table[column]
356
+ end
357
+
358
+ membership = arel_column.in(values.compact)
359
+
360
+ predicate =
361
+ if values.include?(nil)
362
+ arel_column.eq(nil).or(membership)
363
+ else
364
+ membership
365
+ end
366
+
367
+ scope.where(Arel::Nodes::Not.new(predicate))
259
368
  end
260
369
 
261
370
  def apply_numeric_filter(
262
371
  scope,
263
372
  rule,
264
373
  value,
374
+ caster,
265
375
  table_name: nil
266
376
  )
267
-
268
377
  column =
269
378
  rule[:dbcolumn]
270
379
 
@@ -278,36 +387,80 @@ module BharatFilterEngine
278
387
  case rule[:range_type]
279
388
 
280
389
  when :gte
390
+ return scope unless numeric_string?(value)
281
391
 
282
- scope.where(
283
- "#{qualified_column} >= ?",
284
- value
285
- )
392
+ scope.where("#{qualified_column} >= ?", cast_numeric(value, caster))
286
393
 
287
394
  when :lte
395
+ return scope unless numeric_string?(value)
288
396
 
289
- scope.where(
290
- "#{qualified_column} <= ?",
291
- value
397
+ scope.where("#{qualified_column} <= ?", cast_numeric(value, caster))
398
+
399
+ when :between
400
+ apply_numeric_between(
401
+ scope,
402
+ qualified_column,
403
+ value,
404
+ caster
292
405
  )
293
406
 
294
407
  else
408
+ return scope unless numeric_string?(value)
409
+
295
410
  apply_condition(
296
411
  scope,
297
412
  column,
298
- value,
299
- table_name
413
+ cast_numeric(value, caster),
414
+ table_name,
415
+ negate: rule[:negate]
300
416
  )
301
417
  end
302
418
  end
303
419
 
420
+ def apply_numeric_between(scope, qualified_column, value, caster)
421
+ return scope unless value.present?
422
+
423
+ raw_from, raw_to =
424
+ extract_bounds(value)
425
+
426
+ return scope unless
427
+ raw_from.present? || raw_to.present?
428
+
429
+ if raw_from.present? && numeric_string?(raw_from)
430
+ from =
431
+ cast_numeric(raw_from, caster)
432
+ end
433
+
434
+ if raw_to.present? && numeric_string?(raw_to)
435
+ to =
436
+ cast_numeric(raw_to, caster)
437
+ end
438
+
439
+ return scope unless from || to
440
+
441
+ if from && to
442
+ scope.where(qualified_column => from..to)
443
+ elsif from
444
+ scope.where("#{qualified_column} >= ?", from)
445
+ else
446
+ scope.where("#{qualified_column} <= ?", to)
447
+ end
448
+ end
449
+
450
+ def numeric_string?(value)
451
+ value.to_s.strip.match?(NUMERIC_PATTERN)
452
+ end
453
+
454
+ def cast_numeric(value, caster)
455
+ value.to_s.public_send(caster)
456
+ end
457
+
304
458
  def apply_date_range_filter(
305
459
  scope,
306
460
  rule,
307
461
  value,
308
462
  table_name: nil
309
463
  )
310
-
311
464
  return scope unless value.present?
312
465
 
313
466
  column =
@@ -320,42 +473,27 @@ module BharatFilterEngine
320
473
  column.to_s
321
474
  end
322
475
 
323
- from, to =
324
- case value
325
-
326
- when Hash
327
- [
328
- value[:from] ||
329
- value["from"],
330
-
331
- value[:to] ||
332
- value["to"]
333
- ]
334
-
335
- when Array
336
- value
337
-
338
- when String
339
- value
340
- .split(",")
341
- .map(&:strip)
342
-
343
- end
476
+ raw_from, raw_to =
477
+ extract_bounds(value)
344
478
 
345
479
  return scope unless
346
- from.present? || to.present?
480
+ raw_from.present? || raw_to.present?
347
481
 
348
- from =
349
- normalize_date(
350
- from,
351
- :start
352
- ) if from.present?
482
+ if raw_from.present?
483
+ from =
484
+ normalize_date(
485
+ raw_from,
486
+ :start
487
+ )
488
+ end
353
489
 
354
- to =
355
- normalize_date(
356
- to,
357
- :end
358
- ) if to.present?
490
+ if raw_to.present?
491
+ to =
492
+ normalize_date(
493
+ raw_to,
494
+ :end
495
+ )
496
+ end
359
497
 
360
498
  if from.present? && to.present?
361
499
 
@@ -379,6 +517,28 @@ module BharatFilterEngine
379
517
  end
380
518
  end
381
519
 
520
+ def extract_bounds(value)
521
+ case value
522
+
523
+ when Hash
524
+ [
525
+ value[:from] || value['from'],
526
+ value[:to] || value['to']
527
+ ]
528
+
529
+ when Array
530
+ value
531
+
532
+ when String
533
+ value
534
+ .split(',')
535
+ .map(&:strip)
536
+
537
+ else
538
+ [nil, nil]
539
+ end
540
+ end
541
+
382
542
  def normalize_date(value, boundary)
383
543
  return value if
384
544
  value.is_a?(Time) ||
@@ -404,13 +564,11 @@ module BharatFilterEngine
404
564
 
405
565
  def deep_normalize_params(params)
406
566
  params.transform_values do |value|
407
-
408
567
  if value.is_a?(Hash)
409
568
  deep_normalize_params(value)
410
569
  else
411
570
  normalize_value(value)
412
571
  end
413
-
414
572
  end
415
573
  end
416
574
 
@@ -421,17 +579,17 @@ module BharatFilterEngine
421
579
  stripped =
422
580
  value.strip
423
581
 
424
- if stripped.start_with?("[")
582
+ if stripped.start_with?('[')
425
583
  begin
426
584
  JSON.parse(stripped)
427
585
  rescue JSON::ParserError
428
586
  value
429
587
  end
430
588
 
431
- elsif value.include?(",")
589
+ elsif value.include?(',')
432
590
 
433
591
  value
434
- .split(",")
592
+ .split(',')
435
593
  .map(&:strip)
436
594
 
437
595
  else
@@ -440,4 +598,4 @@ module BharatFilterEngine
440
598
  end
441
599
  end
442
600
  end
443
- end
601
+ end
@@ -12,4 +12,4 @@ module BharatFilterEngine
12
12
 
13
13
  class InvalidAssociationError < Error
14
14
  end
15
- end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module BharatFilterEngine
6
+ class JoinTracker
7
+ def initialize(base_klass)
8
+ @tables = Set.new([base_klass.table_name])
9
+ @chains = Set.new
10
+ @subquery_supported = base_klass.primary_key.is_a?(String)
11
+ end
12
+
13
+ # Returns :join or :subquery.
14
+ def claim(result)
15
+ chain = result[:associations]
16
+
17
+ return :join if chain.blank?
18
+ return :join if @chains.include?(chain)
19
+ return :join unless @subquery_supported
20
+ return :subquery if @tables.include?(result[:table_name])
21
+
22
+ register(result)
23
+ :join
24
+ end
25
+
26
+ private
27
+
28
+ def register(result)
29
+ chain = result[:associations]
30
+
31
+ chain.each_index do |index|
32
+ @chains << chain[0..index]
33
+ @tables << result[:reflections][index].klass.table_name
34
+ end
35
+ end
36
+ end
37
+ end