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.
data/spec/engine_spec.rb CHANGED
@@ -1,7 +1,8 @@
1
- require "spec_helper"
1
+ # frozen_string_literal: true
2
2
 
3
- RSpec.describe BharatFilterEngine::Engine do
3
+ require 'spec_helper'
4
4
 
5
+ RSpec.describe BharatFilterEngine::Engine do
5
6
  # `let` (not `let!`) on purpose: these fixtures are only referenced
6
7
  # by name in the first few examples below ("applies string filter",
7
8
  # etc). Every other example in this file builds its own local
@@ -12,14 +13,14 @@ RSpec.describe BharatFilterEngine::Engine do
12
13
  # results whenever their filter happens to match it.
13
14
  let(:client) do
14
15
  Client.create!(
15
- name: "John Doe",
16
- email: "john@example.com"
16
+ name: 'John Doe',
17
+ email: 'john@example.com'
17
18
  )
18
19
  end
19
20
 
20
21
  let(:lead) do
21
22
  Lead.create!(
22
- source: "google",
23
+ source: 'google',
23
24
  client: client,
24
25
  created_at: Time.utc(2026, 7, 20)
25
26
  )
@@ -27,8 +28,8 @@ RSpec.describe BharatFilterEngine::Engine do
27
28
 
28
29
  let(:sale) do
29
30
  Sale.create!(
30
- stage: "qualified",
31
- approval_status: "approved",
31
+ stage: 'qualified',
32
+ approval_status: 'approved',
32
33
  project_id: 10,
33
34
  active: true,
34
35
  amount: 500,
@@ -37,10 +38,8 @@ RSpec.describe BharatFilterEngine::Engine do
37
38
  )
38
39
  end
39
40
 
