bharat_filter_engine 0.1.1 → 0.2.0pre

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e04f7c0ff8f139d774a73fd2b55df78155916228f0b1e7a59a92caa9f93e9a2
4
- data.tar.gz: e8398e7c34db184b5bbf6fec1b73c9dfa24dd10039154bcc2fb75d778578cf75
3
+ metadata.gz: 811680608cacda9f967cd20ce13482ed5cbe637ce21193d6d7b4932266f15556
4
+ data.tar.gz: 41c8837e9ae4df42f83d5fae6a7907ed00dfb7308f72273dbe23d7e05f55ac03
5
5
  SHA512:
6
- metadata.gz: 81976ab2384b41f117ff06b02132753585bb5e0d2f5665cf2f7bb4896a9a3991b3c939b948f2e632cd91df237dff80406b7d0d7a025ecec5700ac43db83f8490
7
- data.tar.gz: 6bdc8729c91673c98a7c2600f338ab71e4536436ec4960e736f5e51d1c488ee3a75863805cea324c494275cf34524e785148972c263a0b8a9f50b57169987560
6
+ metadata.gz: c61055a2cb73bd87b4ddb0bc1258515ee78d1f9dbafe51ad1027165de23b054c3a5d07bb3d3b14c6df089c6d6b34f99b565bedae8a5d05eb63f7ff31bedab5e6
7
+ data.tar.gz: d68e184b788757dcbb8940cb2096ce85859b6a70864457f4977a3b4607316fa7d455e078624ed5f8acc39a78f458331ef4c0bb7b7f455a6d5f4de57dd01176b1
data/Gemfile CHANGED
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- source "https://rubygems.org"
3
+ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
- gem "activerecord", ">= 6.1"
8
- gem "activemodel", ">= 6.1"
9
- gem "activesupport", ">= 6.1"
7
+ gem 'activemodel', '>= 6.1'
8
+ gem 'activerecord', '>= 6.1'
9
+ gem 'activesupport', '>= 6.1'
10
10
 
11
11
  group :development, :test do
12
- gem "sqlite3"
13
- gem "rspec"
14
- gem "rake"
12
+ gem 'rake'
13
+ gem 'rspec'
14
+ gem 'rubocop-rails-omakase', require: false
15
+ gem 'sqlite3'
15
16
  end
16
17
 
17
18
  group :test do
18
- gem "database_cleaner-active_record"
19
- end
19
+ gem 'database_cleaner-active_record'
20
+ end
data/README.md CHANGED
@@ -8,7 +8,7 @@ query string) into a filtered `ActiveRecord::Relation` — including
8
8
  filters on associated models, free-text search, and dynamically
9
9
  populated dropdown values.
10
10
 
