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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ scopable max_price: :cheaper_than
# filters[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] / filters[max_price]
```

### Default filters

A model can declare default filter params, applied whenever the request does
not carry the key:

```ruby
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
Expand Down
71 changes: 63 additions & 8 deletions lib/filterable/concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,47 @@ def add_filter(filter = nil, &block)
self
end

# The default filter params declared on this class, cloned from the
# superclass so a subclass starts with its parent's defaults and can
# append its own.
#
# @api private
# @return [ActiveSupport::HashWithIndifferentAccess] public name => default value.
def default_filter_params
@default_filter_params ||=
if superclass.respond_to?(:default_filter_params)
superclass.default_filter_params.clone
else
{}.with_indifferent_access
end
end

# Declares default filter params, used whenever the request carries no
# value for their key — a present key, even blank, suppresses its default.
# A Proc value is evaluated lazily at each call, and one returning nil
# withdraws its default. Defaults go through the same whitelisted fold as
# request params, so a default on an undeclared name narrows nothing.
#
# @api public
# @param defaults [Hash] public name => default value, or a Proc returning it.
# @return [self] so calls can be chained.
def default_filters(defaults)
default_filter_params.merge!(defaults) if defaults.is_a?(Hash)
self
end

# Folds every registered filter over the relation, narrowing it from the
# nested +filters+ params. Returns +all+ untouched when the params carry no
# usable +filters+ key.
# nested +filters+ params merged over the declared defaults. Returns +all+
# untouched when neither carries anything usable.
#
# @api public
# @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+.
# @return [ActiveRecord::Relation] the filtered relation.
def filterable(params = {})
params = params.with_indifferent_access if params.is_a?(Hash)
return all unless indifferent?(params) && indifferent?(params[:filters])
filters_params = effective_filters(params)
return all if filters_params.empty?

filters.reduce(all) { |scope, filter| filter.call(params[:filters], scope) || scope }
filters.reduce(all) { |scope, filter| filter.call(filters_params, scope) || scope }
end

# Reports which filters {filterable} would actually apply from the same
Expand All @@ -63,12 +92,12 @@ def filterable(params = {})
# @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+.
# @return [ActiveSupport::HashWithIndifferentAccess] public name => accepted value.
def applied_filters(params = {})
params = params.with_indifferent_access if params.is_a?(Hash)
filters_params = effective_filters(params)
report = {}.with_indifferent_access
return report unless indifferent?(params) && indifferent?(params[:filters])
return report if filters_params.empty?

filters.select { |filter| filter.respond_to?(:applied) }
.reduce(report) { |acc, filter| acc.deep_merge(filter.applied(params[:filters], all)) }
.reduce(report) { |acc, filter| acc.deep_merge(filter.applied(filters_params, all)) }
end

# A validator checking every declaration against the model's columns, meant
Expand All @@ -82,6 +111,32 @@ def filterable_declarations

private

# The request filters merged over the evaluated defaults — the single
# params shape handed to every filter by {filterable} and
# {applied_filters}, so the two cannot drift. A key present in the
# request, even blank, wins over its default.
#
# @api private
# @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+.
# @return [ActiveSupport::HashWithIndifferentAccess] the effective +filters+ params.
def effective_filters(params)
params = params.with_indifferent_access if params.is_a?(Hash)
requested = indifferent?(params) && indifferent?(params[:filters]) ? params[:filters] : {}
requested = requested.to_unsafe_h if requested.respond_to?(:to_unsafe_h)
evaluated_defaults.merge(requested)
end

# The declared defaults with every Proc evaluated, nil results withdrawn.
#
# @api private
# @return [ActiveSupport::HashWithIndifferentAccess] public name => default value.
def evaluated_defaults
default_filter_params.each_with_object({}.with_indifferent_access) do |(name, value), defaults|
resolved = value.respond_to?(:call) ? value.call : value
defaults[name] = resolved unless resolved.nil?
end
end

# Whether the value carries indifferent string/symbol access, i.e. an
# ActionController::Parameters (duck-typed) or a HashWithIndifferentAccess.
#
Expand Down
95 changes: 95 additions & 0 deletions spec/filterable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,101 @@ def self.applied(filters_params, _scope)
end
end

describe '.default_filters' do
let(:model) { Class.new(MovementDetail) }

it 'applies a default sort when the params carry nothing' do
model.default_filters(sort: '-value_date')

expect(model.filterable({}).map(&:id)).to eq([march.id, february.id, january.id])
end

it 'applies a default filter value when the key is absent' do
model.default_filters(value_date: { after: '2026-01-15' })

expect(model.filterable({}).map(&:id)).to contain_exactly(february.id, march.id)
end

it 'lets a request value override its default' do
model.default_filters(sort: '-value_date')

result = model.filterable(filters: { sort: '+value_date' })
expect(result.map(&:id)).to eq([january.id, february.id, march.id])
end

it 'suppresses the default when the key is present, even blank' do
model.default_filters(value_date: { after: '2026-01-15' })

result = model.filterable(filters: { value_date: '' })
expect(result.map(&:id)).to contain_exactly(january.id, february.id, march.id)
end

it 'combines a default with requested filters on other keys' do
model.default_filters(sort: '-value_date')

result = model.filterable(filters: { value_date: { after: '2026-01-15' } })
expect(result.map(&:id)).to eq([march.id, february.id])
end

it 'evaluates a proc default at each call' do
bound = '2026-03-01'
model.default_filters(value_date: -> { { after: bound } })

expect(model.filterable({}).map(&:id)).to contain_exactly(march.id)
bound = '2026-01-15'
expect(model.filterable({}).map(&:id)).to contain_exactly(february.id, march.id)
end

it 'treats a proc returning nil as no default' do
model.default_filters(sort: -> {})

expect(model.filterable({}).map(&:id)).to contain_exactly(january.id, february.id, march.id)
expect(model.applied_filters({})).to be_empty
end

it 'keeps a default on an undeclared name a silent no-op' do
model.default_filters(bogus: 'x')

result = nil
expect { result = model.filterable({}) }.not_to raise_error
expect(result.map(&:id)).to contain_exactly(january.id, february.id, march.id)
end

it 'applies defaults when the filters params are unusable' do
model.default_filters(value_date: { after: '2026-01-15' })

expect(model.filterable(filters: 'garbage').map(&:id)).to contain_exactly(february.id, march.id)
end

it 'reports the effective defaults in applied_filters' do
model.default_filters(sort: '-value_date')

expect(model.applied_filters({})[:sort]).to eq('-value_date')
end

it 'merges ActionController::Parameters over the defaults' do
model.default_filters(sort: '-value_date')
params = ActionController::Parameters.new(filters: { sort: '+value_date' })

expect(model.filterable(params).map(&:id)).to eq([january.id, february.id, march.id])
end

it 'inherits the defaults in a subclass without leaking additions to the parent' do
model.default_filters(sort: '-value_date')
subclass = Class.new(model)
subclass.default_filters(value_date: { after: '2026-01-15' })

expect(subclass.filterable({}).map(&:id)).to eq([march.id, february.id])
expect(model.default_filter_params).not_to have_key(:value_date)
end

it 'ignores a non-hash declaration' do
model.default_filters(42)

expect(model.default_filter_params).to be_empty
end
end

describe '.filterable_declarations' do
it 'is valid when every declaration maps onto a real column' do
validator = MovementDetail.filterable_declarations
Expand Down
Loading