If you have ever tried to filter Rails models via a RESTful controller, you’ve probably read Justin Weiss’s great article about making models filterable without bloating the controllers.
I owe him most of the credits for this post because not only his idea greatly helped simplifying codebases I worked on but it became a must-have Concern for almost all Rails applications I’ve been working on since. 🙏 Thank you!
The idea is to allow a list of parameters, provided via the controller, to be used for filtering ActiveRecord models. This behavior is defined in a single-purpose Filterable
concern as below (Justin’s code):
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
module ClassMethods
def filter(filtering_params)
results = self.where(nil)
filtering_params.each do |key, value|
results = results.public_send(key, value) if value.present?
end
results
end
end
end
# app/models/post.rb
class Post < ApplicationRecord
include Filterable
...
end
And then used in the controller with a single method:
# app/controllers/posts_controller.rb
def index
@posts = Post.filter(params.slice(:status, :published_after, :author))
end
This approach worked greatly until one day I happened to work on an advanced search page for an application. We were allowing users to execute fine grained searches on a number of domain models using various input fields.
Every time we wanted to add a new filter to the page we had to:
- Create a scope in the dedicated model class.
- Permit the scope in the controller’s parameters for the
filter
method. - Add the relative view code such as input tags, select tags, etc.
It quickly became difficult to maintain the filters especially remembering to go through all the 3 steps.
In fairness, step #1 is hard to miss because it’s normally the first behavior to be implemented and it’s also the core of the feature.
Step #3 might be optional if implementing JSON API or, if in presence of a frontend app, we may have a wireframe and strong user expectations.
Step #2, permitting the parameter in the controllers, for my experience it was easy to forget.
I just found it hard to remember! 😅
I use Test-Driven Development so I always end up having tests for the scopes and the view. However, I was not going to create controller tests for each scope we were exposing via the advanced search page. Are these controller tests (often slow) really needed?
What if we could automatically update the permitted filters in the controller?
I wanted the controller to use some sort of class method that provides the parameters allowed for the searching. I called it search_params
and I thought we could splat them inside the params.slice
method in the controller.
# app/controllers/posts_controller.rb
def index
@posts = Post.filter(filter_params)
end
private
def filter_params
params.slice(*Post.search_params)
end
At this point we could make the Filterable
concern able to hold a list of scopes of a certain type, which we could call search_scopes
.
We could define a class method search_scope
which delegates to the ActiveRecord’s scope
and also register itself for later use, in the controller.
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
included do
@search_scopes ||= []
end
module ClassMethods
attr_reader :search_scopes
def search_scope(name, *args)
scope name, *args
@search_scopes << name
end
def filter(params)
# same as before
end
end
end
With this simple change we could easily migrate the model scopes that we wanted to expose via the controller by simply changing scope
with search_scope
.
# app/models/post.rb
class Post < ApplicationRecord
search_scope :status, -> (status) {
where(status: status)
}
search_scope :published_after, -> (date) {
where('published_at >= ?', date)
}
# some other scopes we do not want to expose via the controller
scope :private, -> { where(private: true) }
...
end
Conclusions
Not only this enhancement removed entirely any maintenance in the controllers, it brought other positive side effects:
- There was no need to create extra controller tests.
- It helped enforcing security as only the search scopes were automatically permitted.
- It became a signpost for all other developers as the scope needed to be treated with a certain attention (like prevent SQL injection) as it was a publicly exposed one.