bharat_filter_engine 0.1.2 → 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.
@@ -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
@@ -1,52 +1,201 @@
1
- require "spec_helper"
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
2
4
 
3
5
  RSpec.describe BharatFilterEngine::RuleNormalizer do
6
+ it 'keeps modern configuration unchanged' do
7
+ rule = {
8
+ type: :single,
9
+ filter_type: :string,
10
+ dbcolumn: :stage
11
+ }
4
12
 
5
- it "keeps modern configuration unchanged" do
13
+ expect(
14
+ described_class.call(rule)
15
+ ).to eq(rule)
16
+ end
6
17
 
7
- rule = {
8
- type: :single,
9
- filter_type: :string,
18
+ it 'supports legacy configuration' do
19
+ result =
20
+ described_class.call(
21
+ type: :string,
10
22
  dbcolumn: :stage
11
- }
23
+ )
24
+
25
+ expect(
26
+ result[:type]
27
+ ).to eq(:single)
28
+
29
+ expect(
30
+ result[:filter_type]
31
+ ).to eq(:string)
32
+ end
33
+
34
+ it 'returns nil for nil rule' do
35
+ expect(
36
+ described_class.call(nil)
37
+ ).to be_nil
38
+ end
39
+
40
+ it 'symbolizes rule keys' do
41
+ result = described_class.call(
42
+ 'type' => :single,
43
+ 'filter_type' => :string,
44
+ 'dbcolumn' => :stage
45
+ )
46
+
47
+ expect(result[:type]).to eq(:single)
48
+ expect(result[:filter_type]).to eq(:string)
49
+ expect(result[:dbcolumn]).to eq(:stage)
50
+ end
12
51
 
13
- expect(
14
- described_class.call(rule)
15
- ).to eq(rule)
52
+ describe 'validation' do
53
+ it 'raises for an unknown filter_type' do
54
+ expect do
55
+ described_class.call(
56
+ type: :single,
57
+ filter_type: :not_a_real_type,
58
+ dbcolumn: :stage
59
+ )
60
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
16
61
  end
17
62
 
18
- it "supports legacy configuration" do
63
+ it 'raises when a nested rule is missing association' do
64
+ expect do
65
+ described_class.call(
66
+ type: :nested,
67
+ filter_type: :string,
68
+ dbcolumn: :source
69
+ )
70
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
71
+ end
19
72
 
20
- result =
73
+ it 'raises when a search rule is missing dbcolumns' do
74
+ expect do
21
75
  described_class.call(
22
- type: :string,
23
- dbcolumn: :stage
76
+ type: :single,
77
+ filter_type: :search
24
78
  )
79
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
80
+ end
25
81
 
26
- expect(
27
- result[:type]
28
- ).to eq(:single)
82
+ it 'raises when a search rule has an empty dbcolumns array' do
83
+ expect do
84
+ described_class.call(
85
+ type: :single,
86
+ filter_type: :search,
87
+ dbcolumns: []
88
+ )
89
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
90
+ end
29
91
 
30
- expect(
31
- result[:filter_type]
32
- ).to eq(:string)
92
+ it 'raises when a non-search rule is missing dbcolumn' do
93
+ expect do
94
+ described_class.call(
95
+ type: :single,
96
+ filter_type: :string
97
+ )
98
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
33
99
  end
34
100
 
35
- it "returns nil for nil rule" do
36
- expect(
37
- described_class.call(nil)
38
- ).to be_nil
101
+ it 'raises for an unknown range_type' do
102
+ expect do
103
+ described_class.call(
104
+ type: :single,
105
+ filter_type: :integer,
106
+ dbcolumn: :amount,
107
+ range_type: :nonsense
108
+ )
109
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
39
110
  end
40
111
 
41
- it "symbolizes rule keys" do
42
- result = described_class.call(
43
- "type" => :single,
44
- "filter_type" => :string,
45
- "dbcolumn" => :stage
112
+ it 'raises when range_type is used on a non-numeric filter_type' do
113
+ expect do
114
+ described_class.call(
115
+ type: :single,
116
+ filter_type: :string,
117
+ dbcolumn: :stage,
118
+ range_type: :gte
46
119
  )
120
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
121
+ end
47
122
 
48
- expect(result[:type]).to eq(:single)
49
- expect(result[:filter_type]).to eq(:string)
50
- expect(result[:dbcolumn]).to eq(:stage)
123
+ it 'allows range_type: :between on integer/float filters' do
124
+ expect do
125
+ described_class.call(
126
+ type: :single,
127
+ filter_type: :integer,
128
+ dbcolumn: :amount,
129
+ range_type: :between
130
+ )
131
+ end.not_to raise_error
132
+ end
133
+
134
+ it 'raises when negate is combined with range_type' do
135
+ expect do
136
+ described_class.call(
137
+ type: :single,
138
+ filter_type: :integer,
139
+ dbcolumn: :amount,
140
+ range_type: :gte,
141
+ negate: true
142
+ )
143
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
144
+ end
145
+
146
+ it 'raises when negate is combined with a daterange filter' do
147
+ expect do
148
+ described_class.call(
149
+ type: :single,
150
+ filter_type: :daterange,
151
+ dbcolumn: :actual_sale_date,
152
+ negate: true
153
+ )
154
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
155
+ end
156
+
157
+ it 'raises when negate is combined with a search filter' do
158
+ expect do
159
+ described_class.call(
160
+ type: :single,
161
+ filter_type: :search,
162
+ dbcolumns: [:stage],
163
+ negate: true
164
+ )
165
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
166
+ end
167
+
168
+ it 'raises when negate is combined with a presence filter' do
169
+ expect do
170
+ described_class.call(
171
+ type: :single,
172
+ filter_type: :presence,
173
+ dbcolumn: :stage,
174
+ negate: true
175
+ )
176
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
177
+ end
178
+
179
+ it 'allows negate on a plain string/array/boolean/exact-numeric filter' do
180
+ expect do
181
+ described_class.call(
182
+ type: :single,
183
+ filter_type: :string,
184
+ dbcolumn: :stage,
185
+ negate: true
186
+ )
187
+ end.not_to raise_error
188
+ end
189
+
190
+ it 'raises when filter_type: :search is combined with type: :nested' do
191
+ expect do
192
+ described_class.call(
193
+ type: :nested,
194
+ filter_type: :search,
195
+ association: :lead,
196
+ dbcolumns: [:source]
197
+ )
198
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
51
199
  end
52
- end
200
+ end
201
+ 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