diff --git a/README.md b/README.md index 598f6f7..1112b91 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/filterable/concern.rb b/lib/filterable/concern.rb index 51516b6..83bec32 100644 --- a/lib/filterable/concern.rb +++ b/lib/filterable/concern.rb @@ -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 @@ -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 @@ -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. # diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index dadd4ef..eabd67f 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -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