bharat_filter_engine 0.1.1

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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ class AssociationResolver
5
+ def initialize(scope)
6
+ @scope = scope
7
+ end
8
+
9
+ def resolve(path)
10
+ parts = path.to_s.split("__").map(&:to_sym)
11
+
12
+ return nil if parts.empty?
13
+
14
+ current_klass = @scope.klass
15
+ reflections = []
16
+
17
+ parts.each do |association_name|
18
+ reflection =
19
+ current_klass.reflect_on_association(
20
+ association_name
21
+ )
22
+
23
+ return nil unless reflection
24
+
25
+ reflections << reflection
26
+ current_klass = reflection.klass
27
+ end
28
+
29
+ {
30
+ associations: parts,
31
+ reflections: reflections,
32
+ klass: current_klass,
33
+ table_name: current_klass.table_name
34
+ }
35
+ end
36
+
37
+ def resolve_column(field)
38
+ field = field.to_s
39
+
40
+ # ----------------------------------------
41
+ # Direct column
42
+ # ----------------------------------------
43
+
44
+ if @scope.klass.column_names.include?(field)
45
+ return {
46
+ column: field,
47
+ associations: [],
48
+ klass: @scope.klass,
49
+ table_name: @scope.klass.table_name
50
+ }
51
+ end
52
+
53
+ # ----------------------------------------
54
+ # Nested column
55
+ #
56
+ # lead__client__name
57
+ #
58
+ # lead -> client -> name
59
+ # ----------------------------------------
60
+
61
+ parts = field.split("__")
62
+
63
+ column =
64
+ parts.pop
65
+
66
+ association_path =
67
+ parts.join("__")
68
+
69
+ result =
70
+ resolve(association_path)
71
+
72
+ return nil unless result
73
+
74
+ return nil unless
75
+ result[:klass].column_names.include?(column)
76
+
77
+ result.merge(
78
+ column: column
79
+ )
80
+ end
81
+
82
+ def join(scope, associations)
83
+ return scope if associations.blank?
84
+
85
+ scope.joins(
86
+ build_nested_join(associations)
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def build_nested_join(associations)
93
+ associations
94
+ .reverse
95
+ .reduce do |nested, association|
96
+ {
97
+ association => nested
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ class DynamicFilterValues
5
+ def initialize(scope:, model: nil, field:)
6
+ @scope = scope
7
+ @model = model || scope.klass
8
+ @field = field.to_s
9
+ end
10
+
11
+ def call
12
+ resolver = AssociationResolver.new(@scope)
13
+
14
+ result = resolver.resolve_column(@field)
15
+ return nil unless result
16
+
17
+ scoped =
18
+ resolver.join(
19
+ @scope,
20
+ result[:associations]
21
+ )
22
+
23
+ column = result[:column]
24
+ nested = result[:associations].present?
25
+
26
+ not_condition =
27
+ nested ? { result[:table_name] => { column => [nil, ""] } } : { column => [nil, ""] }
28
+
29
+ qualified_column =
30
+ nested ? Arel.sql("#{result[:table_name]}.#{column}") : column
31
+
32
+ scoped
33
+ .where.not(not_condition)
34
+ .distinct
35
+ .order(qualified_column)
36
+ .pluck(qualified_column)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ class Engine
5
+
6
+ class << self
7
+
8
+ def apply(scope:, config:, params:)
9
+ new(
10
+ scope,
11
+ config,
12
+ params
13
+ ).call
14
+ end
15
+
16
+ def filter_values(scope:, config:, params:)
17
+ field =
18
+ params[:filter_field] ||
19
+ params["filter_field"]
20
+
21
+ rule =
22
+ config[field&.to_sym]
23
+
24
+ rule =
25
+ RuleNormalizer.call(rule)
26
+
27
+ return [] unless rule&.dig(:dbcolumn)
28
+
29
+ field_name =
30
+ if rule[:association]
31
+ "#{rule[:association]}__#{rule[:dbcolumn]}"
32
+ else
33
+ rule[:dbcolumn].to_s
34
+ end
35
+
36
+ DynamicFilterValues.new(
37
+ scope: scope,
38
+ field: field_name
39
+ ).call || []
40
+ end
41
+
42
+ end
43
+
44
+ def initialize(scope, config = {}, params = {})
45
+ @scope = scope
46
+ @config = config || {}
47
+ @params = normalize_params(params)
48
+ end
49
+
50
+ def call
51
+ scope = @scope
52
+
53
+ @config.each do |key, raw_rule|
54
+
55
+ value =
56
+ @params[key.to_sym]
57
+
58
+ next if value.blank? && value != false
59
+
60
+ rule =
61
+ RuleNormalizer.call(raw_rule)
62
+
63
+ next unless rule
64
+
65
+ scope =
66
+ apply_rule(
67
+ scope,
68
+ rule,
69
+ value
70
+ )
71
+ end
72
+
73
+ scope
74
+ end
75
+
76
+ private
77
+
78
+ def apply_rule(scope, rule, value)
79
+ case rule[:type]
80
+ when :nested
81
+ apply_nested_filter(
82
+ scope,
83
+ rule,
84
+ value
85
+ )
86
+ else
87
+ apply_filter_by_type(
88
+ scope,
89
+ rule,
90
+ value
91
+ )
92
+ end
93
+ end
94
+
95
+ def apply_nested_filter(scope, rule, value)
96
+ association =
97
+ rule[:association]
98
+
99
+ resolver =
100
+ AssociationResolver.new(scope)
101
+
102
+ result =
103
+ resolver.resolve(
104
+ association
105
+ )
106
+
107
+ return scope unless result
108
+
109
+ joined_scope =
110
+ resolver.join(
111
+ scope,
112
+ result[:associations]
113
+ )
114
+
115
+ apply_filter_by_type(
116
+ joined_scope,
117
+ rule,
118
+ value,
119
+ table_name: result[:table_name]
120
+ ).distinct
121
+ end
122
+
123
+ def apply_filter_by_type(
124
+ scope,
125
+ rule,
126
+ value,
127
+ table_name: nil
128
+ )
129
+
130
+ column =
131
+ rule[:dbcolumn]
132
+
133
+ case rule[:filter_type]
134
+
135
+ when :array
136
+ apply_array_filter(
137
+ scope,
138
+ column,
139
+ value,
140
+ table_name
141
+ )
142
+
143
+ when :boolean
144
+ apply_boolean_filter(
145
+ scope,
146
+ column,
147
+ value,
148
+ table_name
149
+ )
150
+
151
+ when :integer
152
+ apply_numeric_filter(
153
+ scope,
154
+ rule,
155
+ value.to_i,
156
+ table_name: table_name
157
+ )
158
+
159
+ when :float
160
+ apply_numeric_filter(
161
+ scope,
162
+ rule,
163
+ value.to_f,
164
+ table_name: table_name
165
+ )
166
+
167
+ when :string
168
+ apply_string_filter(
169
+ scope,
170
+ column,
171
+ value,
172
+ table_name
173
+ )
174
+
175
+ when :daterange
176
+ apply_date_range_filter(
177
+ scope,
178
+ rule,
179
+ value,
180
+ table_name: table_name
181
+ )
182
+
183
+ when :search
184
+ SearchBuilder.new(
185
+ scope: scope,
186
+ allowed_columns: rule[:dbcolumns]
187
+ ).apply(value)
188
+
189
+ else
190
+ scope
191
+ end
192
+ end
193
+
194
+ def apply_array_filter(
195
+ scope,
196
+ column,
197
+ value,
198
+ table_name
199
+ )
200
+
201
+ apply_condition(
202
+ scope,
203
+ column,
204
+ Array(value),
205
+ table_name
206
+ )
207
+ end
208
+
209
+ def apply_boolean_filter(
210
+ scope,
211
+ column,
212
+ value,
213
+ table_name
214
+ )
215
+
216
+ cast_value =
217
+ ActiveModel::Type::Boolean
218
+ .new
219
+ .cast(value)
220
+
221
+ apply_condition(
222
+ scope,
223
+ column,
224
+ cast_value,
225
+ table_name
226
+ )
227
+ end
228
+
229
+ def apply_string_filter(
230
+ scope,
231
+ column,
232
+ value,
233
+ table_name
234
+ )
235
+
236
+ apply_condition(
237
+ scope,
238
+ column,
239
+ value,
240
+ table_name
241
+ )
242
+ end
243
+
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
+ )
254
+ else
255
+ scope.where(
256
+ column => value
257
+ )
258
+ end
259
+ end
260
+
261
+ def apply_numeric_filter(
262
+ scope,
263
+ rule,
264
+ value,
265
+ table_name: nil
266
+ )
267
+
268
+ column =
269
+ rule[:dbcolumn]
270
+
271
+ qualified_column =
272
+ if table_name
273
+ "#{table_name}.#{column}"
274
+ else
275
+ column.to_s
276
+ end
277
+
278
+ case rule[:range_type]
279
+
280
+ when :gte
281
+
282
+ scope.where(
283
+ "#{qualified_column} >= ?",
284
+ value
285
+ )
286
+
287
+ when :lte
288
+
289
+ scope.where(
290
+ "#{qualified_column} <= ?",
291
+ value
292
+ )
293
+
294
+ else
295
+ apply_condition(
296
+ scope,
297
+ column,
298
+ value,
299
+ table_name
300
+ )
301
+ end
302
+ end
303
+
304
+ def apply_date_range_filter(
305
+ scope,
306
+ rule,
307
+ value,
308
+ table_name: nil
309
+ )
310
+
311
+ return scope unless value.present?
312
+
313
+ column =
314
+ rule[:dbcolumn]
315
+
316
+ qualified_column =
317
+ if table_name
318
+ "#{table_name}.#{column}"
319
+ else
320
+ column.to_s
321
+ end
322
+
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
344
+
345
+ return scope unless
346
+ from.present? || to.present?
347
+
348
+ from =
349
+ normalize_date(
350
+ from,
351
+ :start
352
+ ) if from.present?
353
+
354
+ to =
355
+ normalize_date(
356
+ to,
357
+ :end
358
+ ) if to.present?
359
+
360
+ if from.present? && to.present?
361
+
362
+ scope.where(
363
+ qualified_column => from..to
364
+ )
365
+
366
+ elsif from.present?
367
+
368
+ scope.where(
369
+ "#{qualified_column} >= ?",
370
+ from
371
+ )
372
+
373
+ else
374
+
375
+ scope.where(
376
+ "#{qualified_column} <= ?",
377
+ to
378
+ )
379
+ end
380
+ end
381
+
382
+ def normalize_date(value, boundary)
383
+ return value if
384
+ value.is_a?(Time) ||
385
+ value.is_a?(ActiveSupport::TimeWithZone)
386
+
387
+ date =
388
+ Date.parse(
389
+ value.to_s
390
+ )
391
+
392
+ if boundary == :start
393
+ date.beginning_of_day
394
+ else
395
+ date.end_of_day
396
+ end
397
+ end
398
+
399
+ def normalize_params(params)
400
+ deep_normalize_params(
401
+ params.to_h.deep_symbolize_keys
402
+ )
403
+ end
404
+
405
+ def deep_normalize_params(params)
406
+ params.transform_values do |value|
407
+
408
+ if value.is_a?(Hash)
409
+ deep_normalize_params(value)
410
+ else
411
+ normalize_value(value)
412
+ end
413
+
414
+ end
415
+ end
416
+
417
+ def normalize_value(value)
418
+ return value if value.is_a?(Array)
419
+ return value unless value.is_a?(String)
420
+
421
+ stripped =
422
+ value.strip
423
+
424
+ if stripped.start_with?("[")
425
+ begin
426
+ JSON.parse(stripped)
427
+ rescue JSON::ParserError
428
+ value
429
+ end
430
+
431
+ elsif value.include?(",")
432
+
433
+ value
434
+ .split(",")
435
+ .map(&:strip)
436
+
437
+ else
438
+
439
+ value
440
+ end
441
+ end
442
+ end
443
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ class Error < StandardError
5
+ end
6
+
7
+ class InvalidConfigurationError < Error
8
+ end
9
+
10
+ class InvalidFilterError < Error
11
+ end
12
+
13
+ class InvalidAssociationError < Error
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ class RuleNormalizer
5
+ def self.call(rule)
6
+ return nil unless rule
7
+
8
+ rule = rule.transform_keys(&:to_sym)
9
+
10
+ return rule if rule[:filter_type].present?
11
+
12
+ # Legacy configuration support.
13
+ #
14
+ # Old:
15
+ # {
16
+ # type: :string,
17
+ # dbcolumn: :stage
18
+ # }
19
+ #
20
+ # New:
21
+ # {
22
+ # type: :single,
23
+ # filter_type: :string,
24
+ # dbcolumn: :stage
25
+ # }
26
+
27
+ scope_type =
28
+ if rule[:type] == :nested
29
+ :nested
30
+ else
31
+ :single
32
+ end
33
+
34
+ filter_type =
35
+ if rule[:type] == :nested
36
+ rule[:filter_type]
37
+ else
38
+ rule[:type]
39
+ end
40
+
41
+ rule.merge(
42
+ type: scope_type,
43
+ filter_type: filter_type
44
+ )
45
+ end
46
+ end
47
+ end