diff --git a/README.md b/README.md index b8ecdf0..a4f0205 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,23 @@ 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: + +```ruby +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 diff --git a/lib/filterable/concern.rb b/lib/filterable/concern.rb index 83bec32..01f63ac 100644 --- a/lib/filterable/concern.rb +++ b/lib/filterable/concern.rb @@ -70,14 +70,21 @@ def default_filters(defaults) # nested +filters+ params merged over the declared defaults. Returns +all+ # untouched when neither carries anything usable. # + # The optional context hash is handed, as given, to every filter whose + # callable accepts a third argument — +Model.filterable(params, user_id: + # current_user.id)+ — so a custom filter can depend on the caller without + # global state. Two-argument filters, the built-in ones included, never + # see it. + # # @api public # @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+. + # @param context [Hash] caller-provided context for context-aware filters. # @return [ActiveRecord::Relation] the filtered relation. - def filterable(params = {}) + def filterable(params = {}, context = {}) filters_params = effective_filters(params) return all if filters_params.empty? - filters.reduce(all) { |scope, filter| filter.call(filters_params, scope) || scope } + filters.reduce(all) { |scope, filter| apply_filter(filter, filters_params, scope, context) || scope } end # Reports which filters {filterable} would actually apply from the same @@ -90,14 +97,15 @@ def filterable(params = {}) # # @api public # @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+. + # @param context [Hash] caller-provided context for context-aware filters. # @return [ActiveSupport::HashWithIndifferentAccess] public name => accepted value. - def applied_filters(params = {}) + def applied_filters(params = {}, context = {}) filters_params = effective_filters(params) report = {}.with_indifferent_access return report if filters_params.empty? filters.select { |filter| filter.respond_to?(:applied) } - .reduce(report) { |acc, filter| acc.deep_merge(filter.applied(filters_params, all)) } + .reduce(report) { |acc, filter| acc.deep_merge(report_from(filter, filters_params, context)) } end # A validator checking every declaration against the model's columns, meant @@ -111,6 +119,52 @@ def filterable_declarations private + # Calls one filter, handing it the context when its +call+ accepts a + # third argument. + # + # @api private + # @param filter [#call] the registered filter. + # @param filters_params [ActiveSupport::HashWithIndifferentAccess] the effective +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being narrowed. + # @param context [Hash] the caller-provided context. + # @return [ActiveRecord::Relation, nil] the filter's return value. + def apply_filter(filter, filters_params, scope, context) + if context_aware?(filter, :call) + filter.call(filters_params, scope, context) + else + filter.call(filters_params, scope) + end + end + + # Collects one filter's report, handing it the context when its +applied+ + # accepts a third argument. + # + # @api private + # @param filter [#applied] the registered filter. + # @param filters_params [ActiveSupport::HashWithIndifferentAccess] the effective +filters+ params. + # @param context [Hash] the caller-provided context. + # @return [Hash] the filter's report. + def report_from(filter, filters_params, context) + if context_aware?(filter, :applied) + filter.applied(filters_params, all, context) + else + filter.applied(filters_params, all) + end + end + + # Whether a filter's callable accepts a third positional argument — an + # exact three-parameter signature, or a negative arity whose required + # parameters leave room for one (optional argument or splat). + # + # @api private + # @param filter [Object] the registered filter. + # @param method_name [Symbol] +:call+ or +:applied+. + # @return [Boolean] + def context_aware?(filter, method_name) + arity = filter.is_a?(Proc) ? filter.arity : filter.method(method_name).arity + arity == 3 || (arity.negative? && arity >= -4) + end + # 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 diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index 95f3728..3632ce1 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -553,6 +553,66 @@ def self.broken end end + describe 'call context' do + let!(:mine) { MovementDetail.create!(reference: 'MINE') } + + it 'hands the context to a block filter declaring a third parameter' do + model = Class.new(MovementDetail) do + add_filter do |filters_params, scope, context| + scope.where(reference: context[:ref]) if filters_params[:mine] + end + end + + result = model.filterable({ filters: { mine: '1' } }, ref: 'MINE') + expect(result.map(&:id)).to eq([mine.id]) + end + + it 'hands the context to an object filter with an optional third parameter' do + custom = Module.new do + def self.call(filters_params, scope, context = {}) + scope.where(reference: context[:ref]) if filters_params[:mine] + end + end + model = Class.new(MovementDetail) + model.add_filter(custom) + + result = model.filterable({ filters: { mine: '1' } }, ref: 'MINE') + expect(result.map(&:id)).to eq([mine.id]) + end + + it 'keeps two-parameter filters untouched by the context' do + result = MovementDetail.filterable({ filters: { value_date: { after: '2026-02-01' } } }, user_id: 42) + + expect(result).to contain_exactly(february, march) + end + + it 'defaults the context to an empty hash' do + model = Class.new(MovementDetail) do + add_filter do |filters_params, scope, context| + scope.where(reference: context.fetch(:ref, 'NONE')) if filters_params[:mine] + end + end + + expect(model.filterable(filters: { mine: '1' })).to be_empty + end + + it 'hands the context to a context-aware applied' do + custom = Module.new do + def self.call(filters_params, scope, context = {}) + scope.where(reference: context[:ref]) if filters_params[:mine] + end + + def self.applied(filters_params, _scope, context = {}) + filters_params[:mine] ? { mine: context[:ref] } : {} + end + end + model = Class.new(MovementDetail) + model.add_filter(custom) + + expect(model.applied_filters({ filters: { mine: '1' } }, ref: 'MINE')[:mine]).to eq('MINE') + end + end + describe '.applied_filters' do it 'returns an empty report when no filters are given' do expect(MovementDetail.applied_filters({})).to be_empty