bharat_filter_engine 0.1.2 → 0.2.0pre

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,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
@@ -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,55 @@ 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
98
-
96
+ it 'supports OR search' do
99
97
  result =
100
98
  described_class.new(
101
99
  scope: Sale.all,
102
- allowed_columns: [
103
- :stage,
104
- :"lead__source"
100
+ allowed_columns: %i[
101
+ stage
102
+ lead__source
105
103
  ]
106
104
  ).apply(
107
- "stage=qualified|lead__source=google"
105
+ 'stage=qualified|lead__source=google'
108
106
  )
109
107
 
110
108
  expect(result).to contain_exactly(sale)
111
109
  end
112
110
 
113
- it "supports combined AND and OR conditions" do
111
+ it 'supports combined AND and OR conditions' do
114
112
  sale_one = Sale.create!(
115
- stage: "qualified",
116
- approval_status: "approved"
113
+ stage: 'qualified',
114
+ approval_status: 'approved'
117
115
  )
118
116
 
119
117
  sale_two = Sale.create!(
120
- stage: "qualified",
121
- approval_status: "pending"
118
+ stage: 'qualified',
119
+ approval_status: 'pending'
122
120
  )
123
121
 
124
122
  Sale.create!(
125
- stage: "new",
126
- approval_status: "approved"
123
+ stage: 'new',
124
+ approval_status: 'approved'
127
125
  )
128
126
 
129
127
  result =
130
128
  described_class.new(
131
129
  scope: Sale.all,
132
- allowed_columns: [
133
- :stage,
134
- :approval_status
130
+ allowed_columns: %i[
131
+ stage
132
+ approval_status
135
133
  ]
136
134
  ).apply(
137
- "stage=qualified&approval_status=approved|approval_status=pending"
135
+ 'stage=qualified&approval_status=approved|approval_status=pending'
138
136
  )
139
137
 
140
138
  expect(result).to contain_exactly(
@@ -143,15 +141,152 @@ RSpec.describe BharatFilterEngine::SearchBuilder do
143
141
  )
144
142
  end
145
143
 
146
- it "returns no records when the searched field is not configured" do
144
+ it 'returns no records when the searched field is not configured' do
145
+ result =
146
+ described_class.new(
147
+ scope: Sale.all,
148
+ allowed_columns: [:stage]
149
+ ).apply(
150
+ 'unknown=value'
151
+ )
152
+
153
+ expect(result).to be_empty
154
+ end
155
+
156
+ it 'supports searching the id column as text' do
157
+ result =
158
+ described_class.new(
159
+ scope: Sale.all,
160
+ allowed_columns: [:id]
161
+ ).apply(
162
+ "id=#{sale.id}"
163
+ )
164
+
165
+ expect(result).to contain_exactly(sale)
166
+ end
167
+
168
+ it "handles a value that itself contains an '=' sign" do
169
+ sale_with_url = Sale.create!(
170
+ stage: 'https://example.com?x=1'
171
+ )
172
+
173
+ result =
174
+ described_class.new(
175
+ scope: Sale.all,
176
+ allowed_columns: [:stage]
177
+ ).apply(
178
+ 'stage=https://example.com?x=1'
179
+ )
180
+
181
+ expect(result).to contain_exactly(sale_with_url)
182
+ end
183
+
184
+ it 'skips a whitespace-only expression value' do
147
185
  result =
148
186
  described_class.new(
149
187
  scope: Sale.all,
150
188
  allowed_columns: [:stage]
151
189
  ).apply(
152
- "unknown=value"
190
+ 'stage= '
153
191
  )
154
192
 
193
+ # every expression in the (only) AND group is blank/skipped, so
194
+ # the group can never match anything
155
195
  expect(result).to be_empty
156
196
  end
197
+
198
+ it 'returns the scope unchanged when allowed_columns is empty' do
199
+ result =
200
+ described_class.new(
201
+ scope: Sale.all,
202
+ allowed_columns: []
203
+ ).apply('anything')
204
+
205
+ expect(result).to contain_exactly(sale)
206
+ end
207
+
208
+ it 'raises when none of the allowed_columns can be resolved' do
209
+ expect do
210
+ described_class.new(
211
+ scope: Sale.all,
212
+ allowed_columns: %i[
213
+ totally_not_a_column
214
+ also__not__real
215
+ ]
216
+ )
217
+ end.to raise_error(BharatFilterEngine::InvalidConfigurationError)
218
+ end
219
+
220
+ it 'does not raise when only some allowed_columns are invalid' do
221
+ expect do
222
+ described_class.new(
223
+ scope: Sale.all,
224
+ allowed_columns: %i[
225
+ stage
226
+ totally_not_a_column
227
+ ]
228
+ )
229
+ end.not_to raise_error
230
+ end
231
+
232
+ it 'reuses a single AssociationResolver across a whole search' do
233
+ allow(BharatFilterEngine::AssociationResolver)
234
+ .to receive(:new)
235
+ .and_call_original
236
+
237
+ described_class.new(
238
+ scope: Sale.all,
239
+ allowed_columns: %i[
240
+ lead__source
241
+ lead__client__name
242
+ ]
243
+ ).apply(
244
+ 'lead__source=google&lead__client__name=John'
245
+ )
246
+
247
+ expect(BharatFilterEngine::AssociationResolver)
248
+ .to have_received(:new)
249
+ .once
250
+ end
251
+
252
+ describe 'LIKE wildcard escaping' do
253
+ it 'treats a literal underscore as a literal character, not a wildcard' do
254
+ match = Sale.create!(stage: 'sale_2026')
255
+ Sale.create!(stage: 'saleX2026')
256
+
257
+ result =
258
+ described_class.new(
259
+ scope: Sale.all,
260
+ allowed_columns: [:stage]
261
+ ).apply('sale_2026')
262
+
263
+ expect(result).to contain_exactly(match)
264
+ end
265
+
266
+ it 'treats a literal percent sign as a literal character, not a wildcard' do
267
+ match = Sale.create!(stage: '50%off')
268
+ Sale.create!(stage: '50xoff')
269
+
270
+ result =
271
+ described_class.new(
272
+ scope: Sale.all,
273
+ allowed_columns: [:stage]
274
+ ).apply('50%off')
275
+
276
+ expect(result).to contain_exactly(match)
277
+ end
278
+
279
+ it 'escapes wildcards in field-based search too' do
280
+ match = Sale.create!(stage: 'sale_2026')
281
+ Sale.create!(stage: 'saleX2026')
282
+
283
+ result =
284
+ described_class.new(
285
+ scope: Sale.all,
286
+ allowed_columns: [:stage]
287
+ ).apply('stage=sale_2026')
288
+
289
+ expect(result).to contain_exactly(match)
290
+ end
291
+ end
157
292
  end
data/spec/spec_helper.rb CHANGED
@@ -1,14 +1,14 @@
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 'bundler/setup'
4
+ require 'active_record'
5
+ require 'database_cleaner/active_record'
6
+ require 'bharat_filter_engine'
7
+ require 'sqlite3'
8
8
 
9
9
  ActiveRecord::Base.establish_connection(
10
- adapter: "sqlite3",
11
- database: ":memory:"
10
+ adapter: 'sqlite3',
11
+ database: ':memory:'
12
12
  )
13
13
 
14
14
  ActiveRecord::Schema.define do
@@ -41,29 +41,24 @@ ActiveRecord::Schema.define do
41
41
  end
42
42
  end
43
43
 
44
-
45
44
  class Organization < ActiveRecord::Base
46
45
  has_many :clients
47
46
  end
48
47
 
49
-
50
48
  class Client < ActiveRecord::Base
51
49
  belongs_to :organization
52
50
  has_many :leads
53
51
  end
54
52
 
55
-
56
53
  class Lead < ActiveRecord::Base
57
54
  belongs_to :client
58
55
  has_many :sales
59
56
  end
60
57
 
61
-
62
58
  class Sale < ActiveRecord::Base
63
59
  belongs_to :lead
64
60
  end
65
61
 
66
-
67
62
  RSpec.configure do |config|
68
63
  config.before(:suite) do
69
64
  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.0pre
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-09-25 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: []
@@ -83,6 +85,7 @@ homepage: https://github.com/jayesh70240/bharat-filter-engine
83
85
  licenses:
84
86
  - MIT
85
87
  metadata:
88
+ homepage_uri: https://github.com/jayesh70240/bharat-filter-engine
86
89
  source_code_uri: https://github.com/jayesh70240/bharat-filter-engine
87
90
  changelog_uri: https://github.com/jayesh70240/bharat-filter-engine/blob/main/CHANGELOG.md
88
91
  bug_tracker_uri: https://github.com/jayesh70240/bharat-filter-engine/issues