11
- [![Gem Version](https://img.shields.io/badge/version-0.1.1-blue)](CHANGELOG.md)
11
+ [![Gem Version](https://img.shields.io/badge/version-0.1.2-blue)](CHANGELOG.md)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
13
13
 
14
14
  ---
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task default: :spec
8
+ task default: :spec
@@ -7,7 +7,7 @@ module BharatFilterEngine
7
7
  end
8
8
 
9
9
  def resolve(path)
10
- parts = path.to_s.split("__").map(&:to_sym)
10
+ parts = path.to_s.split('__').map(&:to_sym)
11
11
 
12
12
  return nil if parts.empty?
13
13
 
@@ -58,13 +58,13 @@ module BharatFilterEngine
58
58
  # lead -> client -> name
59
59
  # ----------------------------------------
60
60
 
61
- parts = field.split("__")
61
+ parts = field.split('__')
62
62
 
63
63
  column =
64
64
  parts.pop
65
65
 
66
66
  association_path =
67
- parts.join("__")
67
+ parts.join('__')
68
68
 
69
69
  result =
70
70
  resolve(association_path)
@@ -99,4 +99,4 @@ module BharatFilterEngine
99
99
  end
100
100
  end
101
101
  end
102
- end
102
+ end
@@ -2,10 +2,14 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class DynamicFilterValues
5
- def initialize(scope:, model: nil, field:)
5
+ LIKE_ESCAPE_CHAR = '\\'
6
+
7
+ def initialize(scope:, field:, model: nil, term: nil, limit: nil)
6
8
  @scope = scope
7
9
  @model = model || scope.klass
8
10
  @field = field.to_s
11
+ @term = term.presence
12
+ @limit = limit.presence&.to_i
9
13
  end
10
14
 
11
15
  def call
@@ -20,20 +24,32 @@ module BharatFilterEngine
20
24
  result[:associations]
21
25
  )
22
26
 
23
- column = result[:column]
24
- nested = result[:associations].present?
27
+ arel_column = result[:klass].arel_table[result[:column]]
28
+
29
+ scoped =
30
+ scoped.where(
31
+ arel_column.not_eq(nil).and(
32
+ arel_column.not_eq('')
33
+ )
34
+ )
35
+
36
+ scoped = apply_term(scoped, arel_column) if @term
37
+
38
+ scoped = scoped.distinct.order(arel_column)
39
+ scoped = scoped.limit(@limit) if @limit&.positive?
40
+
41
+ scoped.pluck(arel_column)
42
+ end
25
43
 
26
- not_condition =
27
- nested ? { result[:table_name] => { column => [nil, ""] } } : { column => [nil, ""] }
44
+ private
28
45
 
29
- qualified_column =
30
- nested ? Arel.sql("#{result[:table_name]}.#{column}") : column
46
+ def apply_term(scoped, arel_column)
47
+ escaped =
48
+ @term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
31
49
 
32
- scoped
33
- .where.not(not_condition)
34
- .distinct
35
- .order(qualified_column)
36
- .pluck(qualified_column)
50
+ scoped.where(
51
+ arel_column.matches("%#{escaped}%", LIKE_ESCAPE_CHAR)
52
+ )
37
53
  end
38
54
  end
39
55
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class Engine
5
-
6
5
  class << self
7
-
8
6
  def apply(scope:, config:, params:)
9
7
  new(
10
8
  scope,
@@ -16,7 +14,7 @@ module BharatFilterEngine
16
14
  def filter_values(scope:, config:, params:)
17
15
  field =
18
16
  params[:filter_field] ||
19
- params["filter_field"]
17
+ params['filter_field']
20
18
 
21
19
  rule =
22
20
  config[field&.to_sym]
@@ -33,25 +31,33 @@ module BharatFilterEngine
33
31
  rule[:dbcolumn].to_s
34
32
  end
35
33
 
34
+ term =
35
+ params[:filter_term] ||
36
+ params['filter_term']
37
+
38
+ limit =
39
+ params[:filter_limit] ||
40
+ params['filter_limit']
41
+
36
42
  DynamicFilterValues.new(
37
43
  scope: scope,
38
- field: field_name
44
+ field: field_name,
45
+ term: term,
46
+ limit: limit
39
47
  ).call || []
40
48
  end
41
-
42
49
  end
43
50
 
44
51
  def initialize(scope, config = {}, params = {})
45
- @scope = scope
46
- @config = config || {}
47
- @params = normalize_params(params)
52
+ @scope = scope
53
+ @config = config || {}
54
+ @params = normalize_params(params)
48
55
  end
49
56
 
50
57
  def call
51
58
  scope = @scope
52
59
 
53
60
  @config.each do |key, raw_rule|
54
-
55
61
  value =
56
62
  @params[key.to_sym]
57
63
 
@@ -104,7 +110,13 @@ module BharatFilterEngine
104
110
  association
105
111
  )
106
112
 
107
- return scope unless result
113
+ unless result
114
+ raise InvalidAssociationError,
115
+ 'BharatFilterEngine: could not resolve association ' \
116
+ "#{association.inspect} configured for filter " \
117
+ "#{rule[:dbcolumn].inspect}. Check the `association:` key " \
118
+ 'in your filter config.'
119
+ end
108
120
 
109
121
  joined_scope =
110
122
  resolver.join(
@@ -126,7 +138,6 @@ module BharatFilterEngine
126
138
  value,
127
139
  table_name: nil
128
140
  )
129
-
130
141
  column =
131
142
  rule[:dbcolumn]
132
143
 
@@ -137,7 +148,8 @@ module BharatFilterEngine
137
148
  scope,
138
149
  column,
139
150
  value,
140
- table_name
151
+ table_name,
152
+ negate: rule[:negate]
141
153
  )
142
154
 
143
155
  when :boolean
@@ -145,14 +157,16 @@ module BharatFilterEngine
145
157
  scope,
146
158
  column,
147
159
  value,
148
- table_name
160
+ table_name,
161
+ negate: rule[:negate]
149
162
  )
150
163
 
151
164
  when :integer
152
165
  apply_numeric_filter(
153
166
  scope,
154
167
  rule,
155
- value.to_i,
168
+ value,
169
+ :to_i,
156
170
  table_name: table_name
157
171
  )
158
172
 
@@ -160,7 +174,8 @@ module BharatFilterEngine
160
174
  apply_numeric_filter(
161
175
  scope,
162
176
  rule,
163
- value.to_f,
177
+ value,
178
+ :to_f,
164
179
  table_name: table_name
165
180
  )
166
181
 
@@ -169,7 +184,8 @@ module BharatFilterEngine
169
184
  scope,
170
185
  column,
171
186
  value,
172
- table_name
187
+ table_name,
188
+ negate: rule[:negate]
173
189
  )
174
190
 
175
191
  when :daterange
