bharat_filter_engine 0.1.1

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/README.md ADDED
@@ -0,0 +1,633 @@
1
+ # 🇮🇳 Bharat Filter Engine
2
+
3
+ A generic, configuration-driven filtering engine for Ruby on Rails
4
+ `ActiveRecord` applications. Instead of hand-rolling `where` clauses
5
+ for every index action, you declare a **filter config** once and let
6
+ the engine turn incoming params (from a form, an API request, a
7
+ query string) into a filtered `ActiveRecord::Relation` — including
8
+ filters on associated models, free-text search, and dynamically
9
+ populated dropdown values.
10
+
11
+ [![Gem Version](https://img.shields.io/badge/version-0.1.1-blue)](CHANGELOG.md)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
13
+
14
+ ---
15
+
16
+ ## Table of contents
17
+
18
+ - [Features](#features)
19
+ - [Installation](#installation)
20
+ - [Quick start](#quick-start)
21
+ - [How it works](#how-it-works)
22
+ - [Filter config reference](#filter-config-reference)
23
+ - [Filter types with examples](#filter-types-with-examples)
24
+ - [string](#string)
25
+ - [array](#array)
26
+ - [boolean](#boolean)
27
+ - [integer / float](#integer--float)
28
+ - [daterange](#daterange)
29
+ - [search](#search)
30
+ - [Nested / association filters](#nested--association-filters)
31
+ - [Free-text search (`search` filter type)](#free-text-search-search-filter-type)
32
+ - [Dynamic filter values (for dropdowns)](#dynamic-filter-values-for-dropdowns)
33
+ - [Full Rails controller example](#full-rails-controller-example)
34
+ - [Legacy config format](#legacy-config-format)
35
+ - [Error handling](#error-handling)
36
+ - [Running the test suite](#running-the-test-suite)
37
+ - [Contributing](#contributing)
38
+ - [License](#license)
39
+
40
+ ---
41
+
42
+ ## Features
43
+
44
+ - String, array (multi-select), boolean, integer, and float filters
45
+ - Greater-than-or-equal / less-than-or-equal numeric range filters
46
+ - Date range filters (hash, array, or comma-separated string input)
47
+ - Nested / multi-level association filters (`lead__client__name`)
48
+ - Free-text search across multiple columns, including associations
49
+ - `field=value` search syntax with `&` (AND) / `|` (OR) grouping
50
+ - Dynamic filter values (distinct column values, e.g. to populate a
51
+ dropdown) for both direct and nested/associated columns
52
+ - Automatically ignores blank params so existing scope chains and
53
+ default ordering are left untouched
54
+ - Legacy config format still supported (see below)
55
+
56
+ ## Installation
57
+
58
+ Add this line to your application's `Gemfile`:
59
+
60
+ ```ruby
61
+ gem "bharat_filter_engine"
62
+ ```
63
+
64
+ And then run:
65
+
66
+ ```bash
67
+ bundle install
68
+ ```
69
+
70
+ Or install it yourself:
71
+
72
+ ```bash
73
+ gem install bharat_filter_engine
74
+ ```
75
+
76
+ **Requirements:** Ruby >= 3.0, ActiveRecord/ActiveModel/ActiveSupport >= 6.1.
77
+
78
+ ## Quick start
79
+
80
+ ```ruby
81
+ # 1. Define a filter config for your model (usually a constant or a
82
+ # method on the model/controller).
83
+ FILTER_CONFIG = {
84
+ stage: {
85
+ type: :single,
86
+ filter_type: :string,
87
+ dbcolumn: :stage
88
+ },
89
+
90
+ status: {
91
+ type: :single,
92
+ filter_type: :array,
93
+ dbcolumn: :approval_status
94
+ },
95
+
96
+ amount: {
97
+ type: :single,
98
+ filter_type: :integer,
99
+ dbcolumn: :amount,
100
+ range_type: :gte
101
+ }
102
+ }
103
+
104
+ # 2. Apply it to a scope using whatever params your app received.
105
+ result = BharatFilterEngine.apply(
106
+ scope: Sale.all,
107
+ config: FILTER_CONFIG,
108
+ params: {
109
+ stage: "qualified",
110
+ status: ["approved", "pending"],
111
+ amount: "500"
112
+ }
113
+ )
114
+
115
+ result # => an ActiveRecord::Relation, filtered and ready to use
116
+ ```
117
+
118
+ That's it — `result` behaves exactly like any other `ActiveRecord::Relation`,
119
+ so you can keep chaining `.order`, `.page`, `.includes`, etc.
120
+
121
+ ## How it works
122
+
123
+ `BharatFilterEngine.apply` takes three keyword arguments:
124
+
125
+ | Argument | Type | Description |
126
+ |----------|-----------------------------|----------------------------------------------------------------------------|
127
+ | `scope` | `ActiveRecord::Relation` | The base scope to filter (e.g. `Sale.all`, `current_user.sales`). |
128
+ | `config` | `Hash` | Maps a **param key** to a **filter rule** (see reference below). |
129
+ | `params` | `Hash` / `ActionController::Parameters` | The incoming request params. |
130
+
131
+ For every `key => rule` pair in `config`, the engine looks up
132
+ `params[key]`. If the value is blank (`nil`, `""`, `[]` — but **not**
133
+ `false`), that filter is skipped entirely, so unrelated scope
134
+ conditions and default ordering are preserved. Otherwise the rule is
135
+ applied to the scope.
136
+
137
+ Params are normalized automatically before filtering:
138
+
139
+ - Comma-separated strings become arrays: `"approved,pending"` → `["approved", "pending"]`
140
+ - JSON array strings are parsed: `'["approved","pending"]'` → `["approved", "pending"]`
141
+ - All keys are deep-symbolized, so both `"stage"` and `:stage` work.
142
+
143
+ ## Filter config reference
144
+
145
+ Each entry in `config` is a hash describing one filter rule:
146
+
147
+ ```ruby
148
+ {
149
+ type: :single | :nested, # :single = direct column, :nested = via association
150
+ filter_type: :string | :array | :boolean | :integer | :float | :daterange | :search,
151
+ dbcolumn: :column_name, # the actual DB column (or one of the search columns)
152
+ association: :lead, # required when type: :nested — "__" separated for multi-level
153
+ range_type: :gte | :lte, # optional, for :integer / :float
154
+ dbcolumns: [:stage, :"lead__source"] # required when filter_type: :search
155
+ }
156
+ ```
157
+
158
+ ## Filter types with examples
159
+
160
+ All examples below assume the schema used in the spec suite:
161
+
162
+ ```ruby
163
+ Organization has_many :clients
164
+ Client belongs_to :organization, has_many :leads
165
+ Lead belongs_to :client, has_many :sales
166
+ Sale belongs_to :lead
167
+ ```
168
+
169
+ ### string
170
+
171
+ Exact match on a column.
172
+
173
+ ```ruby
174
+ config = {
175
+ stage: {
176
+ type: :single,
177
+ filter_type: :string,
178
+ dbcolumn: :stage
179
+ }
180
+ }
181
+
182
+ BharatFilterEngine.apply(
183
+ scope: Sale.all,
184
+ config: config,
185
+ params: { stage: "qualified" }
186
+ )
187
+ # SQL: WHERE sales.stage = 'qualified'
188
+ ```
189
+
190
+ ### array
191
+
192
+ Matches any value in the given array (`IN`). Accepts an actual array,
193
+ a comma-separated string, or a JSON array string.
194
+
195
+ ```ruby
196
+ config = {
197
+ status: {
198
+ type: :single,
199
+ filter_type: :array,
200
+ dbcolumn: :approval_status
201
+ }
202
+ }
203
+
204
+ BharatFilterEngine.apply(
205
+ scope: Sale.all,
206
+ config: config,
207
+ params: { status: ["approved", "pending"] }
208
+ )
209
+ # same as params: { status: "approved,pending" }
210
+ # same as params: { status: '["approved","pending"]' }
211
+ # SQL: WHERE sales.approval_status IN ('approved', 'pending')
212
+ ```
213
+
214
+ ### boolean
215
+
216
+ Casts common truthy/falsy input (`"true"`, `"1"`, `true`, `"false"`, `false`, etc.)
217
+ using `ActiveModel::Type::Boolean`.
218
+
219
+ ```ruby
220
+ config = {
221
+ active: {
222
+ type: :single,
223
+ filter_type: :boolean,
224
+ dbcolumn: :active
225
+ }
226
+ }
227
+
228
+ BharatFilterEngine.apply(
229
+ scope: Sale.all,
230
+ config: config,
231
+ params: { active: "true" }
232
+ )
233
+ # SQL: WHERE sales.active = TRUE
234
+ ```
235
+
236
+ > Note: `false` is a meaningful value and is **not** treated as blank,
237
+ > so `params: { active: false }` correctly filters for inactive
238
+ > records instead of being skipped.
239
+
240
+ ### integer / float
241
+
242
+ Exact match by default. Add `range_type: :gte` or `range_type: :lte`
243
+ for open-ended range filters.
244
+
245
+ ```ruby
246
+ # Exact match
247
+ config = {
248
+ amount: {
249
+ type: :single,
250
+ filter_type: :integer,
251
+ dbcolumn: :amount
252
+ }
253
+ }
254
+
255
+ BharatFilterEngine.apply(scope: Sale.all, config: config, params: { amount: "500" })
256
+ # SQL: WHERE sales.amount = 500
257
+
258
+ # Greater-than-or-equal
259
+ config = {
260
+ amount: {
261
+ type: :single,
262
+ filter_type: :integer,
263
+ dbcolumn: :amount,
264
+ range_type: :gte
265
+ }
266
+ }
267
+
268
+ BharatFilterEngine.apply(scope: Sale.all, config: config, params: { amount: 400 })
269
+ # SQL: WHERE sales.amount >= 400
270
+
271
+ # Float, less-than-or-equal
272
+ config = {
273
+ conversion_rate: {
274
+ type: :single,
275
+ filter_type: :float,
276
+ dbcolumn: :conversion_rate,
277
+ range_type: :lte
278
+ }
279
+ }
280
+
281
+ BharatFilterEngine.apply(scope: Sale.all, config: config, params: { conversion_rate: "20.5" })
282
+ # SQL: WHERE sales.conversion_rate <= 20.5
283
+ ```
284
+
285
+ ### daterange
286
+
287
+ Accepts three input shapes for `from`/`to`. Either bound alone is
288
+ enough — leaving one out gives an open-ended range.
289
+
290
+ ```ruby
291
+ config = {
292
+ actual_sale_date: {
293
+ type: :single,
294
+ filter_type: :daterange,
295
+ dbcolumn: :actual_sale_date
296
+ }
297
+ }
298
+
299
+ # Hash form
300
+ BharatFilterEngine.apply(
301
+ scope: Sale.all,
302
+ config: config,
303
+ params: { actual_sale_date: { from: "2026-07-01", to: "2026-07-31" } }
304
+ )
305
+
306
+ # Array form: [from, to]
307
+ BharatFilterEngine.apply(
308
+ scope: Sale.all,
309
+ config: config,
310
+ params: { actual_sale_date: ["2026-07-01", "2026-07-31"] }
311
+ )
312
+
313
+ # Comma-separated string form
314
+ BharatFilterEngine.apply(
315
+ scope: Sale.all,
316
+ config: config,
317
+ params: { actual_sale_date: "2026-07-01,2026-07-31" }
318
+ )
319
+
320
+ # Open-ended — only a lower bound
321
+ BharatFilterEngine.apply(
322
+ scope: Sale.all,
323
+ config: config,
324
+ params: { actual_sale_date: { from: "2026-08-01" } }
325
+ )
326
+ # SQL: WHERE sales.actual_sale_date >= '2026-08-01 00:00:00'
327
+ ```
328
+
329
+ Dates are parsed with `Date.parse` and expanded to
330
+ `beginning_of_day` (from) / `end_of_day` (to), so a plain `"2026-07-20"`
331
+ correctly captures every record on that calendar day.
332
+
333
+ ### search
334
+
335
+ See the dedicated [Free-text search](#free-text-search-search-filter-type)
336
+ section below — it's the same filter type, just wired up through
337
+ `config` instead of used directly.
338
+
339
+ ```ruby
340
+ config = {
341
+ q: {
342
+ type: :single,
343
+ filter_type: :search,
344
+ dbcolumns: [:stage, :approval_status, :"lead__source"]
345
+ }
346
+ }
347
+
348
+ BharatFilterEngine.apply(scope: Sale.all, config: config, params: { q: "google" })
349
+ ```
350
+
351
+ ## Nested / association filters
352
+
353
+ Set `type: :nested` and `association:` to filter on a column that
354
+ belongs to an associated model. The engine resolves the association
355
+ via `ActiveRecord::Base.reflect_on_association`, joins it (with
356
+ `.distinct` applied automatically to avoid duplicate rows from the
357
+ join), and applies the same filter types described above.
358
+
359
+ ```ruby
360
+ # Filter sales by their lead's source
361
+ config = {
362
+ lead_source: {
363
+ type: :nested,
364
+ filter_type: :array,
365
+ association: :lead,
366
+ dbcolumn: :source
367
+ }
368
+ }
369
+
370
+ BharatFilterEngine.apply(
371
+ scope: Sale.all,
372
+ config: config,
373
+ params: { lead_source: ["google", "referral"] }
374
+ )
375
+ # SQL: ... INNER JOIN leads ON leads.id = sales.lead_id
376
+ # WHERE leads.source IN ('google', 'referral')
377
+ ```
378
+
379
+ ### Multi-level associations
380
+
381
+ Use `__` (double underscore) to walk through more than one
382
+ association — this works both in `association:` for `:nested` filter
383
+ rules and directly in `dbcolumns:` for `search`.
384
+
385
+ ```ruby
386
+ config = {
387
+ organization_name: {
388
+ type: :nested,
389
+ filter_type: :string,
390
+ association: :lead__client__organization,
391
+ dbcolumn: :name
392
+ }
393
+ }
394
+
395
+ BharatFilterEngine.apply(
396
+ scope: Sale.all,
397
+ config: config,
398
+ params: { organization_name: "Acme" }
399
+ )
400
+ # Joins sales -> leads -> clients -> organizations
401
+ ```
402
+
403
+ ## Free-text search (`search` filter type)
404
+
405
+ The `search` filter type wires up `BharatFilterEngine::SearchBuilder`,
406
+ which supports two input styles:
407
+
408
+ **1. Simple search** — no `=` sign. Matches the query (case-insensitive,
409
+ substring) against *any* of the allowed columns, `OR`-ed together.
410
+
411
+ ```ruby
412
+ config = {
413
+ q: {
414
+ type: :single,
415
+ filter_type: :search,
416
+ dbcolumns: [:stage, :"lead__source", :"lead__client__name"]
417
+ }
418
+ }
419
+
420
+ BharatFilterEngine.apply(scope: Sale.all, config: config, params: { q: "goog" })
421
+ # matches any Sale whose stage, lead.source, or lead.client.name
422
+ # contains "goog" (case-insensitive)
423
+ ```
424
+
425
+ **2. Field-based search** — contains `=`. Lets the caller target
426
+ specific fields, with `&` for AND and `|` for OR (within an AND
427
+ group). Only fields present in `dbcolumns:` are honored; anything
428
+ else makes that AND group unsatisfiable (so the search returns no
429
+ records rather than silently matching everything).
430
+
431
+ ```ruby
432
+ BharatFilterEngine.apply(
433
+ scope: Sale.all,
434
+ config: config,
435
+ params: { q: "stage=qualified&lead__source=google" }
436
+ )
437
+ # WHERE stage ILIKE '%qualified%' AND leads.source ILIKE '%google%'
438
+
439
+ BharatFilterEngine.apply(
440
+ scope: Sale.all,
441
+ config: config,
442
+ params: { q: "stage=qualified&approval_status=approved|approval_status=pending" }
443
+ )
444
+ # WHERE stage ILIKE '%qualified%'
445
+ # AND (approval_status ILIKE '%approved%' OR approval_status ILIKE '%pending%')
446
+ ```
447
+
448
+ `id` gets special handling so you can search it as text
449
+ (`"id=42"` → `id::text ILIKE '%42%'`).
450
+
451
+ You can also use `SearchBuilder` directly, without going through a
452
+ full config/`BharatFilterEngine.apply` call:
453
+
454
+ ```ruby
455
+ BharatFilterEngine::SearchBuilder.new(
456
+ scope: Sale.all,
457
+ allowed_columns: [:stage, :"lead__source"]
458
+ ).apply("qualified")
459
+ ```
460
+
461
+ ## Dynamic filter values (for dropdowns)
462
+
463
+ Use `BharatFilterEngine.filter_values` to fetch the distinct,
464
+ non-blank values for a configured filter field — handy for populating
465
+ a `<select>`/dropdown/autocomplete without writing a separate query.
466
+
467
+ ```ruby
468
+ BharatFilterEngine.filter_values(
469
+ scope: Sale.all,
470
+ config: FILTER_CONFIG,
471
+ params: { filter_field: "stage" }
472
+ )
473
+ # => ["new", "qualified", ...] (sorted, unique, blanks excluded)
474
+
475
+ # Works for nested/associated fields too, resolved from the same
476
+ # config entry's `association:` + `dbcolumn:`
477
+ BharatFilterEngine.filter_values(
478
+ scope: Sale.all,
479
+ config: FILTER_CONFIG,
480
+ params: { filter_field: "lead_source" }
481
+ )
482
+ # => ["google", "referral", ...]
483
+ ```
484
+
485
+ If `filter_field` isn't present in `config`, or doesn't have a
486
+ `dbcolumn`, this returns `[]` rather than raising.
487
+
488
+ You can also use `BharatFilterEngine::DynamicFilterValues` directly:
489
+
490
+ ```ruby
491
+ BharatFilterEngine::DynamicFilterValues.new(
492
+ scope: Sale.all,
493
+ field: "lead__client__name" # "__" notation, same as association filters
494
+ ).call
495
+ # => ["Acme Client", "Beta Client", ...] or nil if the field is invalid
496
+ ```
497
+
498
+ ## Full Rails controller example
499
+
500
+ ```ruby
501
+ class SalesController < ApplicationController
502
+ FILTER_CONFIG = {
503
+ stage: {
504
+ type: :single,
505
+ filter_type: :string,
506
+ dbcolumn: :stage
507
+ },
508
+
509
+ status: {
510
+ type: :single,
511
+ filter_type: :array,
512
+ dbcolumn: :approval_status
513
+ },
514
+
515
+ active: {
516
+ type: :single,
517
+ filter_type: :boolean,
518
+ dbcolumn: :active
519
+ },
520
+
521
+ amount_from: {
522
+ type: :single,
523
+ filter_type: :integer,
524
+ dbcolumn: :amount,
525
+ range_type: :gte
526
+ },
527
+
528
+ sale_date: {
529
+ type: :single,
530
+ filter_type: :daterange,
531
+ dbcolumn: :actual_sale_date
532
+ },
533
+
534
+ lead_source: {
535
+ type: :nested,
536
+ filter_type: :array,
537
+ association: :lead,
538
+ dbcolumn: :source
539
+ },
540
+
541
+ q: {
542
+ type: :single,
543
+ filter_type: :search,
544
+ dbcolumns: [:stage, :"lead__source", :"lead__client__name"]
545
+ }
546
+ }.freeze
547
+
548
+ def index
549
+ @sales = BharatFilterEngine.apply(
550
+ scope: current_organization.sales.order(created_at: :desc),
551
+ config: FILTER_CONFIG,
552
+ params: filter_params
553
+ ).page(params[:page])
554
+ end
555
+
556
+ # For an AJAX-populated dropdown, e.g. GET /sales/filter_values?filter_field=stage
557
+ def filter_values
558
+ render json: BharatFilterEngine.filter_values(
559
+ scope: current_organization.sales,
560
+ config: FILTER_CONFIG,
561
+ params: params
562
+ )
563
+ end
564
+
565
+ private
566
+
567
+ def filter_params
568
+ params.permit(
569
+ :stage, :active, :amount_from, :q,
570
+ status: [], lead_source: [],
571
+ sale_date: [:from, :to]
572
+ )
573
+ end
574
+ end
575
+ ```
576
+
577
+ Because `BharatFilterEngine.apply` ignores blank params, this same
578
+ config works whether the user has applied zero, one, or every filter
579
+ — no conditional branching required in the controller.
580
+
581
+ ## Legacy config format
582
+
583
+ Older/simpler config entries (`type:` doubling as the filter type,
584
+ without an explicit `filter_type:`) are automatically normalized, so
585
+ existing configs keep working:
586
+
587
+ ```ruby
588
+ # Legacy (still supported)
589
+ { type: :string, dbcolumn: :stage }
590
+
591
+ # Equivalent modern form (what it's normalized to internally)
592
+ { type: :single, filter_type: :string, dbcolumn: :stage }
593
+ ```
594
+
595
+ Nested legacy rules (`type: :nested`) keep `filter_type:` as-is and
596
+ only get `type: :nested` normalized alongside it.
597
+
598
+ ## Error handling
599
+
600
+ `BharatFilterEngine::Errors` defines a small hierarchy you can rescue
601
+ from if you extend the engine:
602
+
603
+ ```ruby
604
+ BharatFilterEngine::Error # base class
605
+ BharatFilterEngine::InvalidConfigurationError
606
+ BharatFilterEngine::InvalidFilterError
607
+ BharatFilterEngine::InvalidAssociationError
608
+ ```
609
+
610
+ Invalid associations/columns referenced via `__` notation don't raise
611
+ by default — `AssociationResolver` and `DynamicFilterValues` simply
612
+ return `nil`, and `Engine` skips filters whose association can't be
613
+ resolved, leaving the scope unchanged.
614
+
615
+ ## Running the test suite
616
+
617
+ ```bash
618
+ bundle install
619
+ bundle exec rspec
620
+ ```
621
+
622
+ The specs run against an in-memory SQLite database defined in
623
+ `spec/spec_helper.rb`, so no external database setup is required.
624
+
625
+ ## Contributing
626
+
627
+ Bug reports and pull requests are welcome. Please add/update specs for
628
+ any behavior change, and run `bundle exec rspec` before opening a PR.
629
+
630
+ ## License
631
+
632
+ The gem is available as open source under the terms of the
633
+ [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec