Declarative, whitelisted query filtering and ordering for ActiveRecord.
A model declares which columns are datable, sortable, equatable and
rangeable, and which scopes are scopable or togglable. filterable(params)
then folds a chain of small, composable filters over the relation, reading from
nested request params and touching only the columns and scopes the model
explicitly exposed. An unknown attribute, a malformed date, or an undeclared
sort term narrows nothing — it never raises and never leaks an arbitrary column
into the query.
Requires Ruby ≥ 3.2 and ActiveRecord ≥ 7.1. No dependency on ActionPack:
ActionController::Parameters are supported by duck-typing (to_unsafe_h).
The gem is published to the Fluence GitHub Packages registry:
# Gemfile
source 'https://rubygems.pkg.github.com/fluence-eu' do
gem 'filterable'
endbundle installIn a Rails application a Railtie auto-includes the engine into every model, so
a model just declares its whitelisted columns:
class MovementDetail < ApplicationRecord
scope :priced, -> { where.not(gross_amount_cents: nil) }
scope :cheaper_than, ->(cents) { where(gross_amount_cents: ...cents) }
datable :value_date, :booking_date
sortable :value_date, :gross_amount_cents
equatable :reference
rangeable :gross_amount_cents
scopable :cheaper_than
togglable :priced
endfilterable is then available on every model; one that declares nothing simply
returns all. See Rails integration for what the Railtie
wires, and Outside Rails to opt in manually.
From a controller, hand the request params straight in:
def index
@details = MovementDetail.filterable(params)
endfilterable reads from params[:filters] and returns an ordinary relation, so
it chains with everything else (pagination, scopes, includes, …):
MovementDetail.where(account: account).filterable(params).page(2)Each declared datable attribute accepts these nested keys under filters[<attribute>]:
| Key | Meaning | SQL |
|---|---|---|
after |
strictly after the bound | > value |
before |
strictly before the bound | < value |
since |
on or before the bound (inclusive) | <= value |
from + to |
inclusive range (both required) | BETWEEN |
MovementDetail.filterable(
filters: { value_date: { after: '2026-01-15', before: '2026-03-01' } }
)Unparseable values are dropped silently (the filter is a no-op), so a bad query param can never raise.
Each declared equatable attribute reads filters[<attribute>] directly: a
scalar compares with =, an array of scalars with IN:
MovementDetail.filterable(filters: { reference: 'INV-1' })
MovementDetail.filterable(filters: { reference: %w[INV-1 REF-2] })Blank values and non-scalar values (e.g. a hash injected through the query string) are dropped silently, so an empty form field or a malformed param narrows nothing.
Each declared rangeable attribute accepts these nested keys under
filters[<attribute>], both inclusive:
| Key | Meaning | SQL |
|---|---|---|
min |
at or above the bound | >= value |
max |
at or below the bound | <= value |
min + max |
inclusive range | BETWEEN |
MovementDetail.filterable(filters: { gross_amount_cents: { min: 100, max: 250 } })Integer strings are read in base ten, decimals fall back to Float;
unparseable or blank bounds are dropped silently. A column may be both
equatable and rangeable: a scalar value filters by equality, a min/max
hash by range.
Each declared matchable attribute accepts these nested keys under
filters[<attribute>]:
| Key | SQL | Cost |
|---|---|---|
starts_with |
LIKE 'term%' |
sargable — can use a B-tree index |
ends_with |
LIKE '%term' |
full scan |
contains |
LIKE '%term%' |
full scan |
matchable :reference
matchable :code, case_sensitive: true
MovementDetail.filterable(filters: { reference: { starts_with: 'INV' } })The term is always LIKE-escaped — a user % or _ matches literally — and
the wildcard placement is fixed by the key: the caller never controls the
pattern. Matching is case insensitive by default (ILIKE on PostgreSQL);
case_sensitive: true opts a declaration into sensitivity, and the request
may override either way with the reserved nested key:
MovementDetail.filterable(filters: { reference: { starts_with: 'INV', case_sensitive: true } })A strict true or bare key switches on, an explicit false ('false', '0')
switches off — even on an attribute declared sensitive — and anything else
falls back to the declaration (case_sensitive is a reserved word, never a
public name; effective sensitivity on SQLite also depends on
PRAGMA case_sensitive_like). Blank or non-string terms are dropped silently.
Existing model scopes can be exposed as filters — the scope name is fixed at declaration time, never derived from the params:
scopable :cheaper_than # filters[cheaper_than]=250 → .cheaper_than(250)
togglable :priced # ?filters[priced] or =true / =1 → .pricedA scopable scope receives the normalized value (blank or non-scalar values
are dropped). A togglable scope takes no argument and toggles on a bare key
(?filters[priced], filters[priced]=) or a strict true value (true,
'true', 1, '1'); an explicit false value — 'false', '0', what Rails'
check_box hidden field submits — or an arbitrary string narrows nothing.
A declared name whose scope does not exist, or a scope not returning a
relation, narrows nothing either — the declarations validator
reports the former.
filters[sort] is a comma-separated list of declared attributes, each optionally
prefixed with + (ascending, the default) or - (descending):
MovementDetail.filterable(filters: { sort: '-value_date,+gross_amount_cents' })Undeclared attributes are ignored.
All the DSLs accept a hash to expose a public name that differs from the column or scope:
datable date: :value_date
sortable amount: :gross_amount_cents
equatable ref: :reference
rangeable amount: :gross_amount_cents
scopable max_price: :cheaper_than
# filters[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] / filters[max_price]datable, equatable and rangeable declarations may target a column through
associations, with an explicit nested hash — the path is declared, never
inferred from names:
class MovementDetail < ApplicationRecord
belongs_to :account
equatable account_name: { account: :name }
rangeable account_balance: { account: :balance_cents }
equatable bank_name: { account: { bank: :name } } # nested path
end
MovementDetail.filterable(filters: { account_name: 'Main' })
# INNER JOIN accounts ... WHERE accounts.name = 'Main'Filtering joins the declared path — rows without the association drop out —
and merges the condition on the target model; a collection anywhere in the
path adds DISTINCT. An unresolvable target (unknown association, unknown
concrete type, ambiguous multi-key hash) narrows nothing, and the
declarations validator reports it. sortable
does not accept association targets.
A polymorphic belongs_to — including the one behind delegated_type — is
crossed by naming the concrete type as the second segment:
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment]
equatable message_subject: { entryable: { message: :subject } }
end
Entry.filterable(filters: { message_subject: 'hello' })
# INNER JOIN messages ON messages.id = entries.entryable_id
# WHERE entries.entryable_type = 'Message' AND messages.subject = 'hello'The hop must open the path and target a column directly on the concrete type;
an unknown type narrows nothing and is reported by the validator. The reverse,
concrete direction (Message → { entry: :created_at } through its
has_one) is an ordinary path.
ActiveStorage attachments have their own declaration. Each declared attachment
accepts these nested keys under filters[<name>]:
| Key | Meaning |
|---|---|
present |
strict true or bare key → attached; explicit false ('false', '0') → unattached |
type |
blob content type — scalar =, array IN, or the image shortcut |
min_size / max_size |
inclusive blob byte size bounds |
class Contract < ApplicationRecord
has_one_attached :document
attachable :document
end
Contract.filterable(filters: { document: { present: true } })
Contract.filterable(filters: { document: { type: %w[application/pdf image/png], max_size: 5_000_000 } })
Contract.filterable(filters: { document: { type: 'image' } })The image shortcut expands at query time against
ActiveStorage.variable_content_types + web_image_content_types (a real
content type always carries a slash, so the name is unambiguous), and composes
inside arrays next to literal types. An unknown literal stays a plain equality
that matches nothing; an unknown symbol narrows nothing.
Everything resolves through the associations has_one_attached /
has_many_attached generate, so the gem still has no ActiveStorage
dependency; a declared name without those associations narrows nothing, and
the declarations validator reports it. Collections
(has_many_attached) deduplicate automatically. For anything beyond these
keys, the generated associations remain ordinary paths:
datable document_since: { document_attachment: :created_at }A model can declare default filter params, applied whenever the request does not carry the key:
class MovementDetail < ApplicationRecord
default_filters sort: '-value_date', value_date: -> { { since: Date.current } }
endDefaults are merged under the request's filters: an absent key falls back to
its default, and a present key — even blank — suppresses it. A Proc default
is evaluated at each call, so Date.current is computed at query time, not at
boot; a Proc returning nil withdraws its default. Defaults go through the
same whitelisted fold as request params — a default on an undeclared name
narrows nothing — and applied_filters reports them like any other applied
filter.
Filterable::Concern is the whole engine. Register any object responding to
call(filters_params, scope), or a block, and it joins the fold:
class MovementDetail < ApplicationRecord
include Filterable::Concern
add_filter do |filters, scope|
scope.where(reference: filters[:reference]) if filters[:reference]
end
endA filter returning nil leaves the scope untouched, so guard clauses are safe.
A filter whose callable accepts a third argument also receives the call
context — an optional hash handed to filterable after the params — so it can
depend on the caller without global state:
add_filter do |filters, scope, context|
scope.where(author_id: context[:user_id]) if filters[:mine] && context[:user_id]
end
Post.filterable(params, user_id: current_user.id)The context is passed as given and defaults to {}; two-argument filters —
the built-in ones included — never see it, and an applied accepting a third
argument receives it the same way. A filter its context cannot satisfy narrows
nothing.
Since an unknown or malformed filter is a silent no-op, applied_filters is how
a caller tells the difference: it reports, from the same params, which filters
filterable would actually apply — echo it in a response, or diff it against
params[:filters] to surface what was ignored:
MovementDetail.applied_filters(
filters: { value_date: { after: '2026-02-01', before: 'oops' },
reference: 'INV-1', sort: '-value_date,+unknown' }
)
# => { 'value_date' => { 'after' => '2026-02-01' },
# 'reference' => 'INV-1',
# 'sort' => '-value_date' }It never runs SQL and never mutates anything. A custom filter object joins the
report by responding to applied(filters_params, scope) next to call; block
filters cannot describe themselves and are simply absent.
At runtime a typo'd declaration is a silent no-op by design. To catch it in CI instead, every model exposes a validator over its declarations:
# in a model spec
expect(MovementDetail.filterable_declarations).to be_validfilterable_declarations.errors lists every declaration whose column or scope
does not exist, e.g. datable: 'date' maps to unknown column 'value_dat' on MovementDetail or scopable: 'max_price' maps to unknown scope 'cheapest' on MovementDetail. The engine never calls it: production behavior stays a silent
no-op.
Filterable::Railtie registers a single initializer that hooks
ActiveSupport.on_load(:active_record) and mixes the engine into
ActiveRecord::Base:
include Filterable::Concern
include Filterable::Concerns::Datable
include Filterable::Concerns::Equatable
include Filterable::Concerns::Rangeable
include Filterable::Concerns::Scopable
include Filterable::Concerns::Sortable
include Filterable::Concerns::TogglableSo every model gains filterable and the datable / sortable / equatable /
rangeable / scopable / togglable DSL with no boilerplate. A model that declares no columns keeps an empty whitelist, so
filterable is a harmless pass-through that returns all.
The railtie is loaded only when Rails::Railtie is defined. With plain
ActiveRecord, include the pieces yourself:
class MovementDetail < ActiveRecord::Base
include Filterable::Concern
include Filterable::Concerns::Datable
include Filterable::Concerns::Equatable
include Filterable::Concerns::Rangeable
include Filterable::Concerns::Scopable
include Filterable::Concerns::Sortable
include Filterable::Concerns::Togglable
datable :value_date
sortable :value_date
equatable :reference
rangeable :gross_amount_cents
scopable :cheaper_than
togglable :priced
endInclude order matters.
Filterable::Concernprovidesadd_filter, which the declaration concerns call at include time — so it must come first.
Filterable::Concernkeeps a per-class list of filters (cloned down the inheritance chain) andfilterablereduces the relation through them.Filterable::Concerns::Datable/Equatable/Rangeable/Scopable/Sortable/Togglableare thinActiveSupport::Concernmixins: they register the concrete filters and add the matching declaration DSL on top of the engine.- The concrete date filters live under
Filterable::Datable::{After,Before,Range,Since}, the equality filter isFilterable::Equatable, the numeric bound filters areFilterable::Rangeable::{Minimum,Maximum}, the scope filters areFilterable::Scopable/Filterable::Togglableand the ordering filter isFilterable::Sortable.
bin/setup # install dependencies
bundle exec rake # specs + rubocop (the default task)
bundle exec rspec # specs only
bundle exec rubocop # lint only
bin/console # interactive promptThe suite runs against an in-memory SQLite database (see spec/spec_helper.rb),
so there is nothing to set up.
See CONTRIBUTING.md. Bug reports and pull requests are welcome on the GitHub repository.
Released under the MIT License.