bharat_filter_engine 0.2.0pre → 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.
- checksums.yaml +4 -4
- data/Gemfile +2 -0
- data/lib/bharat_filter_engine/association_resolver.rb +15 -34
- data/lib/bharat_filter_engine/column_helper.rb +28 -0
- data/lib/bharat_filter_engine/dynamic_filter_values.rb +4 -21
- data/lib/bharat_filter_engine/engine.rb +51 -19
- data/lib/bharat_filter_engine/join_tracker.rb +37 -0
- data/lib/bharat_filter_engine/search_builder.rb +16 -10
- data/lib/bharat_filter_engine/version.rb +1 -1
- data/lib/bharat_filter_engine.rb +2 -0
- data/spec/dynamic_filter_values_spec.rb +40 -0
- data/spec/engine_spec.rb +72 -0
- data/spec/join_tracker_spec.rb +70 -0
- data/spec/same_table_join_spec.rb +199 -0
- data/spec/search_builder_spec.rb +45 -0
- data/spec/spec_helper.rb +4 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45380eb2ba5afe790b2cbc8f0abe65cbab402d2489fb025bd2d2407c5700a57f
|
|
4
|
+
data.tar.gz: 8529dbb3f163c708c819ab166e1d946d23900ef685b9bb44b758f734c44c913b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ae9d8027f95b37c0f7f43235a56cc0b234ab444aa0079bfa1425b44ca408677def18f0f11216dced37321b68765a7e64fcd2217add708dc097a8da6e44c536e
|
|
7
|
+
data.tar.gz: a2ea4acb0d03be39e10d59b0c01b7da206489e95fbad43a7d60c1f04e83c9de7031de5d5779599b6c22348e684c17b326b2d9f3fa3b39a022e69b3ab429cd1be
|
data/Gemfile
CHANGED
|
@@ -8,18 +8,13 @@ module BharatFilterEngine
|
|
|
8
8
|
|
|
9
9
|
def resolve(path)
|
|
10
10
|
parts = path.to_s.split('__').map(&:to_sym)
|
|
11
|
-
|
|
12
11
|
return nil if parts.empty?
|
|
13
12
|
|
|
14
13
|
current_klass = @scope.klass
|
|
15
14
|
reflections = []
|
|
16
15
|
|
|
17
16
|
parts.each do |association_name|
|
|
18
|
-
reflection =
|
|
19
|
-
current_klass.reflect_on_association(
|
|
20
|
-
association_name
|
|
21
|
-
)
|
|
22
|
-
|
|
17
|
+
reflection = current_klass.reflect_on_association(association_name)
|
|
23
18
|
return nil unless reflection
|
|
24
19
|
|
|
25
20
|
reflections << reflection
|
|
@@ -36,11 +31,6 @@ module BharatFilterEngine
|
|
|
36
31
|
|
|
37
32
|
def resolve_column(field)
|
|
38
33
|
field = field.to_s
|
|
39
|
-
|
|
40
|
-
# ----------------------------------------
|
|
41
|
-
# Direct column
|
|
42
|
-
# ----------------------------------------
|
|
43
|
-
|
|
44
34
|
if @scope.klass.column_names.include?(field)
|
|
45
35
|
return {
|
|
46
36
|
column: field,
|
|
@@ -50,41 +40,32 @@ module BharatFilterEngine
|
|
|
50
40
|
}
|
|
51
41
|
end
|
|
52
42
|
|
|
53
|
-
# ----------------------------------------
|
|
54
43
|
# Nested column
|
|
55
|
-
#
|
|
56
44
|
# lead__client__name
|
|
57
|
-
#
|
|
58
45
|
# lead -> client -> name
|
|
59
|
-
# ----------------------------------------
|
|
60
|
-
|
|
61
46
|
parts = field.split('__')
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
association_path =
|
|
67
|
-
parts.join('__')
|
|
68
|
-
|
|
69
|
-
result =
|
|
70
|
-
resolve(association_path)
|
|
47
|
+
column = parts.pop
|
|
48
|
+
association_path = parts.join('__')
|
|
49
|
+
result = resolve(association_path)
|
|
71
50
|
|
|
72
51
|
return nil unless result
|
|
52
|
+
return nil unless result[:klass].column_names.include?(column)
|
|
73
53
|
|
|
74
|
-
|
|
75
|
-
result[:klass].column_names.include?(column)
|
|
76
|
-
|
|
77
|
-
result.merge(
|
|
78
|
-
column: column
|
|
79
|
-
)
|
|
54
|
+
result.merge(column: column)
|
|
80
55
|
end
|
|
81
56
|
|
|
82
57
|
def join(scope, associations)
|
|
83
58
|
return scope if associations.blank?
|
|
84
59
|
|
|
85
|
-
scope.joins(
|
|
86
|
-
|
|
87
|
-
|
|
60
|
+
scope.joins(build_nested_join(associations))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def subquery(associations)
|
|
64
|
+
klass = @scope.klass
|
|
65
|
+
relation = klass.unscoped.joins(build_nested_join(associations))
|
|
66
|
+
relation = yield(relation) if block_given?
|
|
67
|
+
|
|
68
|
+
relation.select(klass.arel_table[klass.primary_key])
|
|
88
69
|
end
|
|
89
70
|
|
|
90
71
|
private
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BharatFilterEngine
|
|
4
|
+
module ColumnHelper
|
|
5
|
+
STRING_COLUMN_TYPES = %i[string text].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def string_like?(klass, column)
|
|
10
|
+
db_column = klass&.columns_hash&.[](column.to_s)
|
|
11
|
+
db_column.nil? || STRING_COLUMN_TYPES.include?(db_column.type)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def present_predicate(arel_column, klass, column)
|
|
15
|
+
predicate = arel_column.not_eq(nil)
|
|
16
|
+
return predicate unless string_like?(klass, column)
|
|
17
|
+
|
|
18
|
+
predicate.and(arel_column.not_eq(''))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def blank_predicate(arel_column, klass, column)
|
|
22
|
+
predicate = arel_column.eq(nil)
|
|
23
|
+
return predicate unless string_like?(klass, column)
|
|
24
|
+
|
|
25
|
+
predicate.or(arel_column.eq(''))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -18,23 +18,10 @@ module BharatFilterEngine
|
|
|
18
18
|
result = resolver.resolve_column(@field)
|
|
19
19
|
return nil unless result
|
|
20
20
|
|
|
21
|
-
scoped =
|
|
22
|
-
resolver.join(
|
|
23
|
-
@scope,
|
|
24
|
-
result[:associations]
|
|
25
|
-
)
|
|
26
|
-
|
|
21
|
+
scoped = resolver.join(@scope, result[:associations])
|
|
27
22
|
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
|
-
|
|
23
|
+
scoped = scoped.where(ColumnHelper.present_predicate(arel_column, result[:klass], result[:column]))
|
|
36
24
|
scoped = apply_term(scoped, arel_column) if @term
|
|
37
|
-
|
|
38
25
|
scoped = scoped.distinct.order(arel_column)
|
|
39
26
|
scoped = scoped.limit(@limit) if @limit&.positive?
|
|
40
27
|
|
|
@@ -44,12 +31,8 @@ module BharatFilterEngine
|
|
|
44
31
|
private
|
|
45
32
|
|
|
46
33
|
def apply_term(scoped, arel_column)
|
|
47
|
-
escaped =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
scoped.where(
|
|
51
|
-
arel_column.matches("%#{escaped}%", LIKE_ESCAPE_CHAR)
|
|
52
|
-
)
|
|
34
|
+
escaped = @term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
|
|
35
|
+
scoped.where(arel_column.matches("%#{escaped}%", LIKE_ESCAPE_CHAR))
|
|
53
36
|
end
|
|
54
37
|
end
|
|
55
38
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module BharatFilterEngine
|
|
4
4
|
class Engine
|
|
5
|
+
NUMERIC_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
|
|
5
6
|
class << self
|
|
6
7
|
def apply(scope:, config:, params:)
|
|
7
8
|
new(
|
|
@@ -56,6 +57,8 @@ module BharatFilterEngine
|
|
|
56
57
|
|
|
57
58
|
def call
|
|
58
59
|
scope = @scope
|
|
60
|
+
@tracker = JoinTracker.new(scope.klass)
|
|
61
|
+
@subquery_groups = {}
|
|
59
62
|
|
|
60
63
|
@config.each do |key, raw_rule|
|
|
61
64
|
value =
|
|
@@ -76,6 +79,10 @@ module BharatFilterEngine
|
|
|
76
79
|
)
|
|
77
80
|
end
|
|
78
81
|
|
|
82
|
+
@subquery_groups.each_value do |group|
|
|
83
|
+
scope = apply_subquery_group(scope, group)
|
|
84
|
+
end
|
|
85
|
+
|
|
79
86
|
scope
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -118,6 +125,18 @@ module BharatFilterEngine
|
|
|
118
125
|
'in your filter config.'
|
|
119
126
|
end
|
|
120
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
|
|
139
|
+
|
|
121
140
|
joined_scope =
|
|
122
141
|
resolver.join(
|
|
123
142
|
scope,
|
|
@@ -128,15 +147,36 @@ module BharatFilterEngine
|
|
|
128
147
|
joined_scope,
|
|
129
148
|
rule,
|
|
130
149
|
value,
|
|
131
|
-
table_name: result[:table_name]
|
|
150
|
+
table_name: result[:table_name],
|
|
151
|
+
klass: result[:klass]
|
|
132
152
|
).distinct
|
|
133
153
|
end
|
|
134
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
|
+
|
|
135
174
|
def apply_filter_by_type(
|
|
136
175
|
scope,
|
|
137
176
|
rule,
|
|
138
177
|
value,
|
|
139
|
-
table_name: nil
|
|
178
|
+
table_name: nil,
|
|
179
|
+
klass: nil
|
|
140
180
|
)
|
|
141
181
|
column =
|
|
142
182
|
rule[:dbcolumn]
|
|
@@ -201,13 +241,15 @@ module BharatFilterEngine
|
|
|
201
241
|
scope,
|
|
202
242
|
column,
|
|
203
243
|
value,
|
|
204
|
-
table_name
|
|
244
|
+
table_name,
|
|
245
|
+
klass: klass
|
|
205
246
|
)
|
|
206
247
|
|
|
207
248
|
when :search
|
|
208
249
|
SearchBuilder.new(
|
|
209
250
|
scope: scope,
|
|
210
|
-
allowed_columns: rule[:dbcolumns]
|
|
251
|
+
allowed_columns: rule[:dbcolumns],
|
|
252
|
+
tracker: @tracker
|
|
211
253
|
).apply(value)
|
|
212
254
|
|
|
213
255
|
else
|
|
@@ -268,28 +310,20 @@ module BharatFilterEngine
|
|
|
268
310
|
)
|
|
269
311
|
end
|
|
270
312
|
|
|
271
|
-
def apply_presence_filter(scope, column, value, table_name)
|
|
313
|
+
def apply_presence_filter(scope, column, value, table_name, klass: nil)
|
|
272
314
|
present = ActiveModel::Type::Boolean.new.cast(value)
|
|
273
|
-
|
|
315
|
+
klass ||= scope.klass
|
|
274
316
|
arel_column =
|
|
275
317
|
if table_name
|
|
276
318
|
Arel::Table.new(table_name)[column]
|
|
277
319
|
else
|
|
278
|
-
|
|
320
|
+
klass.arel_table[column]
|
|
279
321
|
end
|
|
280
322
|
|
|
281
323
|
if present
|
|
282
|
-
scope.where(
|
|
283
|
-
arel_column.not_eq(nil).and(
|
|
284
|
-
arel_column.not_eq('')
|
|
285
|
-
)
|
|
286
|
-
)
|
|
324
|
+
scope.where(ColumnHelper.present_predicate(arel_column, klass, column))
|
|
287
325
|
else
|
|
288
|
-
scope.where(
|
|
289
|
-
arel_column.eq(nil).or(
|
|
290
|
-
arel_column.eq('')
|
|
291
|
-
)
|
|
292
|
-
)
|
|
326
|
+
scope.where(ColumnHelper.blank_predicate(arel_column, klass, column))
|
|
293
327
|
end
|
|
294
328
|
end
|
|
295
329
|
|
|
@@ -333,8 +367,6 @@ module BharatFilterEngine
|
|
|
333
367
|
scope.where(Arel::Nodes::Not.new(predicate))
|
|
334
368
|
end
|
|
335
369
|
|
|
336
|
-
NUMERIC_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
|
|
337
|
-
|
|
338
370
|
def apply_numeric_filter(
|
|
339
371
|
scope,
|
|
340
372
|
rule,
|
|
@@ -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
|
|
@@ -4,8 +4,9 @@ module BharatFilterEngine
|
|
|
4
4
|
class SearchBuilder
|
|
5
5
|
LIKE_ESCAPE_CHAR = '\\'
|
|
6
6
|
|
|
7
|
-
def initialize(scope:, allowed_columns:)
|
|
7
|
+
def initialize(scope:, allowed_columns:, tracker: nil)
|
|
8
8
|
@scope = scope
|
|
9
|
+
@tracker = tracker || JoinTracker.new(scope.klass)
|
|
9
10
|
@allowed_columns =
|
|
10
11
|
Array(allowed_columns).map(&:to_s)
|
|
11
12
|
|
|
@@ -79,6 +80,7 @@ module BharatFilterEngine
|
|
|
79
80
|
|
|
80
81
|
next if field.blank?
|
|
81
82
|
next if search_value.blank?
|
|
83
|
+
next unless @allowed_columns.include?(field)
|
|
82
84
|
next unless valid_field?(field)
|
|
83
85
|
|
|
84
86
|
query = like_query(search_value.strip)
|
|
@@ -132,21 +134,25 @@ module BharatFilterEngine
|
|
|
132
134
|
result = resolver.resolve_column(field)
|
|
133
135
|
|
|
134
136
|
return [scope, nil] unless result
|
|
137
|
+
return [scope, subquery_condition(result, query)] if @tracker.claim(result) == :subquery
|
|
135
138
|
|
|
136
|
-
joined_scope =
|
|
137
|
-
resolver.join(
|
|
138
|
-
scope,
|
|
139
|
-
result[:associations]
|
|
140
|
-
)
|
|
139
|
+
joined_scope = resolver.join(scope, result[:associations]).distinct
|
|
141
140
|
|
|
142
|
-
condition =
|
|
143
|
-
result[:klass]
|
|
144
|
-
.arel_table[result[:column]]
|
|
145
|
-
.matches(query, LIKE_ESCAPE_CHAR)
|
|
141
|
+
condition = result[:klass].arel_table[result[:column]].matches(query, LIKE_ESCAPE_CHAR)
|
|
146
142
|
|
|
147
143
|
[joined_scope, condition]
|
|
148
144
|
end
|
|
149
145
|
|
|
146
|
+
# `primary_key IN (SELECT ... JOIN ... WHERE column LIKE ...)` for a nested column whose
|
|
147
|
+
# table is already joined through another path (e.g. client + referrer -> clients).
|
|
148
|
+
def subquery_condition(result, query)
|
|
149
|
+
klass = @scope.klass
|
|
150
|
+
matcher = result[:klass].arel_table[result[:column]].matches(query, LIKE_ESCAPE_CHAR)
|
|
151
|
+
subquery = resolver.subquery(result[:associations]) { |relation| relation.where(matcher) }
|
|
152
|
+
|
|
153
|
+
klass.arel_table[klass.primary_key].in(subquery.arel)
|
|
154
|
+
end
|
|
155
|
+
|
|
150
156
|
def like_query(term)
|
|
151
157
|
escaped =
|
|
152
158
|
term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
|
data/lib/bharat_filter_engine.rb
CHANGED
|
@@ -8,9 +8,11 @@ require 'json'
|
|
|
8
8
|
require_relative 'bharat_filter_engine/version'
|
|
9
9
|
require_relative 'bharat_filter_engine/errors'
|
|
10
10
|
require_relative 'bharat_filter_engine/association_resolver'
|
|
11
|
+
require_relative 'bharat_filter_engine/column_helper'
|
|
11
12
|
require_relative 'bharat_filter_engine/rule_normalizer'
|
|
12
13
|
require_relative 'bharat_filter_engine/search_builder'
|
|
13
14
|
require_relative 'bharat_filter_engine/dynamic_filter_values'
|
|
15
|
+
require_relative 'bharat_filter_engine/join_tracker'
|
|
14
16
|
require_relative 'bharat_filter_engine/engine'
|
|
15
17
|
|
|
16
18
|
module BharatFilterEngine
|
|
@@ -132,6 +132,46 @@ RSpec.describe BharatFilterEngine::DynamicFilterValues do
|
|
|
132
132
|
)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
it 'returns distinct values for an integer column' do
|
|
136
|
+
Sale.create!(project_id: 3)
|
|
137
|
+
Sale.create!(project_id: 1)
|
|
138
|
+
Sale.create!(project_id: 3)
|
|
139
|
+
Sale.create!(project_id: nil)
|
|
140
|
+
|
|
141
|
+
result = described_class.new(scope: Sale.all, field: 'project_id').call
|
|
142
|
+
expect(result).to eq([1, 3])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'keeps a zero value for a numeric column' do
|
|
146
|
+
Sale.create!(amount: 0)
|
|
147
|
+
Sale.create!(amount: 5)
|
|
148
|
+
|
|
149
|
+
result = described_class.new(scope: Sale.all, field: 'amount').call
|
|
150
|
+
expect(result).to eq([0, 5])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'returns distinct values for a datetime column' do
|
|
154
|
+
Sale.create!(actual_sale_date: Time.utc(2013, 1, 1))
|
|
155
|
+
Sale.create!(actual_sale_date: nil)
|
|
156
|
+
|
|
157
|
+
result = described_class.new(scope: Sale.all, field: 'actual_sale_date').call
|
|
158
|
+
expect(result.size).to eq(1)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'returns distinct values for a nested integer column' do
|
|
162
|
+
client = Client.create!(name: 'Nested Co')
|
|
163
|
+
lead = Lead.create!(source: 'web', client: client)
|
|
164
|
+
Sale.create!(lead: lead)
|
|
165
|
+
Sale.create!(lead: lead)
|
|
166
|
+
Sale.create!(lead: nil)
|
|
167
|
+
|
|
168
|
+
result = described_class.new(scope: Sale.all, field: 'lead__client_id').call
|
|
169
|
+
expected = Sale.joins(:lead).distinct.pluck('leads.client_id').compact.sort
|
|
170
|
+
|
|
171
|
+
expect(result).to include(client.id)
|
|
172
|
+
expect(result).to eq(expected)
|
|
173
|
+
end
|
|
174
|
+
|
|
135
175
|
it 'filters values by a partial term' do
|
|
136
176
|
Sale.create!(stage: 'closed')
|
|
137
177
|
|
data/spec/engine_spec.rb
CHANGED
|
@@ -1356,6 +1356,78 @@ RSpec.describe BharatFilterEngine::Engine do
|
|
|
1356
1356
|
expect(result).to contain_exactly(sale_with_org)
|
|
1357
1357
|
end
|
|
1358
1358
|
end
|
|
1359
|
+
|
|
1360
|
+
describe 'presence filter on non-string columns' do
|
|
1361
|
+
def presence_config(column, **extra)
|
|
1362
|
+
{ has_value: { type: :single, filter_type: :presence, dbcolumn: column }.merge(extra) }
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
{
|
|
1366
|
+
project_id: [1, 7],
|
|
1367
|
+
amount: [0, 500],
|
|
1368
|
+
conversion_rate: [0.0, 2.5],
|
|
1369
|
+
actual_sale_date: [Time.utc(2013, 1, 1), Time.utc(2013, 6, 1)],
|
|
1370
|
+
active: [false, true]
|
|
1371
|
+
}.each do |column, (value_one, value_two)|
|
|
1372
|
+
context "on the #{column} column" do
|
|
1373
|
+
let!(:with_one) { Sale.create!(column => value_one) }
|
|
1374
|
+
let!(:with_two) { Sale.create!(column => value_two) }
|
|
1375
|
+
let!(:without) { Sale.create!(column => nil) }
|
|
1376
|
+
|
|
1377
|
+
it 'returns only non-NULL rows when value is true' do
|
|
1378
|
+
result = described_class.apply(
|
|
1379
|
+
scope: Sale.all, config: presence_config(column), params: { has_value: 'true' }
|
|
1380
|
+
)
|
|
1381
|
+
expect(result).to contain_exactly(with_one, with_two)
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
it 'returns only NULL rows when value is false' do
|
|
1385
|
+
result = described_class.apply(
|
|
1386
|
+
scope: Sale.all, config: presence_config(column), params: { has_value: 'false' }
|
|
1387
|
+
)
|
|
1388
|
+
expect(result).to contain_exactly(without)
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
it 'treats a zero integer as present, not blank' do
|
|
1394
|
+
zero = Sale.create!(amount: 0)
|
|
1395
|
+
Sale.create!(amount: nil)
|
|
1396
|
+
|
|
1397
|
+
result = described_class.apply(
|
|
1398
|
+
scope: Sale.all, config: presence_config(:amount), params: { has_value: 'true' }
|
|
1399
|
+
)
|
|
1400
|
+
expect(result).to contain_exactly(zero)
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
it 'works on a nested non-string (integer foreign key) column' do
|
|
1404
|
+
client = Client.create!(name: 'John')
|
|
1405
|
+
lead_with_client = Lead.create!(source: 'google', client: client)
|
|
1406
|
+
lead_without_client = Lead.create!(source: 'referral', client: nil)
|
|
1407
|
+
sale_with = Sale.create!(lead: lead_with_client)
|
|
1408
|
+
sale_without = Sale.create!(lead: lead_without_client)
|
|
1409
|
+
|
|
1410
|
+
config = {
|
|
1411
|
+
has_client: { type: :nested, filter_type: :presence, association: :lead, dbcolumn: :client_id }
|
|
1412
|
+
}
|
|
1413
|
+
present = described_class.apply(scope: Sale.all, config: config, params: { has_client: 'true' })
|
|
1414
|
+
blank = described_class.apply(scope: Sale.all, config: config, params: { has_client: 'false' })
|
|
1415
|
+
|
|
1416
|
+
expect(present).to contain_exactly(sale_with)
|
|
1417
|
+
expect(blank).to contain_exactly(sale_without)
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
it 'still treats an empty string as blank on a string column' do
|
|
1421
|
+
sale = Sale.create!(stage: 'qualified')
|
|
1422
|
+
Sale.create!(stage: '')
|
|
1423
|
+
|
|
1424
|
+
result = described_class.apply(
|
|
1425
|
+
scope: Sale.all, config: presence_config(:stage), params: { has_value: true }
|
|
1426
|
+
)
|
|
1427
|
+
expect(result).to contain_exactly(sale)
|
|
1428
|
+
end
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1359
1431
|
describe 'invalid numeric input' do
|
|
1360
1432
|
it 'ignores a non-numeric exact integer filter value' do
|
|
1361
1433
|
sale = Sale.create!(amount: 500)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe BharatFilterEngine::JoinTracker do
|
|
6
|
+
subject(:tracker) { described_class.new(Lead) }
|
|
7
|
+
|
|
8
|
+
let(:resolver) { BharatFilterEngine::AssociationResolver.new(Lead.all) }
|
|
9
|
+
|
|
10
|
+
def resolve(path)
|
|
11
|
+
resolver.resolve(path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'lets the first path to a table use a plain join' do
|
|
15
|
+
expect(tracker.claim(resolve(:client))).to eq(:join)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'reuses the join when exactly the same path is claimed again' do
|
|
19
|
+
tracker.claim(resolve(:client))
|
|
20
|
+
|
|
21
|
+
expect(tracker.claim(resolve(:client))).to eq(:join)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'moves a different path to an already-used table into a subquery' do
|
|
25
|
+
tracker.claim(resolve(:client))
|
|
26
|
+
|
|
27
|
+
expect(tracker.claim(resolve(:referrer))).to eq(:subquery)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'keeps answering :subquery for that second path' do
|
|
31
|
+
tracker.claim(resolve(:client))
|
|
32
|
+
tracker.claim(resolve(:referrer))
|
|
33
|
+
|
|
34
|
+
expect(tracker.claim(resolve(:referrer))).to eq(:subquery)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'joins paths that end in different tables' do
|
|
38
|
+
expect(tracker.claim(resolve(:client))).to eq(:join)
|
|
39
|
+
expect(tracker.claim(resolve(:client__organization))).to eq(:join)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'shares a prefix that an earlier, longer path already joined' do
|
|
43
|
+
tracker.claim(resolve(:client__organization))
|
|
44
|
+
|
|
45
|
+
expect(tracker.claim(resolve(:client))).to eq(:join)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'detects collisions at the end of multi-level paths' do
|
|
49
|
+
tracker.claim(resolve(:client__organization))
|
|
50
|
+
|
|
51
|
+
expect(tracker.claim(resolve(:referrer__organization))).to eq(:subquery)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'sends a path that returns to the base table to a subquery' do
|
|
55
|
+
expect(tracker.claim(resolve(:client__leads))).to eq(:subquery)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'always joins when there is no association path' do
|
|
59
|
+
expect(tracker.claim(associations: [], table_name: 'leads')).to eq(:join)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'falls back to joins for composite primary keys' do
|
|
63
|
+
klass = double('composite_pk_model', table_name: 'leads', primary_key: %w[a b])
|
|
64
|
+
composite = described_class.new(klass)
|
|
65
|
+
|
|
66
|
+
composite.claim(resolve(:client))
|
|
67
|
+
|
|
68
|
+
expect(composite.claim(resolve(:referrer))).to eq(:join)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Two different associations that point at the SAME table (Lead#client and
|
|
6
|
+
# Lead#referrer are both `clients`). ActiveRecord aliases the second join, so
|
|
7
|
+
# conditions written against the plain table name used to hit the wrong join.
|
|
8
|
+
RSpec.describe 'filters through two associations to the same table' do
|
|
9
|
+
def nested(association, filter_type, column, **extra)
|
|
10
|
+
{ type: :nested, association: association, filter_type: filter_type, dbcolumn: column }.merge(extra)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def search(columns)
|
|
14
|
+
{ type: :single, filter_type: :search, dbcolumns: columns }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
let!(:acme) { Organization.create!(name: 'Acme') }
|
|
18
|
+
let!(:globex) { Organization.create!(name: 'Globex') }
|
|
19
|
+
|
|
20
|
+
let!(:alice) { Client.create!(name: 'Alice', email: 'alice@x.com', organization: acme) }
|
|
21
|
+
let!(:bob) { Client.create!(name: 'Bob', email: 'bob@x.com', organization: globex) }
|
|
22
|
+
|
|
23
|
+
let!(:alice_bob) { Lead.create!(source: 'google', client: alice, referrer: bob) }
|
|
24
|
+
let!(:bob_alice) { Lead.create!(source: 'google', client: bob, referrer: alice) }
|
|
25
|
+
let!(:alice_alice) { Lead.create!(source: 'google', client: alice, referrer: alice) }
|
|
26
|
+
let!(:alice_none) { Lead.create!(source: 'google', client: alice, referrer: nil) }
|
|
27
|
+
|
|
28
|
+
let(:both_names) do
|
|
29
|
+
{
|
|
30
|
+
client_name: nested(:client, :string, :name),
|
|
31
|
+
referrer_name: nested(:referrer, :string, :name)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def apply(config, scope: Lead.all, **params)
|
|
36
|
+
BharatFilterEngine::Engine.apply(scope: scope, config: config, params: params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe 'nested filters' do
|
|
40
|
+
it 'applies each condition to its own association' do
|
|
41
|
+
expect(apply(both_names, client_name: 'Alice', referrer_name: 'Bob')).to contain_exactly(alice_bob)
|
|
42
|
+
expect(apply(both_names, client_name: 'Bob', referrer_name: 'Alice')).to contain_exactly(bob_alice)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'allows both associations to match the same record' do
|
|
46
|
+
expect(apply(both_names, client_name: 'Alice', referrer_name: 'Alice')).to contain_exactly(alice_alice)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'does not depend on the order of the config' do
|
|
50
|
+
reversed = both_names.to_a.reverse.to_h
|
|
51
|
+
|
|
52
|
+
expect(apply(reversed, client_name: 'Alice', referrer_name: 'Bob')).to contain_exactly(alice_bob)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'returns nothing when no record matches both conditions' do
|
|
56
|
+
expect(apply(both_names, client_name: 'Bob', referrer_name: 'Bob')).to be_empty
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'supports a negated filter on the second association' do
|
|
60
|
+
config = {
|
|
61
|
+
client_name: nested(:client, :string, :name),
|
|
62
|
+
referrer_name: nested(:referrer, :string, :name, negate: true)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(apply(config, client_name: 'Alice', referrer_name: 'Bob')).to contain_exactly(alice_alice)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'works across multi-level paths that end in the same table' do
|
|
69
|
+
config = {
|
|
70
|
+
client_org: nested(:client__organization, :string, :name),
|
|
71
|
+
referrer_org: nested(:referrer__organization, :string, :name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(apply(config, client_org: 'Acme', referrer_org: 'Globex')).to contain_exactly(alice_bob)
|
|
75
|
+
expect(apply(config, client_org: 'Globex', referrer_org: 'Acme')).to contain_exactly(bob_alice)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'works through a has_many-free parent (Sale -> lead -> client/referrer)' do
|
|
79
|
+
Sale.create!(lead: alice_bob, stage: 'won')
|
|
80
|
+
Sale.create!(lead: bob_alice, stage: 'won')
|
|
81
|
+
|
|
82
|
+
sale_config = {
|
|
83
|
+
client_name: nested(:lead__client, :string, :name),
|
|
84
|
+
referrer_name: nested(:lead__referrer, :string, :name)
|
|
85
|
+
}
|
|
86
|
+
sales = apply(sale_config, scope: Sale.all, client_name: 'Alice', referrer_name: 'Bob')
|
|
87
|
+
expect(sales.map(&:lead)).to contain_exactly(alice_bob)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'returns a normal relation that can be counted, ordered and limited' do
|
|
91
|
+
relation = apply(both_names, client_name: 'Alice', referrer_name: 'Bob')
|
|
92
|
+
|
|
93
|
+
expect(relation).to be_a(ActiveRecord::Relation)
|
|
94
|
+
expect(relation.count).to eq(1)
|
|
95
|
+
expect(relation.exists?).to be(true)
|
|
96
|
+
expect(relation.order(:id).limit(1).pluck(:id)).to eq([alice_bob.id])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe 'search' do
|
|
101
|
+
it 'searches both associations with OR semantics' do
|
|
102
|
+
result = apply({ q: search(%i[client__name referrer__name]) }, q: 'Bob')
|
|
103
|
+
|
|
104
|
+
expect(result).to contain_exactly(alice_bob, bob_alice)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'supports field=value search on both associations (AND)' do
|
|
108
|
+
result = apply({ q: search(%i[client__name referrer__name]) }, q: 'client__name=Alice&referrer__name=Bob')
|
|
109
|
+
|
|
110
|
+
expect(result).to contain_exactly(alice_bob)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'works when SearchBuilder is used on its own (no engine)' do
|
|
114
|
+
builder = BharatFilterEngine::SearchBuilder.new(scope: Lead.all, allowed_columns: %w[client__name referrer__name])
|
|
115
|
+
|
|
116
|
+
expect(builder.apply('Bob')).to contain_exactly(alice_bob, bob_alice)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe 'nested filter combined with search' do
|
|
121
|
+
let(:nested_then_search) do
|
|
122
|
+
{ referrer_name: nested(:referrer, :string, :name), q: search(%i[client__name]) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'is correct when the nested filter comes first' do
|
|
126
|
+
expect(apply(nested_then_search, referrer_name: 'Bob', q: 'alice')).to contain_exactly(alice_bob)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'is correct when the search comes first' do
|
|
130
|
+
search_then_nested = nested_then_search.to_a.reverse.to_h
|
|
131
|
+
|
|
132
|
+
expect(apply(search_then_nested, referrer_name: 'Bob', q: 'alice')).to contain_exactly(alice_bob)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe 'has_many paths to the same table' do
|
|
137
|
+
let!(:dave) { Client.create!(name: 'Dave') }
|
|
138
|
+
let!(:carol) { Client.create!(name: 'Carol') }
|
|
139
|
+
|
|
140
|
+
before do
|
|
141
|
+
# alice: a google lead; referred leads won/referral and lost/web (DIFFERENT rows)
|
|
142
|
+
# bob: a google lead; referred lead won/web (ONE row satisfies both)
|
|
143
|
+
# carol: no google lead; referred lead won/web
|
|
144
|
+
Lead.where(client: [alice, bob, carol]).delete_all
|
|
145
|
+
Lead.create!(source: 'google', client: alice)
|
|
146
|
+
Lead.create!(source: 'google', client: bob)
|
|
147
|
+
Lead.create!(source: 'referral', client: carol)
|
|
148
|
+
Lead.create!(source: 'referral', status: 'won', client: dave, referrer: alice)
|
|
149
|
+
Lead.create!(source: 'web', status: 'lost', client: dave, referrer: alice)
|
|
150
|
+
Lead.create!(source: 'web', status: 'won', client: dave, referrer: bob)
|
|
151
|
+
Lead.create!(source: 'web', status: 'won', client: dave, referrer: carol)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
let(:config) do
|
|
155
|
+
{
|
|
156
|
+
own_source: nested(:leads, :string, :source),
|
|
157
|
+
ref_status: nested(:referred_leads, :string, :status),
|
|
158
|
+
ref_source: nested(:referred_leads, :string, :source)
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'works when two has_many associations point at the same table' do
|
|
163
|
+
result = apply(config.slice(:own_source, :ref_status), scope: Client.all, own_source: 'google', ref_status: 'won')
|
|
164
|
+
|
|
165
|
+
expect(result).to contain_exactly(alice, bob)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'keeps conditions on one path matching the SAME joined row' do
|
|
169
|
+
result = apply(config, scope: Client.all, own_source: 'google', ref_status: 'won', ref_source: 'web')
|
|
170
|
+
|
|
171
|
+
expect(result).to contain_exactly(bob)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe 'SQL for cases that never collided' do
|
|
176
|
+
it 'keeps a single nested filter as a plain DISTINCT join' do
|
|
177
|
+
sql = apply({ c: nested(:client, :string, :name) }, c: 'Alice').to_sql
|
|
178
|
+
|
|
179
|
+
expect(sql.scan('INNER JOIN').size).to eq(1)
|
|
180
|
+
expect(sql).to include('DISTINCT')
|
|
181
|
+
expect(sql).not_to include('IN (SELECT')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'shares one join when two filters use the same path' do
|
|
185
|
+
config = { n: nested(:client, :string, :name), e: nested(:client, :string, :email) }
|
|
186
|
+
sql = apply(config, n: 'Alice', e: 'alice@x.com').to_sql
|
|
187
|
+
|
|
188
|
+
expect(sql.scan('INNER JOIN').size).to eq(1)
|
|
189
|
+
expect(sql).not_to include('IN (SELECT')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'does not use a subquery for joins to different tables' do
|
|
193
|
+
config = { c: nested(:client, :string, :name), o: nested(:client__organization, :string, :name) }
|
|
194
|
+
sql = apply(config, c: 'Alice', o: 'Acme').to_sql
|
|
195
|
+
|
|
196
|
+
expect(sql).not_to include('IN (SELECT')
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/spec/search_builder_spec.rb
CHANGED
|
@@ -93,6 +93,27 @@ RSpec.describe BharatFilterEngine::SearchBuilder do
|
|
|
93
93
|
expect(result).to contain_exactly(sale_two)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
it 'returns each record once when searching through a has_many association' do
|
|
97
|
+
matching_client = Client.create!(name: 'John1')
|
|
98
|
+
Lead.create!(source: 'google', client: matching_client)
|
|
99
|
+
Lead.create!(source: 'google', client: matching_client)
|
|
100
|
+
Client.create!(name: 'John2').tap do |other_client|
|
|
101
|
+
Lead.create!(source: 'meta', client: other_client)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
result =
|
|
105
|
+
described_class.new(
|
|
106
|
+
scope: Client.all,
|
|
107
|
+
allowed_columns: [:leads__source]
|
|
108
|
+
).apply(
|
|
109
|
+
'leads__source=google'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(result).to contain_exactly(client, matching_client)
|
|
113
|
+
expect(result.count).to eq(2)
|
|
114
|
+
expect(result.order(:id).limit(1).pluck(:id)).to eq([client.id])
|
|
115
|
+
end
|
|
116
|
+
|
|
96
117
|
it 'supports OR search' do
|
|
97
118
|
result =
|
|
98
119
|
described_class.new(
|
|
@@ -153,6 +174,30 @@ RSpec.describe BharatFilterEngine::SearchBuilder do
|
|
|
153
174
|
expect(result).to be_empty
|
|
154
175
|
end
|
|
155
176
|
|
|
177
|
+
it 'returns no records when the searched model column is not allowed' do
|
|
178
|
+
result =
|
|
179
|
+
described_class.new(
|
|
180
|
+
scope: Sale.all,
|
|
181
|
+
allowed_columns: [:stage]
|
|
182
|
+
).apply(
|
|
183
|
+
'approval_status=approved'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(result).to be_empty
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'returns no records when the searched association column is not allowed' do
|
|
190
|
+
result =
|
|
191
|
+
described_class.new(
|
|
192
|
+
scope: Sale.all,
|
|
193
|
+
allowed_columns: [:stage]
|
|
194
|
+
).apply(
|
|
195
|
+
'lead__client__email=john@example.com'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(result).to be_empty
|
|
199
|
+
end
|
|
200
|
+
|
|
156
201
|
it 'supports searching the id column as text' do
|
|
157
202
|
result =
|
|
158
203
|
described_class.new(
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
3
4
|
require 'bundler/setup'
|
|
4
5
|
require 'active_record'
|
|
5
6
|
require 'database_cleaner/active_record'
|
|
@@ -26,6 +27,7 @@ ActiveRecord::Schema.define do
|
|
|
26
27
|
t.string :source
|
|
27
28
|
t.string :status
|
|
28
29
|
t.references :client
|
|
30
|
+
t.integer :referrer_id
|
|
29
31
|
t.datetime :created_at
|
|
30
32
|
end
|
|
31
33
|
|
|
@@ -48,10 +50,12 @@ end
|
|
|
48
50
|
class Client < ActiveRecord::Base
|
|
49
51
|
belongs_to :organization
|
|
50
52
|
has_many :leads
|
|
53
|
+
has_many :referred_leads, class_name: 'Lead', foreign_key: :referrer_id
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
class Lead < ActiveRecord::Base
|
|
54
57
|
belongs_to :client
|
|
58
|
+
belongs_to :referrer, class_name: 'Client', optional: true
|
|
55
59
|
has_many :sales
|
|
56
60
|
end
|
|
57
61
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bharat_filter_engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jayesh Rathore
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09
|
|
11
|
+
date: 2026-11-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activemodel
|
|
@@ -69,16 +69,20 @@ files:
|
|
|
69
69
|
- Rakefile
|
|
70
70
|
- lib/bharat_filter_engine.rb
|
|
71
71
|
- lib/bharat_filter_engine/association_resolver.rb
|
|
72
|
+
- lib/bharat_filter_engine/column_helper.rb
|
|
72
73
|
- lib/bharat_filter_engine/dynamic_filter_values.rb
|
|
73
74
|
- lib/bharat_filter_engine/engine.rb
|
|
74
75
|
- lib/bharat_filter_engine/errors.rb
|
|
76
|
+
- lib/bharat_filter_engine/join_tracker.rb
|
|
75
77
|
- lib/bharat_filter_engine/rule_normalizer.rb
|
|
76
78
|
- lib/bharat_filter_engine/search_builder.rb
|
|
77
79
|
- lib/bharat_filter_engine/version.rb
|
|
78
80
|
- spec/association_resolver_spec.rb
|
|
79
81
|
- spec/dynamic_filter_values_spec.rb
|
|
80
82
|
- spec/engine_spec.rb
|
|
83
|
+
- spec/join_tracker_spec.rb
|
|
81
84
|
- spec/rule_normalizer_spec.rb
|
|
85
|
+
- spec/same_table_join_spec.rb
|
|
82
86
|
- spec/search_builder_spec.rb
|
|
83
87
|
- spec/spec_helper.rb
|
|
84
88
|
homepage: https://github.com/jayesh70240/bharat-filter-engine
|
|
@@ -89,6 +93,7 @@ metadata:
|
|
|
89
93
|
source_code_uri: https://github.com/jayesh70240/bharat-filter-engine
|
|
90
94
|
changelog_uri: https://github.com/jayesh70240/bharat-filter-engine/blob/main/CHANGELOG.md
|
|
91
95
|
bug_tracker_uri: https://github.com/jayesh70240/bharat-filter-engine/issues
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
92
97
|
post_install_message:
|
|
93
98
|
rdoc_options: []
|
|
94
99
|
require_paths:
|
|
@@ -104,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
104
109
|
- !ruby/object:Gem::Version
|
|
105
110
|
version: '0'
|
|
106
111
|
requirements: []
|
|
107
|
-
rubygems_version: 3.5.
|
|
112
|
+
rubygems_version: 3.5.22
|
|
108
113
|
signing_key:
|
|
109
114
|
specification_version: 4
|
|
110
115
|
summary: A generic filtering engine for Rails ActiveRecord
|