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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 811680608cacda9f967cd20ce13482ed5cbe637ce21193d6d7b4932266f15556
4
- data.tar.gz: 41c8837e9ae4df42f83d5fae6a7907ed00dfb7308f72273dbe23d7e05f55ac03
3
+ metadata.gz: 45380eb2ba5afe790b2cbc8f0abe65cbab402d2489fb025bd2d2407c5700a57f
4
+ data.tar.gz: 8529dbb3f163c708c819ab166e1d946d23900ef685b9bb44b758f734c44c913b
5
5
  SHA512:
6
- metadata.gz: c61055a2cb73bd87b4ddb0bc1258515ee78d1f9dbafe51ad1027165de23b054c3a5d07bb3d3b14c6df089c6d6b34f99b565bedae8a5d05eb63f7ff31bedab5e6
7
- data.tar.gz: d68e184b788757dcbb8940cb2096ce85859b6a70864457f4977a3b4607316fa7d455e078624ed5f8acc39a78f458331ef4c0bb7b7f455a6d5f4de57dd01176b1
6
+ metadata.gz: 4ae9d8027f95b37c0f7f43235a56cc0b234ab444aa0079bfa1425b44ca408677def18f0f11216dced37321b68765a7e64fcd2217add708dc097a8da6e44c536e
7
+ data.tar.gz: a2ea4acb0d03be39e10d59b0c01b7da206489e95fbad43a7d60c1f04e83c9de7031de5d5779599b6c22348e684c17b326b2d9f3fa3b39a022e69b3ab429cd1be
data/Gemfile CHANGED
@@ -12,6 +12,8 @@ group :development, :test do
12
12
  gem 'rake'
13
13
  gem 'rspec'
14
14
  gem 'rubocop-rails-omakase', require: false
15
+ gem 'rubocop-rake', require: false
16
+ gem 'rubocop-rspec', require: false
15
17
  gem 'sqlite3'
16
18
  end
17
19
 
@@ -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
- column =
64
- parts.pop
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
- return nil unless
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
- build_nested_join(associations)
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
- @term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
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
- scope.klass.arel_table[column]
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}" }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BharatFilterEngine
4
- VERSION = '0.2.0pre'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -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
@@ -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.0pre
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-25 00:00:00.000000000 Z
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.3
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