@@ -180,6 +196,14 @@ module BharatFilterEngine
180
196
  table_name: table_name
181
197
  )
182
198
 
199
+ when :presence
200
+ apply_presence_filter(
201
+ scope,
202
+ column,
203
+ value,
204
+ table_name
205
+ )
206
+
183
207
  when :search
184
208
  SearchBuilder.new(
185
209
  scope: scope,
@@ -195,14 +219,15 @@ module BharatFilterEngine
195
219
  scope,
196
220
  column,
197
221
  value,
198
- table_name
222
+ table_name,
223
+ negate: false
199
224
  )
200
-
201
225
  apply_condition(
202
226
  scope,
203
227
  column,
204
228
  Array(value),
205
- table_name
229
+ table_name,
230
+ negate: negate
206
231
  )
207
232
  end
208
233
 
@@ -210,19 +235,20 @@ module BharatFilterEngine
210
235
  scope,
211
236
  column,
212
237
  value,
213
- table_name
238
+ table_name,
239
+ negate: false
214
240
  )
215
-
216
241
  cast_value =
217
242
  ActiveModel::Type::Boolean
218
- .new
219
- .cast(value)
243
+ .new
244
+ .cast(value)
220
245
 
221
246
  apply_condition(
222
247
  scope,
223
248
  column,
224
249
  cast_value,
225
- table_name
250
+ table_name,
251
+ negate: negate
226
252
  )
227
253
  end
228
254
 
@@ -230,41 +256,92 @@ module BharatFilterEngine
230
256
  scope,
231
257
  column,
232
258
  value,
233
- table_name
259
+ table_name,
260
+ negate: false
234
261
  )
235
-
236
262
  apply_condition(
237
263
  scope,
238
264
  column,
239
265
  value,
240
- table_name
266
+ table_name,
267
+ negate: negate
241
268
  )
242
269
  end
243
270
 
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
271
+ def apply_presence_filter(scope, column, value, table_name)
272
+ present = ActiveModel::Type::Boolean.new.cast(value)
273
+
274
+ arel_column =
275
+ if table_name
276
+ Arel::Table.new(table_name)[column]
277
+ else
278
+ scope.klass.arel_table[column]
279
+ end
280
+
281
+ if present
249
282
  scope.where(
250
- table_name => {
251
- column => value
252
- }
283
+ arel_column.not_eq(nil).and(
284
+ arel_column.not_eq('')
285
+ )
253
286
  )
254
287
  else
255
288
  scope.where(
256
- column => value
289
+ arel_column.eq(nil).or(
290
+ arel_column.eq('')
291
+ )
292
+ )
293
+ end
294
+ end
295
+
296
+ def apply_condition(scope, column, value, table_name, negate: false)
297
+ if negate && value.is_a?(Array)
298
+ return apply_negated_array_condition(
299
+ scope,
300
+ column,
301
+ value,
302
+ table_name
257
303
  )
258
304
  end
305
+
306
+ condition =
307
+ if table_name
308
+ { table_name => { column => value } }
309
+ else
310
+ { column => value }
311
+ end
312
+
313
+ negate ? scope.where.not(condition) : scope.where(condition)
259
314
  end
260
315
 
316
+ def apply_negated_array_condition(scope, column, values, table_name)
317
+ arel_column =
318
+ if table_name
319
+ Arel::Table.new(table_name)[column]
320
+ else
321
+ scope.klass.arel_table[column]
322
+ end
323
+
324
+ membership = arel_column.in(values.compact)
325
+
326
+ predicate =
327
+ if values.include?(nil)
328
+ arel_column.eq(nil).or(membership)
329
+ else
330
+ membership
331
+ end
332
+
333
+ scope.where(Arel::Nodes::Not.new(predicate))
334
+ end
335
+
336
+ NUMERIC_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
337
+
261
338
  def apply_numeric_filter(
262
339
  scope,
263
340
  rule,
264
341
  value,
342
+ caster,
265
343
  table_name: nil
266
344
  )
267
-
268
345
  column =
269
346
  rule[:dbcolumn]
270
347
 
@@ -278,36 +355,80 @@ module BharatFilterEngine
278
355
  case rule[:range_type]
279
356
 
280
357
  when :gte
358
+ return scope unless numeric_string?(value)
281
359
 
282
- scope.where(
283
- "#{qualified_column} >= ?",
284
- value
285
- )
360
+ scope.where("#{qualified_column} >= ?", cast_numeric(value, caster))
286
361
 
287
362
  when :lte
363
+ return scope unless numeric_string?(value)
288
364
 
