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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +633 -0
- data/Rakefile +6 -0
- data/lib/bharat_filter_engine/association_resolver.rb +102 -0
- data/lib/bharat_filter_engine/dynamic_filter_values.rb +39 -0
- data/lib/bharat_filter_engine/engine.rb +443 -0
- data/lib/bharat_filter_engine/errors.rb +15 -0
- data/lib/bharat_filter_engine/rule_normalizer.rb +47 -0
- data/lib/bharat_filter_engine/search_builder.rb +146 -0
- data/lib/bharat_filter_engine/version.rb +5 -0
- data/lib/bharat_filter_engine.rb +34 -0
- data/spec/association_resolver_spec.rb +160 -0
- data/spec/dynamic_filter_values_spec.rb +130 -0
- data/spec/engine_spec.rb +894 -0
- data/spec/rule_normalizer_spec.rb +52 -0
- data/spec/search_builder_spec.rb +157 -0
- data/spec/spec_helper.rb +79 -0
- metadata +107 -0
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
|
+
[](CHANGELOG.md)
|
|
12
|
+
[](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).
|