Skip to content

Repository files navigation

filterable

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).


Installation

The gem is published to the Fluence GitHub Packages registry:

# Gemfile
source 'https://rubygems.pkg.github.com/fluence-eu' do
  gem 'filterable'
end
bundle install

Usage

In 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
end

filterable 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)
end

filterable 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)

Date filters (Datable)

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.

Equality filters (Equatable)

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.

Numeric bounds (Rangeable)

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.

Partial match (Matchable)

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.

Scope filters (Scopable / Togglable)

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 → .priced

A 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.

Ordering (Sortable)

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.

Public names vs. DB columns

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]

Association targets

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.

Polymorphic associations and delegated_type

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.

Attachments (Attachable)

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 }

Default filters

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 } }
end

Defaults 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.

Custom filters

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
end

A 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.

Applied filters introspection

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.

Validating declarations

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_valid

filterable_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.


Rails integration

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::Togglable

So 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.

Outside Rails

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
end

Include order matters. Filterable::Concern provides add_filter, which the declaration concerns call at include time — so it must come first.


How it works

  • Filterable::Concern keeps a per-class list of filters (cloned down the inheritance chain) and filterable reduces the relation through them.
  • Filterable::Concerns::Datable / Equatable / Rangeable / Scopable / Sortable / Togglable are thin ActiveSupport::Concern mixins: 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 is Filterable::Equatable, the numeric bound filters are Filterable::Rangeable::{Minimum,Maximum}, the scope filters are Filterable::Scopable / Filterable::Togglable and the ordering filter is Filterable::Sortable.

Development

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 prompt

The suite runs against an in-memory SQLite database (see spec/spec_helper.rb), so there is nothing to set up.


Contributing

See CONTRIBUTING.md. Bug reports and pull requests are welcome on the GitHub repository.

License

Released under the MIT License.

Releases

Packages

Used by

Contributors

Languages