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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BharatFilterEngine
4
+ VERSION = "0.1.1"
5
+ 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