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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 58 additions & 4 deletions lib/filterable/concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions spec/filterable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading