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.