40
- describe ".apply" do
41
-
42
- it "applies string filter" do
43
-
41
+ describe '.apply' do
42
+ it 'applies string filter' do
44
43
  config = {
45
44
  stage: {
46
45
  type: :single,
@@ -54,15 +53,14 @@ RSpec.describe BharatFilterEngine::Engine do
54
53
  scope: Sale.all,
55
54
  config: config,
56
55
  params: {
57
- stage: "qualified"
56
+ stage: 'qualified'
58
57
  }
59
58
  )
60
59
 
61
60
  expect(result).to contain_exactly(sale)
62
61
  end
63
62
 
64
- it "applies array filter" do
65
-
63
+ it 'applies array filter' do
66
64
  config = {
67
65
  status: {
68
66
  type: :single,
@@ -76,9 +74,9 @@ RSpec.describe BharatFilterEngine::Engine do
76
74
  scope: Sale.all,
77
75
  config: config,
78
76
  params: {
79
- status: [
80
- "approved",
81
- "pending"
77
+ status: %w[
78
+ approved
79
+ pending
82
80
  ]
83
81
  }
84
82
  )
@@ -86,8 +84,7 @@ RSpec.describe BharatFilterEngine::Engine do
86
84
  expect(result).to contain_exactly(sale)
87
85
  end
88
86
 
89
- it "applies boolean filter" do
90
-
87
+ it 'applies boolean filter' do
91
88
  config = {
92
89
  active: {
93
90
  type: :single,
@@ -101,15 +98,14 @@ RSpec.describe BharatFilterEngine::Engine do
101
98
  scope: Sale.all,
102
99
  config: config,
103
100
  params: {
104
- active: "true"
101
+ active: 'true'
105
102
  }
106
103
  )
107
104
 
108
105
  expect(result).to contain_exactly(sale)
109
106
  end
110
107
 
111
- it "applies integer gte filter" do
112
-
108
+ it 'applies integer gte filter' do
113
109
  config = {
114
110
  amount: {
115
111
  type: :single,
@@ -131,8 +127,7 @@ RSpec.describe BharatFilterEngine::Engine do
131
127
  expect(result).to contain_exactly(sale)
132
128
  end
133
129
 
134
- it "applies date range" do
135
-
130
+ it 'applies date range' do
136
131
  config = {
137
132
  actual_sale_date: {
138
133
  type: :single,
@@ -147,8 +142,8 @@ RSpec.describe BharatFilterEngine::Engine do
147
142
  config: config,
148
143
  params: {
149
144
  actual_sale_date: {
150
- from: "2026-07-15",
151
- to: "2026-07-30"
145
+ from: '2026-07-15',
146
+ to: '2026-07-30'
152
147
  }
153
148
  }
154
149
  )
@@ -156,8 +151,7 @@ RSpec.describe BharatFilterEngine::Engine do
156
151
  expect(result).to contain_exactly(sale)
157
152
  end
158
153
 
159
- it "applies nested filter" do
160
-
154
+ it 'applies nested filter' do
161
155
  config = {
162
156
  lead_source: {
163
157
  type: :nested,
@@ -172,723 +166,1335 @@ RSpec.describe BharatFilterEngine::Engine do
172
166
  scope: Sale.all,
173
167
  config: config,
174
168
  params: {
175
- lead_source: ["google"]
169
+ lead_source: ['google']
176
170
  }
177
171
  )
178
172
 
179
173
  expect(result).to contain_exactly(sale)
180
174
  end
181
175
 
182
- it "returns no records when string value does not match" do
183
- sale = Sale.create!(
184
- stage: "qualified"
185
- )
176
+ it 'returns no records when string value does not match' do
177
+ Sale.create!(
178
+ stage: 'qualified'
179
+ )
186
180
 
187
- config = {
188
- stage: {
189
- type: :single,
190
- filter_type: :string,
191
- dbcolumn: :stage
192
- }
181
+ config = {
182
+ stage: {
183
+ type: :single,
184
+ filter_type: :string,
185
+ dbcolumn: :stage
193
186
  }
187
+ }
194
188
 
195
- result = described_class.apply(
196
- scope: Sale.all,
197
- config: config,
198
- params: {
199
- stage: "closed"
200
- }
201
- )
189
+ result = described_class.apply(
190
+ scope: Sale.all,
191
+ config: config,
192
+ params: {
193
+ stage: 'closed'
194
+ }
195
+ )
202
196
 
203
- expect(result).to be_empty
197
+ expect(result).to be_empty
204
198
  end
205
199
 
206
- it "ignores blank string values" do
207
- sale = Sale.create!(
208
- stage: "qualified"
209
- )
200
+ it 'ignores blank string values' do
201
+ sale = Sale.create!(
202
+ stage: 'qualified'
203
+ )
210
204
 
211
- config = {
212
- stage: {
213
- type: :single,
214
- filter_type: :string,
215
- dbcolumn: :stage
216
- }
205
+ config = {
206
+ stage: {
207
+ type: :single,
208
+ filter_type: :string,
209
+ dbcolumn: :stage
217
210
  }
211
+ }
218
212
 
219
- result = described_class.apply(
220
- scope: Sale.all,
221
- config: config,
222
- params: {
223
- stage: ""
224
- }
225
- )
213
+ result = described_class.apply(
214
+ scope: Sale.all,
215
+ config: config,
216
+ params: {
217
+ stage: ''
218
+ }
219
+ )
226
220
 
227
- expect(result).to contain_exactly(sale)
221
+ expect(result).to contain_exactly(sale)
228
222
  end
229
223
 
230
- it "supports multiple array values" do
231
- sale_one = Sale.create!(
232
- approval_status: "approved"
233
- )
234
-
235
- sale_two = Sale.create!(
236
- approval_status: "pending"
237
- )
238
-
239
- Sale.create!(
240
- approval_status: "rejected"
241
- )
224
+ it 'ignores a nil filter value' do
225
+ sale = Sale.create!(
226
+ stage: 'qualified'
227
+ )
242
228
 
243
- config = {
244
- status: {
245
- type: :single,
246
- filter_type: :array,
247
- dbcolumn: :approval_status
248
- }
229
+ config = {
230
+ stage: {
231
+ type: :single,
232
+ filter_type: :string,
233
+ dbcolumn: :stage
249
234
  }
235
+ }
250
236
 
251
- result = described_class.apply(
252
- scope: Sale.all,
253
- config: config,
254
- params: {
255
- status: ["approved", "pending"]
256
- }
257
- )
237
+ result = described_class.apply(
238
+ scope: Sale.all,
239
+ config: config,
240
+ params: {
241
+ stage: nil
242
+ }
243
+ )
258
244
 
259
- expect(result).to contain_exactly(
260
- sale_one,
261
- sale_two
262
- )
245
+ expect(result).to contain_exactly(sale)
263
246
  end
264
247
 
265
- it "normalizes comma separated values into an array" do
266
- sale_one = Sale.create!(
267
- approval_status: "approved"
268
- )
269
-
270
- sale_two = Sale.create!(
271
- approval_status: "pending"
272
- )
248
+ it 'ignores an empty array filter value' do
249
+ sale = Sale.create!(
250
+ approval_status: 'approved'
251
+ )
273
252
 
274
- config = {
275
- status: {
276
- type: :single,
277
- filter_type: :array,
278
- dbcolumn: :approval_status
279
- }
253
+ config = {
254
+ status: {
255
+ type: :single,
256
+ filter_type: :array,
257
+ dbcolumn: :approval_status
280
258
  }
259
+ }
281
260
 
282
- result = described_class.apply(
283
- scope: Sale.all,
284
- config: config,
285
- params: {
286
- status: "approved,pending"
287
- }
288
- )
261
+ result = described_class.apply(
262
+ scope: Sale.all,
263
+ config: config,
264
+ params: {
265
+ status: []
266
+ }
267
+ )
289
268
 
290
- expect(result).to contain_exactly(
291
- sale_one,
292
- sale_two
293
- )
269
+ expect(result).to contain_exactly(sale)
294
270
  end
295
271
 
296
- it "filters boolean true values" do
297
- sale = Sale.create!(
298
- active: true
299
- )
300
-
301
- Sale.create!(
302
- active: false
303
- )
272
+ it 'returns the original scope unchanged for an empty config' do
273
+ sale = Sale.create!(
274
+ stage: 'qualified'
275
+ )
304
276
 
305
- config = {
306
- active: {
307
- type: :single,
308
- filter_type: :boolean,
309
- dbcolumn: :active
310
- }
277
+ result = described_class.apply(
278
+ scope: Sale.all,
279
+ config: {},
280
+ params: {
281
+ stage: 'qualified'
311
282
  }
283
+ )
312
284
 
313
- result = described_class.apply(
314
- scope: Sale.all,
315
- config: config,
316
- params: {
317
- active: "true"
318
- }
319
- )
320
-
321
- expect(result).to contain_exactly(sale)
285
+ expect(result).to contain_exactly(sale)
322
286
  end
323
287
 
324
- it "filters boolean false values" do
325
- Sale.create!(
326
- active: true
327
- )
288
+ it 'supports multiple array values' do
289
+ sale_one = Sale.create!(
290
+ approval_status: 'approved'
291
+ )
328
292
 
329
- sale = Sale.create!(
330
- active: false
331
- )
293
+ sale_two = Sale.create!(
294
+ approval_status: 'pending'
295
+ )
332
296
 
333
- config = {
334
- active: {
335
- type: :single,
336
- filter_type: :boolean,
337
- dbcolumn: :active
338
- }
297
+ Sale.create!(
298
+ approval_status: 'rejected'
299
+ )
300
+
301
+ config = {
302
+ status: {
303
+ type: :single,
304
+ filter_type: :array,
305
+ dbcolumn: :approval_status
339
306
  }
307
+ }
340
308
 
341
- result = described_class.apply(
342
- scope: Sale.all,
343
- config: config,
344
- params: {
345
- active: false
346
- }
347
- )
309
+ result = described_class.apply(
310
+ scope: Sale.all,
311
+ config: config,
312
+ params: {
313
+ status: %w[approved pending]
314
+ }
315
+ )
348
316
 
349
- expect(result).to contain_exactly(sale)
317
+ expect(result).to contain_exactly(
318
+ sale_one,
319
+ sale_two
320
+ )
350
321
  end
351
322
 
352
- it "filters exact integer values" do
353
- sale = Sale.create!(
354
- amount: 500
355
- )
323
+ it 'normalizes comma separated values into an array' do
324
+ sale_one = Sale.create!(
325
+ approval_status: 'approved'
326
+ )
356
327
 
357
- Sale.create!(
358
- amount: 1000
359
- )
328
+ sale_two = Sale.create!(
329
+ approval_status: 'pending'
330
+ )
360
331
 
361
- config = {
362
- amount: {
363
- type: :single,
364
- filter_type: :integer,
365
- dbcolumn: :amount
366
- }
332
+ config = {
333
+ status: {
334
+ type: :single,
335
+ filter_type: :array,
336
+ dbcolumn: :approval_status
367
337
  }
338
+ }
368
339
 
369
- result = described_class.apply(
370
- scope: Sale.all,
371
- config: config,
372
- params: {
373
- amount: "500"
374
- }
375
- )
340
+ result = described_class.apply(
341
+ scope: Sale.all,
342
+ config: config,
343
+ params: {
344
+ status: 'approved,pending'
345
+ }
346
+ )
376
347
 
377
- expect(result).to contain_exactly(sale)
348
+ expect(result).to contain_exactly(
349
+ sale_one,
350
+ sale_two
351
+ )
378
352
  end
379
353
 
380
- it "supports integer greater than or equal filter" do
381
- sale_one = Sale.create!(
382
- amount: 500
383
- )
384
-
385
- sale_two = Sale.create!(
386
- amount: 1000
387
- )
354
+ it 'filters boolean true values' do
355
+ sale = Sale.create!(
356
+ active: true
357
+ )
388
358
 
389
- Sale.create!(
390
- amount: 200
391
- )
359
+ Sale.create!(
360
+ active: false
361
+ )
392
362
 
393
- config = {
394
- amount: {
395
- type: :single,
396
- filter_type: :integer,
397
- dbcolumn: :amount,
398
- range_type: :gte
399
- }
363
+ config = {
364
+ active: {
365
+ type: :single,
366
+ filter_type: :boolean,
367
+ dbcolumn: :active
400
368
  }
369
+ }
401
370
 
402
- result = described_class.apply(
403
- scope: Sale.all,
404
- config: config,
405
- params: {
406
- amount: 500
407
- }
408
- )
371
+ result = described_class.apply(
372
+ scope: Sale.all,
373
+ config: config,
374
+ params: {
375
+ active: 'true'
376
+ }
377
+ )
409
378
 
410
- expect(result).to contain_exactly(
411
- sale_one,
412
- sale_two
413
- )
379
+ expect(result).to contain_exactly(sale)
414
380
  end
415
381
 
416
- it "supports integer less than or equal filter" do
417
- sale_one = Sale.create!(
418
- amount: 500
419
- )
420
-
421
- sale_two = Sale.create!(
422
- amount: 200
423
- )
382
+ it 'filters boolean false values' do
383
+ Sale.create!(
384
+ active: true
385
+ )
424
386
 
425
- Sale.create!(
426
- amount: 1000
427
- )
387
+ sale = Sale.create!(
388
+ active: false
389
+ )
428
390
 
429
- config = {
430
- amount: {
431
- type: :single,
432
- filter_type: :integer,
433
- dbcolumn: :amount,
434
- range_type: :lte
435
- }
391
+ config = {
392
+ active: {
393
+ type: :single,
394
+ filter_type: :boolean,
395
+ dbcolumn: :active
436
396
  }
397
+ }
437
398
 
438
- result = described_class.apply(
439
- scope: Sale.all,
440
- config: config,
441
- params: {
442
- amount: 500
443
- }
444
- )
399
+ result = described_class.apply(
400
+ scope: Sale.all,
401
+ config: config,
402
+ params: {
403
+ active: false
404
+ }
405
+ )
445
406
 
446
- expect(result).to contain_exactly(
447
- sale_one,
448
- sale_two
449
- )
407
+ expect(result).to contain_exactly(sale)
450
408
  end
451
409
 
452
- it "supports float filters" do
453
- sale = Sale.create!(
454
- conversion_rate: 12.5
455
- )
410
+ it 'filters exact integer values' do
411
+ sale = Sale.create!(
412
+ amount: 500
413
+ )
456
414
 
457
- Sale.create!(
458
- conversion_rate: 20.5
459
- )
415
+ Sale.create!(
416
+ amount: 1000
417
+ )
460
418
 
461
- config = {
462
- conversion_rate: {
463
- type: :single,
464
- filter_type: :float,
465
- dbcolumn: :conversion_rate
466
- }
419
+ config = {
420
+ amount: {
421
+ type: :single,
422
+ filter_type: :integer,
423
+ dbcolumn: :amount
467
424
  }
425
+ }
468
426
 
469
- result = described_class.apply(
470
- scope: Sale.all,
471
- config: config,
472
- params: {
473
- conversion_rate: "12.5"
474
- }
475
- )
427
+ result = described_class.apply(
428
+ scope: Sale.all,
429
+ config: config,
430
+ params: {
431
+ amount: '500'
432
+ }
433
+ )
476
434
 
477
- expect(result).to contain_exactly(sale)
435
+ expect(result).to contain_exactly(sale)
478
436
  end
479
437
 
480
- it "supports float filters" do
481
- sale = Sale.create!(
482
- conversion_rate: 12.5
483
- )
438
+ it 'filters an exact integer value of zero' do
439
+ sale = Sale.create!(
440
+ amount: 0
441
+ )
484
442
 
485
- Sale.create!(
486
- conversion_rate: 20.5
487
- )
443
+ Sale.create!(
444
+ amount: 500
445
+ )
488
446
 
489
- config = {
490
- conversion_rate: {
491
- type: :single,
492
- filter_type: :float,
493
- dbcolumn: :conversion_rate
494
- }
447
+ config = {
448
+ amount: {
449
+ type: :single,
450
+ filter_type: :integer,
451
+ dbcolumn: :amount
495
452
  }
453
+ }
496
454
 
497
- result = described_class.apply(
498
- scope: Sale.all,
499
- config: config,
500
- params: {
501
- conversion_rate: "12.5"
502
- }
503
- )
455
+ result = described_class.apply(
456
+ scope: Sale.all,
457
+ config: config,
458
+ params: {
459
+ amount: 0
460
+ }
461
+ )
504
462
 
505
- expect(result).to contain_exactly(sale)
463
+ expect(result).to contain_exactly(sale)
506
464
  end
507
465
 
508
- it "supports float greater than or equal filter" do
509
- sale_one = Sale.create!(
510
- conversion_rate: 10.5
511
- )
466
+ it 'supports integer greater than or equal filter' do
467
+ sale_one = Sale.create!(
468
+ amount: 500
469
+ )
512
470
 
513
- sale_two = Sale.create!(
514
- conversion_rate: 20.5
515
- )
471
+ sale_two = Sale.create!(
472
+ amount: 1000
473
+ )
516
474
 
517
- Sale.create!(
518
- conversion_rate: 5.5
519
- )
475
+ Sale.create!(
476
+ amount: 200
477
+ )
520
478
 
521
- config = {
522
- conversion_rate: {
523
- type: :single,
524
- filter_type: :float,
525
- dbcolumn: :conversion_rate,
526
- range_type: :gte
527
- }
479
+ config = {
480
+ amount: {
481
+ type: :single,
482
+ filter_type: :integer,
483
+ dbcolumn: :amount,
484
+ range_type: :gte
528
485
  }
486
+ }
529
487
 
530
- result = described_class.apply(
531
- scope: Sale.all,
532
- config: config,
533
- params: {
534
- conversion_rate: 10.5
535
- }
536
- )
488
+ result = described_class.apply(
489
+ scope: Sale.all,
490
+ config: config,
491
+ params: {
492
+ amount: 500
493
+ }
494
+ )
537
495
 
538
- expect(result).to contain_exactly(
539
- sale_one,
540
- sale_two
541
- )
496
+ expect(result).to contain_exactly(
497
+ sale_one,
498
+ sale_two
499
+ )
542
500
  end
543
501
 
544
- it "filters between two dates" do
545
- sale = Sale.create!(
546
- actual_sale_date: Time.utc(2026, 7, 20)
547
- )
502
+ it 'supports integer less than or equal filter' do
503
+ sale_one = Sale.create!(
504
+ amount: 500
505
+ )
548
506
 
549
- Sale.create!(
550
- actual_sale_date: Time.utc(2026, 8, 20)
551
- )
507
+ sale_two = Sale.create!(
508
+ amount: 200
509
+ )
552
510
 
553
- config = {
554
- actual_sale_date: {
555
- type: :single,
556
- filter_type: :daterange,
557
- dbcolumn: :actual_sale_date
558
- }
511
+ Sale.create!(
512
+ amount: 1000
513
+ )
514
+
515
+ config = {
516
+ amount: {
517
+ type: :single,
518
+ filter_type: :integer,
519
+ dbcolumn: :amount,
520
+ range_type: :lte
559
521
  }
522
+ }
560
523
 
561
- result = described_class.apply(
562
- scope: Sale.all,
563
- config: config,
564
- params: {
565
- actual_sale_date: {
566
- from: "2026-07-01",
567
- to: "2026-07-31"
568
- }
569
- }
570
- )
524
+ result = described_class.apply(
525
+ scope: Sale.all,
526
+ config: config,
527
+ params: {
528
+ amount: 500
529
+ }
530
+ )
571
531
 
572
- expect(result).to contain_exactly(sale)
532
+ expect(result).to contain_exactly(
533
+ sale_one,
534
+ sale_two
535
+ )
573
536
  end
574
537
 
575
- it "supports date range with only from date" do
576
- sale = Sale.create!(
577
- actual_sale_date: Time.utc(2026, 8, 10)
578
- )
538
+ it 'supports integer between filter with a hash' do
539
+ sale_one = Sale.create!(amount: 500)
540
+ sale_two = Sale.create!(amount: 800)
579
541
 
580
- Sale.create!(
581
- actual_sale_date: Time.utc(2026, 7, 10)
582
- )
542
+ Sale.create!(amount: 100)
543
+ Sale.create!(amount: 1500)
583
544
 
584
- config = {
585
- actual_sale_date: {
586
- type: :single,
587
- filter_type: :daterange,
588
- dbcolumn: :actual_sale_date
589
- }
545
+ config = {
546
+ amount: {
547
+ type: :single,
548
+ filter_type: :integer,
549
+ dbcolumn: :amount,
550
+ range_type: :between
590
551
  }
552
+ }
591
553
 
592
- result = described_class.apply(
593
- scope: Sale.all,
594
- config: config,
595
- params: {
596
- actual_sale_date: {
597
- from: "2026-08-01"
598
- }
599
- }
600
- )
554
+ result = described_class.apply(
555
+ scope: Sale.all,
556
+ config: config,
557
+ params: {
558
+ amount: { from: 300, to: 1000 }
559
+ }
560
+ )
601
561
 
602
- expect(result).to contain_exactly(sale)
562
+ expect(result).to contain_exactly(
563
+ sale_one,
564
+ sale_two
565
+ )
603
566
  end
604
567
 
605
- it "supports date range with only to date" do
606
- sale = Sale.create!(
607
- actual_sale_date: Time.utc(2026, 7, 10)
608
- )
568
+ it 'supports integer between filter with an array' do
569
+ sale_one = Sale.create!(amount: 500)
570
+ sale_two = Sale.create!(amount: 800)
571
+
572
+ Sale.create!(amount: 100)
573
+
574
+ config = {
575
+ amount: {
576
+ type: :single,
577
+ filter_type: :integer,
578
+ dbcolumn: :amount,
579
+ range_type: :between
580
+ }
581
+ }
582
+
583
+ result = described_class.apply(
584
+ scope: Sale.all,
585
+ config: config,
586
+ params: {
587
+ amount: [300, 1000]
588
+ }
589
+ )
590
+
591
+ expect(result).to contain_exactly(
592
+ sale_one,
593
+ sale_two
594
+ )
595
+ end
596
+
597
+ it 'supports integer between filter with only a lower bound' do
598
+ sale_one = Sale.create!(amount: 500)
599
+ sale_two = Sale.create!(amount: 800)
600
+
601
+ Sale.create!(amount: 100)
602
+
603
+ config = {
604
+ amount: {
605
+ type: :single,
606
+ filter_type: :integer,
607
+ dbcolumn: :amount,
608
+ range_type: :between
609
+ }
610
+ }
611
+
612
+ result = described_class.apply(
613
+ scope: Sale.all,
614
+ config: config,
615
+ params: {
616
+ amount: { from: 300 }
617
+ }
618
+ )
619
+
620
+ expect(result).to contain_exactly(
621
+ sale_one,
622
+ sale_two
623
+ )
624
+ end
625
+
626
+ it 'supports float between filter' do
627
+ sale = Sale.create!(conversion_rate: 12.5)
628
+
629
+ Sale.create!(conversion_rate: 1.0)
630
+ Sale.create!(conversion_rate: 99.0)
631
+
632
+ config = {
633
+ conversion_rate: {
634
+ type: :single,
635
+ filter_type: :float,
636
+ dbcolumn: :conversion_rate,
637
+ range_type: :between
638
+ }
639
+ }
640
+
641
+ result = described_class.apply(
642
+ scope: Sale.all,
643
+ config: config,
644
+ params: {
645
+ conversion_rate: { from: 10.0, to: 15.0 }
646
+ }
647
+ )
648
+
649
+ expect(result).to contain_exactly(sale)
650
+ end
651
+
652
+ it 'supports float filters' do
653
+ sale = Sale.create!(
654
+ conversion_rate: 12.5
655
+ )
609
656
 
610
- Sale.create!(
611
- actual_sale_date: Time.utc(2026, 8, 10)
657
+ Sale.create!(
658
+ conversion_rate: 20.5
659
+ )
660
+
661
+ config = {
662
+ conversion_rate: {
663
+ type: :single,
664
+ filter_type: :float,
665
+ dbcolumn: :conversion_rate
666
+ }
667
+ }
668
+
669
+ result = described_class.apply(
670
+ scope: Sale.all,
671
+ config: config,
672
+ params: {
673
+ conversion_rate: '12.5'
674
+ }
675
+ )
676
+
677
+ expect(result).to contain_exactly(sale)
678
+ end
679
+
680
+ it 'supports float greater than or equal filter' do
681
+ sale_one = Sale.create!(
682
+ conversion_rate: 10.5
683
+ )
684
+
685
+ sale_two = Sale.create!(
686
+ conversion_rate: 20.5
687
+ )
688
+
689
+ Sale.create!(
690
+ conversion_rate: 5.5
691
+ )
692
+
693
+ config = {
694
+ conversion_rate: {
695
+ type: :single,
696
+ filter_type: :float,
697
+ dbcolumn: :conversion_rate,
698
+ range_type: :gte
699
+ }
700
+ }
701
+
702
+ result = described_class.apply(
703
+ scope: Sale.all,
704
+ config: config,
705
+ params: {
706
+ conversion_rate: 10.5
707
+ }
708
+ )
709
+
710
+ expect(result).to contain_exactly(
711
+ sale_one,
712
+ sale_two
713
+ )
714
+ end
715
+
716
+ it 'filters between two dates' do
717
+ sale = Sale.create!(
718
+ actual_sale_date: Time.utc(2026, 7, 20)
719
+ )
720
+
721
+ Sale.create!(
722
+ actual_sale_date: Time.utc(2026, 8, 20)
723
+ )
724
+
725
+ config = {
726
+ actual_sale_date: {
727
+ type: :single,
728
+ filter_type: :daterange,
729
+ dbcolumn: :actual_sale_date
730
+ }
731
+ }
732
+
733
+ result = described_class.apply(
734
+ scope: Sale.all,
735
+ config: config,
736
+ params: {
737
+ actual_sale_date: {
738
+ from: '2026-07-01',
739
+ to: '2026-07-31'
740
+ }
741
+ }
742
+ )
743
+
744
+ expect(result).to contain_exactly(sale)
745
+ end
746
+
747
+ it 'supports date range with only from date' do
748
+ sale = Sale.create!(
749
+ actual_sale_date: Time.utc(2026, 8, 10)
750
+ )
751
+
752
+ Sale.create!(
753
+ actual_sale_date: Time.utc(2026, 7, 10)
754
+ )
755
+
756
+ config = {
757
+ actual_sale_date: {
758
+ type: :single,
759
+ filter_type: :daterange,
760
+ dbcolumn: :actual_sale_date
761
+ }
762
+ }
763
+
764
+ result = described_class.apply(
765
+ scope: Sale.all,
766
+ config: config,
767
+ params: {
768
+ actual_sale_date: {
769
+ from: '2026-08-01'
770
+ }
771
+ }
772
+ )
773
+
774
+ expect(result).to contain_exactly(sale)
775
+ end
776
+
777
+ it 'supports date range with only to date' do
778
+ sale = Sale.create!(
779
+ actual_sale_date: Time.utc(2026, 7, 10)
780
+ )
781
+
782
+ Sale.create!(
783
+ actual_sale_date: Time.utc(2026, 8, 10)
784
+ )
785
+
786
+ config = {
787
+ actual_sale_date: {
788
+ type: :single,
789
+ filter_type: :daterange,
790
+ dbcolumn: :actual_sale_date
791
+ }
792
+ }
793
+
794
+ result = described_class.apply(
795
+ scope: Sale.all,
796
+ config: config,
797
+ params: {
798
+ actual_sale_date: {
799
+ to: '2026-07-31'
800
+ }
801
+ }
802
+ )
803
+
804
+ expect(result).to contain_exactly(sale)
805
+ end
806
+
807
+ it 'supports date range array format' do
808
+ sale = Sale.create!(
809
+ actual_sale_date: Time.utc(2026, 7, 20)
810
+ )
811
+
812
+ config = {
813
+ actual_sale_date: {
814
+ type: :single,
815
+ filter_type: :daterange,
816
+ dbcolumn: :actual_sale_date
817
+ }
818
+ }
819
+
820
+ result = described_class.apply(
821
+ scope: Sale.all,
822
+ config: config,
823
+ params: {
824
+ actual_sale_date: %w[
825
+ 2026-07-01
826
+ 2026-07-31
827
+ ]
828
+ }
829
+ )
830
+
831
+ expect(result).to contain_exactly(sale)
832
+ end
833
+
834
+ it 'supports nested string filter' do
835
+ client = Client.create!(
836
+ name: 'John'
837
+ )
838
+
839
+ lead = Lead.create!(
840
+ source: 'google',
841
+ client: client
842
+ )
843
+
844
+ sale = Sale.create!(
845
+ lead: lead
846
+ )
847
+
848
+ config = {
849
+ lead_source: {
850
+ type: :nested,
851
+ filter_type: :string,
852
+ association: :lead,
853
+ dbcolumn: :source
854
+ }
855
+ }
856
+
857
+ result = described_class.apply(
858
+ scope: Sale.all,
859
+ config: config,
860
+ params: {
861
+ lead_source: 'google'
862
+ }
863
+ )
864
+
865
+ expect(result).to contain_exactly(sale)
866
+ end
867
+
868
+ it 'supports nested array filter' do
869
+ client = Client.create!(
870
+ name: 'John'
871
+ )
872
+
873
+ lead_one = Lead.create!(
874
+ source: 'google',
875
+ client: client
876
+ )
877
+
878
+ lead_two = Lead.create!(
879
+ source: 'referral',
880
+ client: client
881
+ )
882
+
883
+ sale_one = Sale.create!(lead: lead_one)
884
+ sale_two = Sale.create!(lead: lead_two)
885
+
886
+ config = {
887
+ lead_source: {
888
+ type: :nested,
889
+ filter_type: :array,
890
+ association: :lead,
891
+ dbcolumn: :source
892
+ }
893
+ }
894
+
895
+ result = described_class.apply(
896
+ scope: Sale.all,
897
+ config: config,
898
+ params: {
899
+ lead_source: %w[google referral]
900
+ }
901
+ )
902
+
903
+ expect(result).to contain_exactly(
904
+ sale_one,
905
+ sale_two
906
+ )
907
+ end
908
+
909
+ it "raises InvalidAssociationError when a nested rule's association cannot be resolved" do
910
+ config = {
911
+ lead_source: {
912
+ type: :nested,
913
+ filter_type: :string,
914
+ association: :not_a_real_association,
915
+ dbcolumn: :source
916
+ }
917
+ }
918
+
919
+ expect do
920
+ described_class.apply(
921
+ scope: Sale.all,
922
+ config: config,
923
+ params: { lead_source: 'google' }
612
924
  )
925
+ end.to raise_error(BharatFilterEngine::InvalidAssociationError)
926
+ end
927
+
928
+ it 'applies multiple filters together' do
929
+ sale_one = Sale.create!(
930
+ stage: 'qualified',
931
+ approval_status: 'approved',
932
+ amount: 500
933
+ )
934
+
935
+ Sale.create!(
936
+ stage: 'qualified',
937
+ approval_status: 'pending',
938
+ amount: 500
939
+ )
940
+
941
+ Sale.create!(
942
+ stage: 'new',
943
+ approval_status: 'approved',
944
+ amount: 500
945
+ )
946
+
947
+ config = {
948
+ stage: {
949
+ type: :single,
950
+ filter_type: :string,
951
+ dbcolumn: :stage
952
+ },
953
+
954
+ status: {
955
+ type: :single,
956
+ filter_type: :array,
957
+ dbcolumn: :approval_status
958
+ }
959
+ }
960
+
961
+ result = described_class.apply(
962
+ scope: Sale.all,
963
+ config: config,
964
+ params: {
965
+ stage: 'qualified',
966
+ status: ['approved']
967
+ }
968
+ )
969
+
970
+ expect(result).to contain_exactly(sale_one)
971
+ end
972
+
973
+ it 'preserves existing scope conditions' do
974
+ sale_one = Sale.create!(
975
+ stage: 'qualified',
976
+ active: true
977
+ )
978
+
979
+ Sale.create!(
980
+ stage: 'qualified',
981
+ active: false
982
+ )
983
+
984
+ base_scope =
985
+ Sale.where(active: true)
986
+
987
+ config = {
988
+ stage: {
989
+ type: :single,
990
+ filter_type: :string,
991
+ dbcolumn: :stage
992
+ }
993
+ }
994
+
995
+ result = described_class.apply(
996
+ scope: base_scope,
997
+ config: config,
998
+ params: {
999
+ stage: 'qualified'
1000
+ }
1001
+ )
1002
+
1003
+ expect(result).to contain_exactly(
1004
+ sale_one
1005
+ )
1006
+ end
1007
+
1008
+ it 'returns the original scope when no filters are provided' do
1009
+ sale = Sale.create!(
1010
+ stage: 'qualified'
1011
+ )
1012
+
1013
+ config = {
1014
+ stage: {
1015
+ type: :single,
1016
+ filter_type: :string,
1017
+ dbcolumn: :stage
1018
+ }
1019
+ }
1020
+
1021
+ result = described_class.apply(
1022
+ scope: Sale.all,
1023
+ config: config,
1024
+ params: {}
1025
+ )
1026
+
1027
+ expect(result).to contain_exactly(sale)
1028
+ end
1029
+
1030
+ it 'ignores unknown params that are not present in config' do
1031
+ sale = Sale.create!(
1032
+ stage: 'qualified'
1033
+ )
1034
+
1035
+ config = {
1036
+ stage: {
1037
+ type: :single,
1038
+ filter_type: :string,
1039
+ dbcolumn: :stage
1040
+ }
1041
+ }
1042
+
1043
+ result = described_class.apply(
1044
+ scope: Sale.all,
1045
+ config: config,
1046
+ params: {
1047
+ unknown_field: 'something'
1048
+ }
1049
+ )
1050
+
1051
+ expect(result).to contain_exactly(sale)
1052
+ end
1053
+
1054
+ it 'parses JSON array values' do
1055
+ sale_one = Sale.create!(
1056
+ approval_status: 'approved'
1057
+ )
1058
+
1059
+ sale_two = Sale.create!(
1060
+ approval_status: 'pending'
1061
+ )
1062
+
1063
+ config = {
1064
+ status: {
1065
+ type: :single,
1066
+ filter_type: :array,
1067
+ dbcolumn: :approval_status
1068
+ }
1069
+ }
1070
+
1071
+ result = described_class.apply(
1072
+ scope: Sale.all,
1073
+ config: config,
1074
+ params: {
1075
+ status: '["approved","pending"]'
1076
+ }
1077
+ )
1078
+
1079
+ expect(result).to contain_exactly(
1080
+ sale_one,
1081
+ sale_two
1082
+ )
1083
+ end
1084
+
1085
+ it 'supports a config-driven search filter' do
1086
+ client = Client.create!(name: 'John')
1087
+ lead = Lead.create!(source: 'google', client: client)
1088
+ sale = Sale.create!(stage: 'qualified', lead: lead)
1089
+
1090
+ Sale.create!(stage: 'new')
1091
+
1092
+ config = {
1093
+ q: {
1094
+ type: :single,
1095
+ filter_type: :search,
1096
+ dbcolumns: %i[stage lead__source]
1097
+ }
1098
+ }
1099
+
1100
+ result = described_class.apply(
1101
+ scope: Sale.all,
1102
+ config: config,
1103
+ params: { q: 'google' }
1104
+ )
1105
+
1106
+ expect(result).to contain_exactly(sale)
1107
+ end
1108
+
1109
+ describe 'negate' do
1110
+ it 'excludes matching string values' do
1111
+ sale = Sale.create!(stage: 'new')
1112
+ Sale.create!(stage: 'qualified')
613
1113
 
614
1114
  config = {
615
- actual_sale_date: {
1115
+ stage: {
616
1116
  type: :single,
617
- filter_type: :daterange,
618
- dbcolumn: :actual_sale_date
619
- }
1117
+ filter_type: :string,
1118
+ dbcolumn: :stage,
1119
+ negate: true
1120
+ }
620
1121
  }
621
1122
 
622
1123
  result = described_class.apply(
623
- scope: Sale.all,
624
- config: config,
625
- params: {
626
- actual_sale_date: {
627
- to: "2026-07-31"
628
- }
629
- }
1124
+ scope: Sale.all,
1125
+ config: config,
1126
+ params: { stage: 'qualified' }
630
1127
  )
631
1128
 
632
1129
  expect(result).to contain_exactly(sale)
633
- end
1130
+ end
634
1131
 
635
- it "supports date range array format" do
636
- sale = Sale.create!(
637
- actual_sale_date: Time.utc(2026, 7, 20)
638
- )
1132
+ it 'excludes matching array values' do
1133
+ sale = Sale.create!(approval_status: 'rejected')
1134
+ Sale.create!(approval_status: 'approved')
1135
+ Sale.create!(approval_status: 'pending')
639
1136
 
640
1137
  config = {
641
- actual_sale_date: {
1138
+ status: {
642
1139
  type: :single,
643
- filter_type: :daterange,
644
- dbcolumn: :actual_sale_date
645
- }
1140
+ filter_type: :array,
1141
+ dbcolumn: :approval_status,
1142
+ negate: true
1143
+ }
646
1144
  }
647
1145
 
648
1146
  result = described_class.apply(
649
- scope: Sale.all,
650
- config: config,
651
- params: {
652
- actual_sale_date: [
653
- "2026-07-01",
654
- "2026-07-31"
655
- ]
656
- }
1147
+ scope: Sale.all,
1148
+ config: config,
1149
+ params: { status: %w[approved pending] }
657
1150
  )
658
1151
 
659
1152
  expect(result).to contain_exactly(sale)
660
- end
1153
+ end
661
1154
 
662
- it "supports nested string filter" do
663
- client = Client.create!(
664
- name: "John"
665
- )
1155
+ it 'excludes matching boolean values' do
1156
+ sale = Sale.create!(active: false)
1157
+ Sale.create!(active: true)
1158
+
1159
+ config = {
1160
+ active: {
1161
+ type: :single,
1162
+ filter_type: :boolean,
1163
+ dbcolumn: :active,
1164
+ negate: true
1165
+ }
1166
+ }
666
1167
 
667
- lead = Lead.create!(
668
- source: "google",
669
- client: client
1168
+ result = described_class.apply(
1169
+ scope: Sale.all,
1170
+ config: config,
1171
+ params: { active: 'true' }
670
1172
  )
671
1173
 
672
- sale = Sale.create!(
673
- lead: lead
1174
+ expect(result).to contain_exactly(sale)
1175
+ end
1176
+
1177
+ it 'excludes an exact integer match' do
1178
+ sale = Sale.create!(amount: 1000)
1179
+ Sale.create!(amount: 500)
1180
+
1181
+ config = {
1182
+ amount: {
1183
+ type: :single,
1184
+ filter_type: :integer,
1185
+ dbcolumn: :amount,
1186
+ negate: true
1187
+ }
1188
+ }
1189
+
1190
+ result = described_class.apply(
1191
+ scope: Sale.all,
1192
+ config: config,
1193
+ params: { amount: 500 }
674
1194
  )
675
1195
 
1196
+ expect(result).to contain_exactly(sale)
1197
+ end
1198
+
1199
+ it 'works on a nested (associated) column' do
1200
+ client = Client.create!(name: 'John')
1201
+ lead_google = Lead.create!(source: 'google', client: client)
1202
+ lead_referral = Lead.create!(source: 'referral', client: client)
1203
+
1204
+ sale = Sale.create!(lead: lead_referral)
1205
+ Sale.create!(lead: lead_google)
1206
+
676
1207
  config = {
677
- lead_source: {
1208
+ lead_source: {
678
1209
  type: :nested,
679
1210
  filter_type: :string,
680
1211
  association: :lead,
681
- dbcolumn: :source
682
- }
1212
+ dbcolumn: :source,
1213
+ negate: true
1214
+ }
683
1215
  }
684
1216
 
685
1217
  result = described_class.apply(
686
- scope: Sale.all,
687
- config: config,
688
- params: {
689
- lead_source: "google"
690
- }
1218
+ scope: Sale.all,
1219
+ config: config,
1220
+ params: { lead_source: 'google' }
691
1221
  )
692
1222
 
693
1223
  expect(result).to contain_exactly(sale)
694
- end
1224
+ end
695
1225
 
696
- it "supports nested array filter" do
697
- client = Client.create!(
698
- name: "John"
699
- )
1226
+ it 'excludes matching array values on a nested (associated) column' do
1227
+ client = Client.create!(name: 'John')
1228
+ lead_google = Lead.create!(source: 'google', client: client)
1229
+ lead_referral = Lead.create!(source: 'referral', client: client)
1230
+ lead_direct = Lead.create!(source: 'direct', client: client)
700
1231
 
701
- lead_one = Lead.create!(
702
- source: "google",
703
- client: client
704
- )
705
-
706
- lead_two = Lead.create!(
707
- source: "referral",
708
- client: client
709
- )
710
-
711
- sale_one = Sale.create!(lead: lead_one)
712
- sale_two = Sale.create!(lead: lead_two)
1232
+ sale = Sale.create!(lead: lead_direct)
1233
+ Sale.create!(lead: lead_google)
1234
+ Sale.create!(lead: lead_referral)
713
1235
 
714
1236
  config = {
715
- lead_source: {
1237
+ lead_source: {
716
1238
  type: :nested,
717
1239
  filter_type: :array,
718
1240
  association: :lead,
719
- dbcolumn: :source
720
- }
1241
+ dbcolumn: :source,
1242
+ negate: true
1243
+ }
721
1244
  }
722
1245
 
723
1246
  result = described_class.apply(
724
- scope: Sale.all,
725
- config: config,
726
- params: {
727
- lead_source: ["google", "referral"]
728
- }
729
- )
730
-
731
- expect(result).to contain_exactly(
732
- sale_one,
733
- sale_two
734
- )
735
- end
736
-
737
- it "applies multiple filters together" do
738
- sale_one = Sale.create!(
739
- stage: "qualified",
740
- approval_status: "approved",
741
- amount: 500
1247
+ scope: Sale.all,
1248
+ config: config,
1249
+ params: { lead_source: %w[google referral] }
742
1250
  )
743
1251
 
744
- Sale.create!(
745
- stage: "qualified",
746
- approval_status: "pending",
747
- amount: 500
748
- )
1252
+ expect(result).to contain_exactly(sale)
1253
+ end
749
1254
 
750
- Sale.create!(
751
- stage: "new",
752
- approval_status: "approved",
753
- amount: 500
754
- )
1255
+ it 'excludes an array value that itself contains nil' do
1256
+ sale = Sale.create!(approval_status: 'rejected')
1257
+ Sale.create!(approval_status: 'approved')
1258
+ Sale.create!(approval_status: nil)
755
1259
 
756
1260
  config = {
757
- stage: {
758
- type: :single,
759
- filter_type: :string,
760
- dbcolumn: :stage
761
- },
762
-
763
- status: {
1261
+ status: {
764
1262
  type: :single,
765
1263
  filter_type: :array,
766
- dbcolumn: :approval_status
767
- }
1264
+ dbcolumn: :approval_status,
1265
+ negate: true
1266
+ }
768
1267
  }
769
1268
 
770
1269
  result = described_class.apply(
771
- scope: Sale.all,
772
- config: config,
773
- params: {
774
- stage: "qualified",
775
- status: ["approved"]
776
- }
1270
+ scope: Sale.all,
1271
+ config: config,
1272
+ params: { status: [nil, 'approved'] }
777
1273
  )
778
1274
 
779
- expect(result).to contain_exactly(sale_one)
1275
+ expect(result).to contain_exactly(sale)
1276
+ end
780
1277
  end
781
1278
 
782
- it "preserves existing scope conditions" do
783
- sale_one = Sale.create!(
784
- stage: "qualified",
785
- active: true
786
- )
1279
+ describe 'presence filter' do
1280
+ it 'returns records where the column is present when value is true' do
1281
+ sale = Sale.create!(stage: 'qualified')
1282
+ Sale.create!(stage: nil)
1283
+ Sale.create!(stage: '')
1284
+
1285
+ config = {
1286
+ has_stage: {
1287
+ type: :single,
1288
+ filter_type: :presence,
1289
+ dbcolumn: :stage
1290
+ }
1291
+ }
787
1292
 
788
- Sale.create!(
789
- stage: "qualified",
790
- active: false
1293
+ result = described_class.apply(
1294
+ scope: Sale.all,
1295
+ config: config,
1296
+ params: { has_stage: 'true' }
791
1297
  )
792
1298
 
793
- base_scope =
794
- Sale.where(active: true)
1299
+ expect(result).to contain_exactly(sale)
1300
+ end
1301
+
1302
+ it 'returns records where the column is blank when value is false' do
1303
+ Sale.create!(stage: 'qualified')
1304
+ sale_nil = Sale.create!(stage: nil)
1305
+ sale_blank = Sale.create!(stage: '')
795
1306
 
796
1307
  config = {
797
- stage: {
1308
+ has_stage: {
798
1309
  type: :single,
799
- filter_type: :string,
1310
+ filter_type: :presence,
800
1311
  dbcolumn: :stage
801
- }
1312
+ }
802
1313
  }
803
1314
 
804
1315
  result = described_class.apply(
805
- scope: base_scope,
806
- config: config,
807
- params: {
808
- stage: "qualified"
809
- }
1316
+ scope: Sale.all,
1317
+ config: config,
1318
+ params: { has_stage: 'false' }
810
1319
  )
811
1320
 
812
1321
  expect(result).to contain_exactly(
813
- sale_one
1322
+ sale_nil,
1323
+ sale_blank
814
1324
  )
815
- end
1325
+ end
816
1326
 
817
- it "returns the original scope when no filters are provided" do
818
- sale = Sale.create!(
819
- stage: "qualified"
1327
+ it 'works on a nested (associated) column' do
1328
+ client_with_org = Client.create!(
1329
+ name: 'John',
1330
+ organization: Organization.create!(name: 'Acme')
820
1331
  )
821
1332
 
1333
+ client_without_org = Client.create!(name: 'Jane')
1334
+
1335
+ lead_one = Lead.create!(source: 'google', client: client_with_org)
1336
+ lead_two = Lead.create!(source: 'referral', client: client_without_org)
1337
+
1338
+ sale_with_org = Sale.create!(lead: lead_one)
1339
+ Sale.create!(lead: lead_two)
1340
+
822
1341
  config = {
823
- stage: {
824
- type: :single,
825
- filter_type: :string,
826
- dbcolumn: :stage
827
- }
1342
+ has_organization: {
1343
+ type: :nested,
1344
+ filter_type: :presence,
1345
+ association: :lead__client,
1346
+ dbcolumn: :organization_id
1347
+ }
828
1348
  }
829
1349
 
830
1350
  result = described_class.apply(
831
- scope: Sale.all,
832
- config: config,
833
- params: {}
1351
+ scope: Sale.all,
1352
+ config: config,
1353
+ params: { has_organization: 'true' }
834
1354
  )
835
1355
 
836
- expect(result).to contain_exactly(sale)
1356
+ expect(result).to contain_exactly(sale_with_org)
1357
+ end
837
1358
  end
838
1359
 
839
- it "ignores unknown params that are not present in config" do
840
- sale = Sale.create!(
841
- stage: "qualified"
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' }
842
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)
843
1409
 
844
1410
  config = {
845
- stage: {
846
- type: :single,
847
- filter_type: :string,
848
- dbcolumn: :stage
849
- }
1411
+ has_client: { type: :nested, filter_type: :presence, association: :lead, dbcolumn: :client_id }
850
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: '')
851
1423
 
852
1424
  result = described_class.apply(
853
- scope: Sale.all,
854
- config: config,
855
- params: {
856
- unknown_field: "something"
857
- }
1425
+ scope: Sale.all, config: presence_config(:stage), params: { has_value: true }
858
1426
  )
859
-
860
1427
  expect(result).to contain_exactly(sale)
1428
+ end
861
1429
  end
862
1430
 
863
- it "parses JSON array values" do
864
- sale_one = Sale.create!(
865
- approval_status: "approved"
1431
+ describe 'invalid numeric input' do
1432
+ it 'ignores a non-numeric exact integer filter value' do
1433
+ sale = Sale.create!(amount: 500)
1434
+
1435
+ config = {
1436
+ amount: {
1437
+ type: :single,
1438
+ filter_type: :integer,
1439
+ dbcolumn: :amount
1440
+ }
1441
+ }
1442
+
1443
+ result = described_class.apply(
1444
+ scope: Sale.all,
1445
+ config: config,
1446
+ params: { amount: 'abc' }
866
1447
  )
867
1448
 
868
- sale_two = Sale.create!(
869
- approval_status: "pending"
1449
+ expect(result).to contain_exactly(sale)
1450
+ end
1451
+
1452
+ it 'ignores a non-numeric gte filter value' do
1453
+ sale = Sale.create!(amount: 500)
1454
+
1455
+ config = {
1456
+ amount: {
1457
+ type: :single,
1458
+ filter_type: :integer,
1459
+ dbcolumn: :amount,
1460
+ range_type: :gte
1461
+ }
1462
+ }
1463
+
1464
+ result = described_class.apply(
1465
+ scope: Sale.all,
1466
+ config: config,
1467
+ params: { amount: 'not-a-number' }
870
1468
  )
871
1469
 
1470
+ expect(result).to contain_exactly(sale)
1471
+ end
1472
+
1473
+ it 'honors the valid bound and ignores an invalid bound in a between filter' do
1474
+ sale_one = Sale.create!(amount: 100)
1475
+ sale_two = Sale.create!(amount: 900)
1476
+ Sale.create!(amount: 2000)
1477
+
872
1478
  config = {
873
- status: {
1479
+ amount: {
874
1480
  type: :single,
875
- filter_type: :array,
876
- dbcolumn: :approval_status
877
- }
1481
+ filter_type: :integer,
1482
+ dbcolumn: :amount,
1483
+ range_type: :between
1484
+ }
878
1485
  }
879
1486
 
880
1487
  result = described_class.apply(
881
- scope: Sale.all,
882
- config: config,
883
- params: {
884
- status: '["approved","pending"]'
885
- }
1488
+ scope: Sale.all,
1489
+ config: config,
1490
+ params: { amount: { from: 'not-a-number', to: 1000 } }
886
1491
  )
887
1492
 
888
1493
  expect(result).to contain_exactly(
889
- sale_one,
890
- sale_two
1494
+ sale_one,
1495
+ sale_two
891
1496
  )
1497
+ end
892
1498
  end
893
1499
  end
894
- end
1500
+ end