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.
- checksums.yaml +4 -4
- data/Gemfile +12 -9
- data/README.md +1 -1
- data/Rakefile +5 -3
- data/lib/bharat_filter_engine/association_resolver.rb +18 -37
- data/lib/bharat_filter_engine/column_helper.rb +28 -0
- data/lib/bharat_filter_engine/dynamic_filter_values.rb +17 -18
- data/lib/bharat_filter_engine/engine.rb +249 -91
- data/lib/bharat_filter_engine/errors.rb +1 -1
- data/lib/bharat_filter_engine/join_tracker.rb +37 -0
- data/lib/bharat_filter_engine/rule_normalizer.rb +88 -30
- data/lib/bharat_filter_engine/search_builder.rb +48 -24
- data/lib/bharat_filter_engine/version.rb +1 -1
- data/lib/bharat_filter_engine.rb +14 -12
- data/spec/association_resolver_spec.rb +60 -41
- data/spec/dynamic_filter_values_spec.rb +161 -29
- data/spec/engine_spec.rb +1144 -538
- data/spec/join_tracker_spec.rb +70 -0
- data/spec/rule_normalizer_spec.rb +181 -32
- data/spec/same_table_join_spec.rb +199 -0
- data/spec/search_builder_spec.rb +222 -42
- data/spec/spec_helper.rb +11 -12
- metadata +14 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
+
expect(
|
|
14
|
+
described_class.call(rule)
|
|
15
|
+
).to eq(rule)
|
|
16
|
+
end
|
|
6
17
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
73
|
+
it 'raises when a search rule is missing dbcolumns' do
|
|
74
|
+
expect do
|
|
21
75
|
described_class.call(
|
|
22
|
-
|
|
23
|
-
|
|
76
|
+
type: :single,
|
|
77
|
+
filter_type: :search
|
|
24
78
|
)
|
|
79
|
+
end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
|
|
80
|
+
end
|
|
25
81
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|