Skip to content

Commit 6355b80

Browse files
authored
Merge pull request #20 from fluence-eu/feat/filter-context
feat(concern): hand a call context to context-aware filters
2 parents b2592b7 + 668a8d7 commit 6355b80

3 files changed

Lines changed: 135 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,23 @@ end
293293

294294
A filter returning `nil` leaves the scope untouched, so guard clauses are safe.
295295

296+
A filter whose callable accepts a **third argument** also receives the call
297+
context — an optional hash handed to `filterable` after the params — so it can
298+
depend on the caller without global state:
299+
300+
```ruby
301+
add_filter do |filters, scope, context|
302+
scope.where(author_id: context[:user_id]) if filters[:mine] && context[:user_id]
303+
end
304+
305+
Post.filterable(params, user_id: current_user.id)
306+
```
307+
308+
The context is passed as given and defaults to `{}`; two-argument filters —
309+
the built-in ones included — never see it, and an `applied` accepting a third
310+
argument receives it the same way. A filter its context cannot satisfy narrows
311+
nothing.
312+
296313
### Applied filters introspection
297314

298315
Since an unknown or malformed filter is a silent no-op, `applied_filters` is how

‎lib/filterable/concern.rb‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,21 @@ def default_filters(defaults)
7070
# nested +filters+ params merged over the declared defaults. Returns +all+
7171
# untouched when neither carries anything usable.
7272
#
73+
# The optional context hash is handed, as given, to every filter whose
74+
# callable accepts a third argument — +Model.filterable(params, user_id:
75+
# current_user.id)+ — so a custom filter can depend on the caller without
76+
# global state. Two-argument filters, the built-in ones included, never
77+
# see it.
78+
#
7379
# @api public
7480
# @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+.
81+
# @param context [Hash] caller-provided context for context-aware filters.
7582
# @return [ActiveRecord::Relation] the filtered relation.
76-
def filterable(params = {})
83+
def filterable(params = {}, context = {})
7784
filters_params = effective_filters(params)
7885
return all if filters_params.empty?
7986

80-
filters.reduce(all) { |scope, filter| filter.call(filters_params, scope) || scope }
87+
filters.reduce(all) { |scope, filter| apply_filter(filter, filters_params, scope, context) || scope }
8188
end
8289

8390
# Reports which filters {filterable} would actually apply from the same
@@ -90,14 +97,15 @@ def filterable(params = {})
9097
#
9198
# @api public
9299
# @param params [Hash, ActionController::Parameters] request params, read at +params[:filters]+.
100+
# @param context [Hash] caller-provided context for context-aware filters.
93101
# @return [ActiveSupport::HashWithIndifferentAccess] public name => accepted value.
94-
def applied_filters(params = {})
102+
def applied_filters(params = {}, context = {})
95103
filters_params = effective_filters(params)
96104
report = {}.with_indifferent_access
97105
return report if filters_params.empty?
98106

99107
filters.select { |filter| filter.respond_to?(:applied) }
100-
.reduce(report) { |acc, filter| acc.deep_merge(filter.applied(filters_params, all)) }
108+
.reduce(report) { |acc, filter| acc.deep_merge(report_from(filter, filters_params, context)) }
101109
end
102110

103111
# A validator checking every declaration against the model's columns, meant
@@ -111,6 +119,52 @@ def filterable_declarations
111119

112120
private
113121

