Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

Declarative, **whitelisted** query filtering and ordering for ActiveRecord.

A model declares which columns are *datable*, *sortable*, *equatable* and *rangeable*.
`filterable(params)` then folds a chain of small, composable filters over the
relation, reading from nested request params and touching **only** the columns
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.
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`).
Expand Down Expand Up @@ -38,10 +39,15 @@ a model just declares its whitelisted columns:

```ruby
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
```

Expand Down Expand Up @@ -118,6 +124,25 @@ 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.

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

```ruby
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](#validating-declarations)
reports the former.

### Ordering (`Sortable`)

`filters[sort]` is a comma-separated list of declared attributes, each optionally
Expand All @@ -131,15 +156,17 @@ 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:
All the DSLs accept a hash to expose a public name that differs from the column
or scope:

```ruby
datable date: :value_date
sortable amount: :gross_amount_cents
equatable ref: :reference
rangeable amount: :gross_amount_cents
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[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] / filters[max_price]
```

### Custom filters
Expand Down Expand Up @@ -190,9 +217,11 @@ instead, every model exposes a validator over its declarations:
expect(MovementDetail.filterable_declarations).to be_valid
```

`filterable_declarations.errors` lists every declaration whose column does not
exist, e.g. `datable: 'date' maps to unknown column 'value_dat' on MovementDetail`.
The engine never calls it: production behavior stays a silent no-op.
`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.

---

Expand All @@ -207,11 +236,13 @@ 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` DSL with no boilerplate. A model that declares no columns keeps an empty whitelist, so
`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
Expand All @@ -225,32 +256,36 @@ class MovementDetail < ActiveRecord::Base
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 `Datable`/`Equatable`/`Rangeable`/`Sortable` concerns call at include
> time — so it must come first.
> 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` / `Sortable` are
thin `ActiveSupport::Concern` mixins: they register the concrete filters and
add the `datable` / `equatable` / `rangeable` / `sortable` declaration DSL on
top of the engine.
- `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}` and the ordering filter is
`Filterable::Rangeable::{Minimum,Maximum}`, the scope filters are
`Filterable::Scopable` / `Filterable::Togglable` and the ordering filter is
`Filterable::Sortable`.

---
Expand Down
9 changes: 9 additions & 0 deletions lib/filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative 'filterable/version'
require_relative 'filterable/concern'
require_relative 'filterable/attribute_normalization'
require_relative 'filterable/value_normalization'
require_relative 'filterable/declarations_validator'
require_relative 'filterable/datable'
require_relative 'filterable/datable/after'
Expand All @@ -16,11 +17,15 @@
require_relative 'filterable/rangeable'
require_relative 'filterable/rangeable/minimum'
require_relative 'filterable/rangeable/maximum'
require_relative 'filterable/scopable'
require_relative 'filterable/sortable'
require_relative 'filterable/togglable'
require_relative 'filterable/concerns/datable'
require_relative 'filterable/concerns/equatable'
require_relative 'filterable/concerns/rangeable'
require_relative 'filterable/concerns/scopable'
require_relative 'filterable/concerns/sortable'
require_relative 'filterable/concerns/togglable'

# Declarative, whitelisted query filtering and ordering for ActiveRecord models.
#
Expand All @@ -32,6 +37,8 @@
# sortable :value_date, :gross_amount_cents
# equatable :reference
# rangeable :gross_amount_cents
# scopable :cheaper_than
# togglable :priced
# end
#
# MovementDetail.filterable(filters: { value_date: { after: '2026-02-01' }, sort: '-value_date' })
Expand All @@ -42,7 +49,9 @@
# include Filterable::Concerns::Datable
# include Filterable::Concerns::Equatable
# include Filterable::Concerns::Rangeable
# include Filterable::Concerns::Scopable
# include Filterable::Concerns::Sortable
# include Filterable::Concerns::Togglable
module Filterable
# Base error class for the gem.
class Error < StandardError; end
Expand Down
48 changes: 48 additions & 0 deletions lib/filterable/concerns/scopable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module Filterable
module Concerns
# Adds scope delegation to a Filterable::Concern model. Declare the scopes
# with +scopable :cheaper_than+ (or +scopable public_name: :scope_name+ to
# alias), then +filters[<name>]+ hands its value to those scopes only.
module Scopable
extend ActiveSupport::Concern

included do
add_filter Filterable::Scopable
end

class_methods do
# The declared scopable names, mapping each public name to its scope,
# cloned from the superclass so a subclass starts with its parent's
# declarations and can append its own.
#
# @api private
# @return [ActiveSupport::HashWithIndifferentAccess] public name => scope name.
def scopable_scope_names
@scopable_scope_names ||=
if superclass.respond_to?(:scopable_scope_names)
superclass.scopable_scope_names.clone
else
{}.with_indifferent_access
end
end

# Declares one or more scopes as scopable. A bare name maps onto the
# scope of the same name; a hash aliases a public name to a different
# scope.
#
# @api public
# @param scope_names [Array<Symbol, String, Hash>] the scopes to expose.
# @return [self] so calls can be chained.
def scopable(*scope_names)
scope_names.flatten.each do |scope_name|
scopable_scope_names.merge!(Filterable::AttributeNormalization.normalize(scope_name))
end

self
end
end
end
end
end
49 changes: 49 additions & 0 deletions lib/filterable/concerns/togglable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module Filterable
module Concerns
# Adds scope toggling to a Filterable::Concern model. Declare the
# no-argument scopes with +togglable :priced+ (or +togglable public_name:
# :scope_name+ to alias), then a strict true under +filters[<name>]+
# applies those scopes only — checkbox semantics.
module Togglable
extend ActiveSupport::Concern

included do
add_filter Filterable::Togglable
end

class_methods do
# The declared togglable names, mapping each public name to its scope,
# cloned from the superclass so a subclass starts with its parent's
# declarations and can append its own.
#
# @api private
# @return [ActiveSupport::HashWithIndifferentAccess] public name => scope name.
def togglable_scope_names
@togglable_scope_names ||=
if superclass.respond_to?(:togglable_scope_names)
superclass.togglable_scope_names.clone
else
{}.with_indifferent_access
end
end

# Declares one or more scopes as togglable. A bare name maps onto the
# scope of the same name; a hash aliases a public name to a different
# scope.
#
# @api public
# @param scope_names [Array<Symbol, String, Hash>] the scopes to expose.
# @return [self] so calls can be chained.
def togglable(*scope_names)
scope_names.flatten.each do |scope_name|
togglable_scope_names.merge!(Filterable::AttributeNormalization.normalize(scope_name))
end

self
end
end
end
end
end
47 changes: 38 additions & 9 deletions lib/filterable/declarations_validator.rb
Original file line number Diff line number Diff line change
@@ -1,48 +1,60 @@
# frozen_string_literal: true

module Filterable
# Checks a model's declarations against its actual columns, so a typo in a
# declaration — a silent no-op at runtime by design — can be caught by a spec:
# Checks a model's declarations against its actual columns and scopes, so a
# typo in a declaration — a silent no-op at runtime by design — can be caught
# by a spec:
#
# expect(MovementDetail.filterable_declarations).to be_valid
#
# Never used by the engine at runtime: filtering stays a silent no-op on
# unknown attributes.
class DeclarationsValidator
# The declaration DSLs to check, each exposing +<kind>_attribute_names+ on
# the model. A kind the model does not respond to is skipped.
# The column-backed declaration DSLs to check, each exposing
# +<kind>_attribute_names+ on the model. A kind the model does not respond
# to is skipped.
KINDS = %i[datable sortable equatable rangeable].freeze

# The scope-backed declaration DSLs to check, each exposing
# +<kind>_scope_names+ on the model. A kind the model does not respond to
# is skipped.
SCOPE_KINDS = %i[scopable togglable].freeze

# Builds a validator over one model's declarations.
#
# @api public
# @param model [Class] an ActiveRecord model using the declaration DSLs.
def initialize(model)
@model = model
end

# Whether every declaration maps onto an existing column.
# Whether every declaration maps onto an existing column or scope.
#
# @api public
# @return [Boolean]
def valid?
errors.empty?
end

# The declarations pointing to unknown columns, as human-readable messages.
# The declarations pointing to unknown columns or scopes, as human-readable
# messages.
#
# @api public
# @return [Array<String>] one message per broken declaration; empty when valid.
def errors
@errors ||= KINDS.flat_map { |kind| kind_errors(kind) }
@errors ||= KINDS.flat_map { |kind| column_errors(kind) } +
SCOPE_KINDS.flat_map { |kind| scope_errors(kind) }
end

private

# The error messages for one declaration DSL.
# The error messages for one column-backed declaration DSL.
#
# @api private
# @param kind [Symbol] the declaration DSL to check.
# @return [Array<String>] one message per declaration of that kind whose
# column does not exist.
def kind_errors(kind)
def column_errors(kind)
reader = "#{kind}_attribute_names"
return [] unless @model.respond_to?(reader)

Expand All @@ -52,5 +64,22 @@ def kind_errors(kind)
"#{kind}: '#{public_name}' maps to unknown column '#{column}' on #{@model.name}"
end
end

# The error messages for one scope-backed declaration DSL.
#
# @api private
# @param kind [Symbol] the declaration DSL to check.
# @return [Array<String>] one message per declaration of that kind whose
# scope does not exist.
def scope_errors(kind)
reader = "#{kind}_scope_names"
return [] unless @model.respond_to?(reader)

@model.public_send(reader).filter_map do |public_name, scope_name|
next if @model.respond_to?(scope_name)

"#{kind}: '#{public_name}' maps to unknown scope '#{scope_name}' on #{@model.name}"
end
end
end
end
Loading
Loading