Are you angry every time you see mutating instance variables in rails controller to filter data? This article is for you. 🙂
Filters
You have probably seen this before:
# app/controllers/api/v1/things_controller.rb
module API
module V1
class ThingsController < BaseController
def index
@things = Thing.all
@things = @things.where(size: params[:size]) if params[:size]
@things = @things.where('name ILIKE ?', "%#{params[:name_contains]}%") if params[:name_contains]
render json: @things
end
end
end
end
Why do I consider it to be a bad code? Because it simply makes our controller fat. IMHO we should extract as many logic as we can from controllers and use a purpose-related utils or services. In this case we will implement a generic filter that we will be able to use across many controllers.
But wait, first let’s analyse the current code. It can be bad but works, though. We have some initial scope (Thing.all) and then are limiting it if user has passed related parameter. For each filter we actually check if the param was passed and if so, we apply a filter. The second thing is that we don’t need to use the ivar, we can use plain old local variables.
Ok, then. Couldn’t we use some service object to mutate the initial scope? The execution can look like this:
# app/controllers/api/v1/things_controller.rb
module API
module V1
class ThingsController < BaseController
def index
scope = Thing.all
things = Things::IndexFilter.new.call(scope, params)
render json: things
end
end
end
end
It looks much better now, but of course we have to implement the filter yet. Note that call’s signature will be the same for all resources, so we can have some generic class for this task.
# app/services/generic/index_filter.rb
module Generic
class IndexFilter
EMPTY_HASH = {}.freeze
def self.filters
EMPTY_HASH
end
def call(scope, params)
apply_filters!(self.class.filters.keys, scope, params)
end
private
def apply_filters!(filter_keys, scope, params)
filter_keys.inject(scope.dup) do |current_scope, filter_key|
apply_filter!(filter_key, current_scope, params)
end
end
def apply_filter!(filter_key, scope, params)
filter = fetch_filter(filter_key)
return scope unless apply_filter?(filter, params)
filter[:apply].call(scope, params)
end
def apply_filter?(filter, params)
filter[:apply?].call(params)
end
def fetch_filter(filter_key)
self.class.filters.fetch(filter_key) { raise ArgumentError, 'unknown filter' }
end
end
end
Seems complicated? Not really – all the magic happens in #apply_filters!. We take the duplicate of the initial scope and apply each filter to it.
When we apply the scope it means we mutate the duplicate of our initial scope. And we expect filters to be implemented as a hash in the self.filters method of a child class. Let’s do it.
# app/services/things/index_filter.rb
module Things
class IndexFilter < Generic::IndexFilter
FILTERS = {
size_filter: {
apply?: ->(params) {
params[:size].is_a?(String)
},
apply: ->(scope, params) {
scope.where(size: params[:size])
}
}.freeze,
name_contains_filter: {
apply?: ->(params) {
params[:name_contains].is_a?(String)
},
apply: ->(scope, params) {
scope.where('name ILIKE ?', "%#{params[:name_contains]}%")
}
}.freeze
}.freeze
def self.filters
FILTERS
end
end
end
That’s it! We have written more code, but the simple filters will look the same way for all the resources. We have cleaned controller from the code responsible of filtering and provided ‘specialised’ class for this purpose that follows very clear convention.