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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BharatFilterEngine
|
|
4
|
+
class SearchBuilder
|
|
5
|
+
def initialize(scope:, allowed_columns:)
|
|
6
|
+
@scope = scope
|
|
7
|
+
@allowed_columns =
|
|
8
|
+
Array(allowed_columns).map(&:to_s)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def apply(value)
|
|
12
|
+
return @scope if value.blank?
|
|
13
|
+
|
|
14
|
+
search = value.to_s.strip
|
|
15
|
+
|
|
16
|
+
if search.include?("=")
|
|
17
|
+
field_based_search(search)
|
|
18
|
+
else
|
|
19
|
+
simple_search(search)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def simple_search(search)
|
|
26
|
+
query = "%#{search}%"
|
|
27
|
+
conditions = []
|
|
28
|
+
|
|
29
|
+
@allowed_columns.each do |dbcolumn|
|
|
30
|
+
next unless valid_field?(dbcolumn)
|
|
31
|
+
|
|
32
|
+
if dbcolumn.include?("__")
|
|
33
|
+
@scope, condition =
|
|
34
|
+
nested_condition(
|
|
35
|
+
@scope,
|
|
36
|
+
dbcolumn,
|
|
37
|
+
query
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
conditions << condition if condition
|
|
41
|
+
else
|
|
42
|
+
table = @scope.klass.arel_table
|
|
43
|
+
|
|
44
|
+
conditions << table[dbcolumn].matches(query)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return @scope if conditions.empty?
|
|
49
|
+
|
|
50
|
+
@scope.where(
|
|
51
|
+
conditions.reduce { |left, right| left.or(right) }
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# field=value pairs, "&" = AND, "|" = OR within an AND group.
|
|
56
|
+
#
|
|
57
|
+
# If an AND group ends up with no valid conditions (e.g. every
|
|
58
|
+
# field in it is unknown/blank), that group can never be satisfied
|
|
59
|
+
# -- it must NOT be silently dropped, otherwise the whole search
|
|
60
|
+
# is skipped and unrelated records leak through unfiltered.
|
|
61
|
+
def field_based_search(search)
|
|
62
|
+
and_conditions =
|
|
63
|
+
search.split("&").map do |and_group|
|
|
64
|
+
or_conditions =
|
|
65
|
+
and_group.split("|").filter_map do |expression|
|
|
66
|
+
field, search_value =
|
|
67
|
+
expression.split("=", 2)
|
|
68
|
+
|
|
69
|
+
next if field.blank?
|
|
70
|
+
next if search_value.blank?
|
|
71
|
+
next unless valid_field?(field)
|
|
72
|
+
|
|
73
|
+
query = "%#{search_value.strip}%"
|
|
74
|
+
|
|
75
|
+
if field.include?("__")
|
|
76
|
+
@scope, condition =
|
|
77
|
+
nested_condition(
|
|
78
|
+
@scope,
|
|
79
|
+
field,
|
|
80
|
+
query
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
condition
|
|
84
|
+
elsif field == "id"
|
|
85
|
+
quoted_column =
|
|
86
|
+
@scope.klass
|
|
87
|
+
.connection
|
|
88
|
+
.quote_column_name(field)
|
|
89
|
+
|
|
90
|
+
Arel.sql(
|
|
91
|
+
"#{@scope.klass.quoted_table_name}.#{quoted_column}::text"
|
|
92
|
+
).matches(query)
|
|
93
|
+
else
|
|
94
|
+
@scope.klass.arel_table[field].matches(query)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every expression in this AND group was invalid/unmatchable,
|
|
99
|
+
# so the whole search can never match anything.
|
|
100
|
+
return @scope.none if or_conditions.empty?
|
|
101
|
+
|
|
102
|
+
or_conditions.reduce { |left, right| left.or(right) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
return @scope if and_conditions.empty?
|
|
106
|
+
|
|
107
|
+
@scope.where(
|
|
108
|
+
and_conditions.reduce { |left, right| left.and(right) }
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def valid_field?(field)
|
|
113
|
+
if field.include?("__")
|
|
114
|
+
!resolver.resolve_column(field).nil?
|
|
115
|
+
else
|
|
116
|
+
@scope.klass.column_names.include?(field)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def nested_condition(scope, field, query)
|
|
121
|
+
result = resolver.resolve_column(field)
|
|
122
|
+
|
|
123
|
+
return [scope, nil] unless result
|
|
124
|
+
|
|
125
|
+
joined_scope =
|
|
126
|
+
resolver.join(
|
|
127
|
+
scope,
|
|
128
|
+
result[:associations]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
condition =
|
|
132
|
+
result[:klass]
|
|
133
|
+
.arel_table[result[:column]]
|
|
134
|
+
.matches(query)
|
|
135
|
+
|
|
136
|
+
[joined_scope, condition]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# AssociationResolver only needs @scope.klass (which never changes
|
|
140
|
+
# across joins), so a single instance can be reused for the whole
|
|
141
|
+
# search instead of being rebuilt every time the scope is updated.
|
|
142
|
+
def resolver
|
|
143
|
+
@resolver ||= AssociationResolver.new(@scope)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_model"
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
require_relative "bharat_filter_engine/version"
|
|
9
|
+
require_relative "bharat_filter_engine/errors"
|
|
10
|
+
require_relative "bharat_filter_engine/association_resolver"
|
|
11
|
+
require_relative "bharat_filter_engine/rule_normalizer"
|
|
12
|
+
require_relative "bharat_filter_engine/search_builder"
|
|
13
|
+
require_relative "bharat_filter_engine/dynamic_filter_values"
|
|
14
|
+
require_relative "bharat_filter_engine/engine"
|
|
15
|
+
|
|
16
|
+
module BharatFilterEngine
|
|
17
|
+
class << self
|
|
18
|
+
def apply(scope:, config:, params:)
|
|
19
|
+
Engine.apply(
|
|
20
|
+
scope: scope,
|
|
21
|
+
config: config,
|
|
22
|
+
params: params
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def filter_values(scope:, config:, params:)
|
|
27
|
+
Engine.filter_values(
|
|
28
|
+
scope: scope,
|
|
29
|
+
config: config,
|
|
30
|
+
params: params
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe BharatFilterEngine::AssociationResolver do
|
|
6
|
+
|
|
7
|
+
let(:resolver) do
|
|
8
|
+
described_class.new(
|
|
9
|
+
Sale.all
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "#resolve" do
|
|
14
|
+
|
|
15
|
+
it "resolves a direct association" do
|
|
16
|
+
result = resolver.resolve("lead")
|
|
17
|
+
|
|
18
|
+
expect(result[:associations]).to eq(
|
|
19
|
+
[:lead]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(result[:klass]).to eq(
|
|
23
|
+
Lead
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "resolves nested associations" do
|
|
28
|
+
result = resolver.resolve(
|
|
29
|
+
"lead__client"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(result[:associations]).to eq(
|
|
33
|
+
[:lead, :client]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(result[:klass]).to eq(
|
|
37
|
+
Client
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "resolves multi-level associations" do
|
|
42
|
+
result = resolver.resolve(
|
|
43
|
+
"lead__client__organization"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(result[:associations]).to eq(
|
|
47
|
+
[
|
|
48
|
+
:lead,
|
|
49
|
+
:client,
|
|
50
|
+
:organization
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
expect(result[:klass]).to eq(
|
|
55
|
+
Organization
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "returns nil for invalid association" do
|
|
60
|
+
result = resolver.resolve(
|
|
61
|
+
"unknown"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result).to be_nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "#resolve_column" do
|
|
69
|
+
|
|
70
|
+
it "resolves direct columns" do
|
|
71
|
+
result =
|
|
72
|
+
resolver.resolve_column(
|
|
73
|
+
"stage"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(result[:column]).to eq(
|
|
77
|
+
"stage"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(result[:associations]).to eq([])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "resolves nested columns" do
|
|
84
|
+
result =
|
|
85
|
+
resolver.resolve_column(
|
|
86
|
+
"lead__source"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(result[:column]).to eq(
|
|
90
|
+
"source"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expect(result[:associations]).to eq(
|
|
94
|
+
[:lead]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "resolves multi-level columns" do
|
|
99
|
+
result =
|
|
100
|
+
resolver.resolve_column(
|
|
101
|
+
"lead__client__name"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(result[:column]).to eq(
|
|
105
|
+
"name"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(result[:associations]).to eq(
|
|
109
|
+
[:lead, :client]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(result[:klass]).to eq(
|
|
113
|
+
Client
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "returns nil for invalid column" do
|
|
118
|
+
result =
|
|
119
|
+
resolver.resolve_column(
|
|
120
|
+
"lead__invalid"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
expect(result).to be_nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe "#join" do
|
|
128
|
+
|
|
129
|
+
it "joins one association" do
|
|
130
|
+
scope =
|
|
131
|
+
resolver.join(
|
|
132
|
+
Sale.all,
|
|
133
|
+
[:lead]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
scope.to_sql
|
|
138
|
+
).to include(
|
|
139
|
+
"JOIN"
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "joins nested associations" do
|
|
144
|
+
scope =
|
|
145
|
+
resolver.join(
|
|
146
|
+
Sale.all,
|
|
147
|
+
[
|
|
148
|
+
:lead,
|
|
149
|
+
:client
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(
|
|
154
|
+
scope.to_sql
|
|
155
|
+
).to include(
|
|
156
|
+
"JOIN"
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe BharatFilterEngine::DynamicFilterValues do
|
|
4
|
+
|
|
5
|
+
let!(:client_one) do
|
|
6
|
+
Client.create!(
|
|
7
|
+
name: "John"
|
|
8
|
+
)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
let!(:client_two) do
|
|
12
|
+
Client.create!(
|
|
13
|
+
name: "Jane"
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
let!(:lead_one) do
|
|
18
|
+
Lead.create!(
|
|
19
|
+
source: "google",
|
|
20
|
+
client: client_one
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
let!(:lead_two) do
|
|
25
|
+
Lead.create!(
|
|
26
|
+
source: "referral",
|
|
27
|
+
client: client_two
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
before do
|
|
32
|
+
Sale.create!(
|
|
33
|
+
stage: "qualified",
|
|
34
|
+
lead: lead_one
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Sale.create!(
|
|
38
|
+
stage: "new",
|
|
39
|
+
lead: lead_two
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "returns direct values" do
|
|
44
|
+
|
|
45
|
+
result =
|
|
46
|
+
described_class.new(
|
|
47
|
+
scope: Sale.all,
|
|
48
|
+
field: "stage"
|
|
49
|
+
).call
|
|
50
|
+
|
|
51
|
+
expect(result).to eq(
|
|
52
|
+
%w[new qualified]
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "returns nested values" do
|
|
57
|
+
|
|
58
|
+
result =
|
|
59
|
+
described_class.new(
|
|
60
|
+
scope: Sale.all,
|
|
61
|
+
field: "lead__source"
|
|
62
|
+
).call
|
|
63
|
+
|
|
64
|
+
expect(result).to eq(
|
|
65
|
+
%w[google referral]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "returns multi-level nested values" do
|
|
70
|
+
|
|
71
|
+
result =
|
|
72
|
+
described_class.new(
|
|
73
|
+
scope: Sale.all,
|
|
74
|
+
field: "lead__client__name"
|
|
75
|
+
).call
|
|
76
|
+
|
|
77
|
+
expect(result).to eq(
|
|
78
|
+
%w[Jane John]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "returns nil for invalid field" do
|
|
83
|
+
|
|
84
|
+
result =
|
|
85
|
+
described_class.new(
|
|
86
|
+
scope: Sale.all,
|
|
87
|
+
field: "invalid_field"
|
|
88
|
+
).call
|
|
89
|
+
|
|
90
|
+
expect(result).to be_nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "returns nil for a valid association with an invalid column" do
|
|
94
|
+
|
|
95
|
+
result =
|
|
96
|
+
described_class.new(
|
|
97
|
+
scope: Sale.all,
|
|
98
|
+
field: "lead__something_wrong"
|
|
99
|
+
).call
|
|
100
|
+
|
|
101
|
+
expect(result).to be_nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "returns nil for an invalid association" do
|
|
105
|
+
|
|
106
|
+
result =
|
|
107
|
+
described_class.new(
|
|
108
|
+
scope: Sale.all,
|
|
109
|
+
field: "something__name"
|
|
110
|
+
).call
|
|
111
|
+
|
|
112
|
+
expect(result).to be_nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "does not return blank values" do
|
|
116
|
+
Sale.create!(stage: "")
|
|
117
|
+
Sale.create!(stage: nil)
|
|
118
|
+
|
|
119
|
+
result =
|
|
120
|
+
described_class.new(
|
|
121
|
+
scope: Sale.all,
|
|
122
|
+
field: "stage"
|
|
123
|
+
).call
|
|
124
|
+
|
|
125
|
+
expect(result).to eq(
|
|
126
|
+
%w[new qualified]
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
end
|