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.
@@ -1,79 +1,78 @@
1
- require "spec_helper"
1
+ # frozen_string_literal: true
2
2
 
3
- RSpec.describe BharatFilterEngine::SearchBuilder do
3
+ require 'spec_helper'
4
4
 
5
+ RSpec.describe BharatFilterEngine::SearchBuilder do
5
6
  let!(:client) do
6
7
  Client.create!(
7
- name: "John Doe",
8
- email: "john@example.com"
8
+ name: 'John Doe',
9
+ email: 'john@example.com'
9
10
  )
10
11
  end
11
12
 
12
13
  let!(:lead) do
13
14
  Lead.create!(
14
- source: "google",
15
+ source: 'google',
15
16
  client: client
16
17
  )
17
18
  end
18
19
 
19
20
  let!(:sale) do
20
21
  Sale.create!(
21
- stage: "qualified",
22
+ stage: 'qualified',
22
23
  lead: lead
23
24
  )
24
25
  end
25
26
 
26
- it "supports simple search" do
27
-
27
+ it 'supports simple search' do
28
28
  result =
29
29
  described_class.new(
30
30
  scope: Sale.all,
31
- allowed_columns: [
32
- :stage,
33
- :"lead__source"
31
+ allowed_columns: %i[
32
+ stage
33
+ lead__source
34
34
  ]
35
- ).apply("google")
35
+ ).apply('google')
36
36
 
37
37
  expect(result).to contain_exactly(sale)
38
38
  end
39
39
 
40
- it "supports case insensitive search" do
40
+ it 'supports case insensitive search' do
41
41
  result =
42
42
  described_class.new(
43
43
  scope: Sale.all,
44
44
  allowed_columns: [:stage]
45
- ).apply("QUAL")
45
+ ).apply('QUAL')
46
46
 
47
47
  expect(result).to contain_exactly(sale)
48
48
  end
49
49
 
50
- it "supports nested field search" do
51
-
50
+ it 'supports nested field search' do
52
51
  result =
53
52
  described_class.new(
54
53
  scope: Sale.all,
55
54
  allowed_columns: [
56
- :"lead__client__name"
55
+ :lead__client__name
57
56
  ]
58
57
  ).apply(
59
- "lead__client__name=John"
58
+ 'lead__client__name=John'
60
59
  )
61
60
 
62
61
  expect(result).to contain_exactly(sale)
63
62
  end
64
63
 
65
- it "supports multi-level association search" do
64
+ it 'supports multi-level association search' do
66
65
  organization = Organization.create!(
67
- name: "Acme"
66
+ name: 'Acme'
68
67
  )
69
68
 
70
69
  client_two = Client.create!(
71
- name: "Jane",
70
+ name: 'Jane',
72
71
  organization: organization
73
72
  )
74
73
 
75
74
  lead_two = Lead.create!(
76
- source: "referral",
75
+ source: 'referral',
77
76
  client: client_two
78
77
  )
79
78
 
@@ -85,56 +84,76 @@ RSpec.describe BharatFilterEngine::SearchBuilder do
85
84
  described_class.new(
86
85
  scope: Sale.all,
87
86
  allowed_columns: [
88
- :"lead__client__organization__name"
87
+ :lead__client__organization__name
89
88
  ]
90
89
  ).apply(
91
- "lead__client__organization__name=Acme"
90
+ 'lead__client__organization__name=Acme'
92
91
  )
93
92
 
94
93
  expect(result).to contain_exactly(sale_two)
95
94
  end
96
95
 
97
- it "supports OR search" do
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
98
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
+
117
+ it 'supports OR search' do
99
118
  result =