122+
# Calls one filter, handing it the context when its +call+ accepts a
123+
# third argument.
124+
#
125+
# @api private
126+
# @param filter [#call] the registered filter.
127+
# @param filters_params [ActiveSupport::HashWithIndifferentAccess] the effective +filters+ params.
128+
# @param scope [ActiveRecord::Relation] the relation being narrowed.
129+
# @param context [Hash] the caller-provided context.
130+
# @return [ActiveRecord::Relation, nil] the filter's return value.
131+
def apply_filter(filter, filters_params, scope, context)
132+
if context_aware?(filter, :call)
133+
filter.call(filters_params, scope, context)
134+
else
135+
filter.call(filters_params, scope)
136+
end
137+
end
138+
139+
# Collects one filter's report, handing it the context when its +applied+
140+
# accepts a third argument.
141+
#
142+
# @api private
143+
# @param filter [#applied] the registered filter.
144+
# @param filters_params [ActiveSupport::HashWithIndifferentAccess] the effective +filters+ params.
145+
# @param context [Hash] the caller-provided context.
146+
# @return [Hash] the filter's report.
147+
def report_from(filter, filters_params, context)
148+
if context_aware?(filter, :applied)
149+
filter.applied(filters_params, all, context)
150+
else
151+
filter.applied(filters_params, all)
152+
end
153+
end
154+
155+
# Whether a filter's callable accepts a third positional argument — an
156+
# exact three-parameter signature, or a negative arity whose required
157+
# parameters leave room for one (optional argument or splat).
158+
#
159+
# @api private
160+
# @param filter [Object] the registered filter.
161+
# @param method_name [Symbol] +:call+ or +:applied+.
162+
# @return [Boolean]
163+
def context_aware?(filter, method_name)
164+
arity = filter.is_a?(Proc) ? filter.arity : filter.method(method_name).arity
165+
arity == 3 || (arity.negative? && arity >= -4)
166+
end
167+
114168
# The request filters merged over the evaluated defaults — the single
115169
# params shape handed to every filter by {filterable} and
116170
# {applied_filters}, so the two cannot drift. A key present in the

‎spec/filterable_spec.rb‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,66 @@ def self.broken
553553
end
554554
end
555555

556+
describe 'call context' do
557+
let!(:mine) { MovementDetail.create!(reference: 'MINE') }
558+
559+
it 'hands the context to a block filter declaring a third parameter' do
560+
model = Class.new(MovementDetail) do
561+
add_filter do |filters_params, scope, context|
562+
scope.where(reference: context[:ref]) if filters_params[:mine]
563+
end
564+
end
565+
566+
result = model.filterable({ filters: { mine: '1' } }, ref: 'MINE')
567+
expect(result.map(&:id)).to eq([mine.id])
568+
end
569+
570+
it 'hands the context to an object filter with an optional third parameter' do
571+
custom = Module.new do
572+
def self.call(filters_params, scope, context = {})
573+
scope.where(reference: context[:ref]) if filters_params[:mine]
574+
end
575+
end
576+
model = Class.new(MovementDetail)
577+
model.add_filter(custom)
578+
579+
result = model.filterable({ filters: { mine: '1' } }, ref: 'MINE')
580+
expect(result.map(&:id)).to eq([mine.id])
581+
end
582+
583+
it 'keeps two-parameter filters untouched by the context' do
584+
result = MovementDetail.filterable({ filters: { value_date: { after: '2026-02-01' } } }, user_id: 42)
585+
586+
expect(result).to contain_exactly(february, march)
587+
end
588+
589+
it 'defaults the context to an empty hash' do
590+
model = Class.new(MovementDetail) do
591+
add_filter do |filters_params, scope, context|
592+
scope.where(reference: context.fetch(:ref, 'NONE')) if filters_params[:mine]
593+
end
594+
end
595+
596+
expect(model.filterable(filters: { mine: '1' })).to be_empty
597+
end
598+
599+
it 'hands the context to a context-aware applied' do
600+
custom = Module.new do
601+
def self.call(filters_params, scope, context = {})
602+
scope.where(reference: context[:ref]) if filters_params[:mine]
603+
end
604+
605+
def self.applied(filters_params, _scope, context = {})
606+
filters_params[:mine] ? { mine: context[:ref] } : {}
607+
end
608+
end
609+
model = Class.new(MovementDetail)
610+
model.add_filter(custom)
611+
612+
expect(model.applied_filters({ filters: { mine: '1' } }, ref: 'MINE')[:mine]).to eq('MINE')
613+
end
614+
end
615+
556616
describe '.applied_filters' do
557617
it 'returns an empty report when no filters are given' do
558618
expect(MovementDetail.applied_filters({})).to be_empty

0 commit comments

Comments
 (0)