289
- scope.where(
290
- "#{qualified_column} <= ?",
291
- value
365
+ scope.where("#{qualified_column} <= ?", cast_numeric(value, caster))
366
+
367
+ when :between
368
+ apply_numeric_between(
369
+ scope,
370
+ qualified_column,
371
+ value,
372
+ caster
292
373
  )
293
374
 
294
375
  else
376
+ return scope unless numeric_string?(value)
377
+
295
378
  apply_condition(
296
379
  scope,
297
380
  column,
298
- value,
299
- table_name
381
+ cast_numeric(value, caster),
382
+ table_name,
383
+ negate: rule[:negate]
300
384
  )
301
385
  end
302
386
  end
303
387
 
388
+ def apply_numeric_between(scope, qualified_column, value, caster)
389
+ return scope unless value.present?
390
+
391
+ raw_from, raw_to =
392
+ extract_bounds(value)
393
+
394
+ return scope unless
395
+ raw_from.present? || raw_to.present?
396
+
397
+ if raw_from.present? && numeric_string?(raw_from)
398
+ from =
399
+ cast_numeric(raw_from, caster)
400
+ end
401
+
402
+ if raw_to.present? && numeric_string?(raw_to)
403
+ to =
404
+ cast_numeric(raw_to, caster)
405
+ end
406
+
407
+ return scope unless from || to
408
+
409
+ if from && to
410
+ scope.where(qualified_column => from..to)
411
+ elsif from
412
+ scope.where("#{qualified_column} >= ?", from)
413
+ else
414
+ scope.where("#{qualified_column} <= ?", to)
415
+ end
416
+ end
417
+
418
+ def numeric_string?(value)
419
+ value.to_s.strip.match?(NUMERIC_PATTERN)
420
+ end
421
+
422
+ def cast_numeric(value, caster)
423
+ value.to_s.public_send(caster)
424
+ end
425
+
304
426
  def apply_date_range_filter(
305
427
  scope,
306
428
  rule,
307
429
  value,
308
430
  table_name: nil
309
431
  )
310
-
311
432
  return scope unless value.present?
312
433
 
313
434
  column =
@@ -320,42 +441,27 @@ module BharatFilterEngine
320
441
  column.to_s
321
442
  end
322
443
 
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
444
+ raw_from, raw_to =
445
+ extract_bounds(value)
344
446
 
345
447
  return scope unless
346
- from.present? || to.present?
448
+ raw_from.present? || raw_to.present?
347
449
 
348
- from =
349
- normalize_date(
350
- from,
351
- :start
352
- ) if from.present?
450
+ if raw_from.present?
451
+ from =
452
+ normalize_date(
453
+ raw_from,
454
+ :start
455
+ )
456
+ end
353
457
 
354
- to =
355
- normalize_date(
356
- to,
357
- :end
358
- ) if to.present?
458
+ if raw_to.present?
459
+ to =
460
+ normalize_date(
461
+ raw_to,
462
+ :end
463
+ )
464
+ end
359
465
 
360
466
  if from.present? && to.present?
361
467
 
@@ -379,6 +485,28 @@ module BharatFilterEngine
379
485
  end
380
486
  end
381
487
 
488
+ def extract_bounds(value)
489
+ case value
490
+
491
+ when Hash
492
+ [
493
+ value[:from] || value['from'],
494
+ value[:to] || value['to']
495
+ ]
496
+
497
+ when Array
498
+ value
499
+
500
+ when String
501
+ value
502
+ .split(',')
503
+ .map(&:strip)
504
+
505
+ else
506
+ [nil, nil]
507
+ end
508
+ end
509
+
382
510
  def normalize_date(value, boundary)
383
511
  return value if
384
512
  value.is_a?(Time) ||
@@ -404,13 +532,11 @@ module BharatFilterEngine
404
532
 
405
533
  def deep_normalize_params(params)
406
534
  params.transform_values do |value|
407
-
408
535
  if value.is_a?(Hash)
409
536
  deep_normalize_params(value)
410
537
  else
411
538
  normalize_value(value)
412
539
  end
413
-
414
540
  end
415
541
  end
416
542
 
@@ -421,17 +547,17 @@ module BharatFilterEngine
421
547
  stripped =
422
548
  value.strip
423
549
 
424
- if stripped.start_with?("[")
550
+ if stripped.start_with?('[')
425
551
  begin
426
552
  JSON.parse(stripped)
427
553
  rescue JSON::ParserError
428
554
  value
429
555
  end
430
556
 
431
- elsif value.include?(",")
557
+ elsif value.include?(',')
432
558
 
433
559
  value
434
- .split(",")
560
+ .split(',')
435
561
  .map(&:strip)
436
562
 
437
563
  else
@@ -440,4 +566,4 @@ module BharatFilterEngine
440
566
  end
441
567
  end
442
568
  end
443
- end
569
+ end
@@ -12,4 +12,4 @@ module BharatFilterEngine
12
12
 
13
13
  class InvalidAssociationError < Error
14
14
  end
15
- end
15
+ end