100
119
  described_class.new(
101
120
  scope: Sale.all,
102
- allowed_columns: [
103
- :stage,
104
- :"lead__source"
121
+ allowed_columns: %i[
122
+ stage
123
+ lead__source
105
124
  ]
106
125
  ).apply(
107
- "stage=qualified|lead__source=google"
126
+ 'stage=qualified|lead__source=google'
108
127
  )
109
128
 
110
129
  expect(result).to contain_exactly(sale)
111
130
  end
112
131
 
113
- it "supports combined AND and OR conditions" do
132
+ it 'supports combined AND and OR conditions' do
114
133
  sale_one = Sale.create!(
115
- stage: "qualified",
116
- approval_status: "approved"
134
+ stage: 'qualified',
135
+ approval_status: 'approved'
117
136
  )
118
137
 
119
138
  sale_two = Sale.create!(
120
- stage: "qualified",
121
- approval_status: "pending"
139
+ stage: 'qualified',
140
+ approval_status: 'pending'
122
141
  )
123
142
 
124
143
  Sale.create!(
125
- stage: "new",
126
- approval_status: "approved"
144
+ stage: 'new',
145
+ approval_status: 'approved'
127
146
  )
128
147
 
129
148
  result =
130
149
  described_class.new(
131
150
  scope: Sale.all,
132
- allowed_columns: [
133
- :stage,
134
- :approval_status
151
+ allowed_columns: %i[
152
+ stage
153
+ approval_status
135
154
  ]
136
155
  ).apply(
137
- "stage=qualified&approval_status=approved|approval_status=pending"
156
+ 'stage=qualified&approval_status=approved|approval_status=pending'
138
157
  )
139
158
 
140
159
  expect(result).to contain_exactly(
@@ -143,15 +162,176 @@ RSpec.describe BharatFilterEngine::SearchBuilder do
143
162
  )
144
163
  end
145
164
 
146
- it "returns no records when the searched field is not configured" do
165
+ it 'returns no records when the searched field is not configured' do
166
+ result =
167
+ described_class.new(
168
+ scope: Sale.all,
169
+ allowed_columns: [:stage]
170
+ ).apply(
171
+ 'unknown=value'
172
+ )
173
+
174
+ expect(result).to be_empty
175
+ end
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
+
201
+ it 'supports searching the id column as text' do
202
+ result =
203
+ described_class.new(
204
+ scope: Sale.all,
205
+ allowed_columns: [:id]
206
+ ).apply(
207
+ "id=#{sale.id}"
208
+ )
209
+
210
+ expect(result).to contain_exactly(sale)
211
+ end
212
+
213
+ it "handles a value that itself contains an '=' sign" do
214
+ sale_with_url = Sale.create!(
215
+ stage: 'https://example.com?x=1'
216
+ )
217
+
218
+ result =
219
+ described_class.new(
220
+ scope: Sale.all,
221
+ allowed_columns: [:stage]
222
+ ).apply(
223
+ 'stage=https://example.com?x=1'
224
+ )
225
+
226
+ expect(result).to contain_exactly(sale_with_url)
227
+ end
228
+
229
+ it 'skips a whitespace-only expression value' do
147
230
  result =
148
231
  described_class.new(
149
232
  scope: Sale.all,
150
233
  allowed_columns: [:stage]
151
234
  ).apply(
152
- "unknown=value"
235
+ 'stage= '
153
236
  )
154
237
 
238
+ # every expression in the (only) AND group is blank/skipped, so
239
+ # the group can never match anything
155
240
  expect(result).to be_empty
156
241
  end
242
+
243
+ it 'returns the scope unchanged when allowed_columns is empty' do
244
+ result =
245
+ described_class.new(
246
+ scope: Sale.all,
247
+ allowed_columns: []
248
+ ).apply('anything')
249
+
250
+ expect(result).to contain_exactly(sale)
251
+ end
252
+
253
+ it 'raises when none of the allowed_columns can be resolved' do
254
+ expect do
255
+ described_class.new(
256
+ scope: Sale.all,
257
+ allowed_columns: %i[
258
+ totally_not_a_column
259
+ also__not__real
260
+ ]
261
+ )
262
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
263
+ end
264
+
265
+ it 'does not raise when only some allowed_columns are invalid' do
266
+ expect do
267
+ described_class.new(
268
+ scope: Sale.all,
269
+ allowed_columns: %i[
270
+ stage
271
+ totally_not_a_column
272
+ ]
273
+ )
274
+ end.not_to raise_error
275
+ end
276
+
277
+ it 'reuses a single AssociationResolver across a whole search' do
278
+ allow(BharatFilterEngine::AssociationResolver)
279
+ .to receive(:new)
280
+ .and_call_original
281
+
282
+ described_class.new(
283
+ scope: Sale.all,
284
+ allowed_columns: %i[
285
+ lead__source
286
+ lead__client__name
287
+ ]
288
+ ).apply(
289
+ 'lead__source=google&lead__client__name=John'
290
+ )
291
+
292
+ expect(BharatFilterEngine::AssociationResolver)
293
+ .to have_received(:new)
294
+ .once
295
+ end
296
+
297
+ describe 'LIKE wildcard escaping' do
298
+ it 'treats a literal underscore as a literal character, not a wildcard' do
299
+ match = Sale.create!(stage: 'sale_2026')
300
+ Sale.create!(stage: 'saleX2026')
301
+
302
+ result =
303
+ described_class.new(
304
+ scope: Sale.all,
305
+ allowed_columns: [:stage]
306
+ ).apply('sale_2026')
307
+
308
+ expect(result).to contain_exactly(match)
309
+ end
310
+
311
+ it 'treats a literal percent sign as a literal character, not a wildcard' do
312
+ match = Sale.create!(stage: '50%off')
313
+ Sale.create!(stage: '50xoff')
314
+
315
+ result =
316
+ described_class.new(
317
+ scope: Sale.all,
318
+ allowed_columns: [:stage]
319
+ ).apply('50%off')
320
+
321
+ expect(result).to contain_exactly(match)
322
+ end
323
+
324
+ it 'escapes wildcards in field-based search too' do
325
+ match = Sale.create!(stage: 'sale_2026')
326
+ Sale.create!(stage: 'saleX2026')
327
+
328
+ result =
329
+ described_class.new(
330
+ scope: Sale.all,
331
+ allowed_columns: [:stage]
332
+ ).apply('stage=sale_2026')
333
+
334
+ expect(result).to contain_exactly(match)
335
+ end
336
+ end
157
337
  end
data/spec/spec_helper.rb CHANGED
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "active_record"
5
- require "database_cleaner/active_record"
6
- require "bharat_filter_engine"
7
- require "sqlite3"
3
+ require 'logger'
4
+ require 'bundler/setup'
5
+ require 'active_record'
6
+ require 'database_cleaner/active_record'
7
+ require 'bharat_filter_engine'
8
+ require 'sqlite3'
8
9
 
9
10
  ActiveRecord::Base.establish_connection(
10
- adapter: "sqlite3",
11
- database: ":memory:"
11
+ adapter: 'sqlite3',
12
+ database: ':memory:'
12
13
  )
13
14
 
14
15
  ActiveRecord::Schema.define do
@@ -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
 
@@ -41,29 +43,26 @@ ActiveRecord::Schema.define do
41
43
  end
42
44
  end
43
45
 
44
-
45
46
  class Organization < ActiveRecord::Base
46
47
  has_many :clients
47
48
  end
48
49
 
49
-
50
50
  class Client < ActiveRecord::Base
51
51
  belongs_to :organization
52
52
  has_many :leads
53
+ has_many :referred_leads, class_name: 'Lead', foreign_key: :referrer_id
53
54
  end
54
55
 
55
-
56
56
  class Lead < ActiveRecord::Base
57
57
  belongs_to :client
58
+ belongs_to :referrer, class_name: 'Client', optional: true
58
59
  has_many :sales
59
60
  end
60
61
 
61
-
62
62
  class Sale < ActiveRecord::Base
63
63
  belongs_to :lead
64
64
  end
65
65
 
66
-
67
66
  RSpec.configure do |config|
68
67
  config.before(:suite) do
69
68
  DatabaseCleaner.clean_with(:truncation)
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bharat_filter_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
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-03 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
- name: activerecord
14
+ name: activemodel
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
@@ -25,7 +25,7 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '6.1'
27
27
  - !ruby/object:Gem::Dependency
28
- name: activemodel
28
+ name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
@@ -53,7 +53,9 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '6.1'
55
55
  description: Configurable Rails ActiveRecord filtering engine supporting string, array,
56
- boolean, numeric, date-range, nested association and AND/OR search filters.
56
+ boolean, numeric (with gte/lte/between ranges), date-range, presence, negate/exclude,
57
+ nested association, and AND/OR search filters, plus dynamic filter-value lookups
58
+ for dropdowns/autocomplete.
57
59
  email:
58
60
  - jayeshrathore7024@gmail.com
59
61
  executables: []
@@ -67,25 +69,31 @@ files:
67
69
  - Rakefile
68
70
  - lib/bharat_filter_engine.rb
69
71
  - lib/bharat_filter_engine/association_resolver.rb
72
+ - lib/bharat_filter_engine/column_helper.rb
70
73
  - lib/bharat_filter_engine/dynamic_filter_values.rb
71
74
  - lib/bharat_filter_engine/engine.rb
72
75
  - lib/bharat_filter_engine/errors.rb
76
+ - lib/bharat_filter_engine/join_tracker.rb
73
77
  - lib/bharat_filter_engine/rule_normalizer.rb
74
78
  - lib/bharat_filter_engine/search_builder.rb
75
79
  - lib/bharat_filter_engine/version.rb
76
80
  - spec/association_resolver_spec.rb
77
81
  - spec/dynamic_filter_values_spec.rb
78
82
  - spec/engine_spec.rb
83
+ - spec/join_tracker_spec.rb
79
84
  - spec/rule_normalizer_spec.rb
85
+ - spec/same_table_join_spec.rb
80
86
  - spec/search_builder_spec.rb
81
87
  - spec/spec_helper.rb
82
88
  homepage: https://github.com/jayesh70240/bharat-filter-engine
83
89
  licenses:
84
90
  - MIT
85
91
  metadata:
92
+ homepage_uri: https://github.com/jayesh70240/bharat-filter-engine
86
93
  source_code_uri: https://github.com/jayesh70240/bharat-filter-engine
87
94
  changelog_uri: https://github.com/jayesh70240/bharat-filter-engine/blob/main/CHANGELOG.md
88
95
  bug_tracker_uri: https://github.com/jayesh70240/bharat-filter-engine/issues
96
+ rubygems_mfa_required: 'true'
89
97
  post_install_message:
90
98
  rdoc_options: []
91
99
  require_paths:
@@ -101,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
101
109
  - !ruby/object:Gem::Version
102
110
  version: '0'
103
111
  requirements: []
104
- rubygems_version: 3.5.3
112
+ rubygems_version: 3.5.22
105
113
  signing_key:
106
114
  specification_version: 4
107
115
  summary: A generic filtering engine for Rails ActiveRecord