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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +633 -0
- data/Rakefile +6 -0
- data/lib/bharat_filter_engine/association_resolver.rb +102 -0
- data/lib/bharat_filter_engine/dynamic_filter_values.rb +39 -0
- data/lib/bharat_filter_engine/engine.rb +443 -0
- data/lib/bharat_filter_engine/errors.rb +15 -0
- data/lib/bharat_filter_engine/rule_normalizer.rb +47 -0
- data/lib/bharat_filter_engine/search_builder.rb +146 -0
- data/lib/bharat_filter_engine/version.rb +5 -0
- data/lib/bharat_filter_engine.rb +34 -0
- data/spec/association_resolver_spec.rb +160 -0
- data/spec/dynamic_filter_values_spec.rb +130 -0
- data/spec/engine_spec.rb +894 -0
- data/spec/rule_normalizer_spec.rb +52 -0
- data/spec/search_builder_spec.rb +157 -0
- data/spec/spec_helper.rb +79 -0
- metadata +107 -0
|
@@ -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,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
|