Building a filtering system is a common feature while building any kind of web application. As the codebase grows, the filtering system might become more complex. In this article, we’ll explore how to build a dynamic filtering system in Ruby on Rails.
As an example, we’ll try to create a filtering system for an Linkedin-like application.
Filterable models
For this example we’ll assume two models: Person
and Company
. We’ll be attaching the filtering logic to these entities later in the article.
The minimal setup for these models would look something like this:
# app/models/post.rb
class Person < ApplicationRecord
# Concerns
include Filterable
# Scopes
scope :visible, -> { where(visible: true) }
end
# app/models/company.rb
class Company < ApplicationRecord
# Concerns
include Filterable
# Scopes
scope :visible, -> { where(visible: true) }
end
In this case, we add a Filterable
concern which will include all of the filtering logic. We’ll discuss this concern in the next section.
Concern
To make the filtering system more modular, we can extract the filtering logic into a concern. In that case, we’ll be able to just include the concern in any model we want to filter.
Querying
We’ll assume that both of the models will be filtered by these three parameters: industries
, location
, and tags
. We’ll take these parameters from the request and pass them to the #where
query method.
To make it more interesting, we’ll assume that the filterable attributes are associated with the Person
and Company
models. This means that we’ll have to query the associated records.
Company.where(tags: { name: params[:tags] })
Conditional chaining
Most of the filtering magic will happen due to Ruby’s #yield_self
method. Luckily, since Ruby 2.6, a more readable #then
alias was introduced.
The method yields self to the block and returns the result of the block, as per documentation. We’ll leverage this method to chain the filtering queries conditionally. As a result, we can construct a filtered_by
scope for filterable models.
scope :filtered_by, ->(params) {
self
.then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }
.then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }
.then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }
}
Eager Loading
Eager loading is a way to load all of the associated records in one go. This is useful when you have a lot of records and you want to avoid the N+1 query problem. However, when dealing with a composable filtering system, loading records eagerly might not be straightforward.
We’ll use #includes
method to load the associated records in one sweep. In our case, we’ll construct the arguments for the loading method dynamically.
def inclusion_arguments(params)
args = []
args << :industries if params[:industries].present?
args << :location if params[:location].present?
args << :tags if params[:tags].present?
args
end
Final concern
Now that we have all the pieces, we can combine them together. This should be the final concern that we can include in our models.
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
included do
scope :filtered_by, ->(params) {
includes(inclusion_arguments(params))
.then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }
.then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }
.then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }
}
scope :ordered, -> { order(created_at: :desc) }
end
private
def inclusion_args(params)
args = []
args << :industries if params[:industries].present?
args << :location if params[:location].present?
args << :tags if params[:tags].present?
args
end
end
Controller
Now that we have the filtering logic in place, we can use it in our controllers. We’ll assume that the filtering parameters are passed as query parameters.
For this example, we’ll have ResultsController
controller that will be responsible for rendering the filtered results of all kinds.
# app/controllers/results_controller.rb
class ResultsController < ApplicationController
def index
@people = Person.filtered_by(params).ordered
@companies = Company.filtered_by(params).ordered
end
end
Bonus: Overrides
In some cases, you might want to override the filtering logic for a specific model. For example, you might want to filter the Person
model by the language
attribute and have a different default ordering.
# app/models/concerns/person/filterable.rb
module Person::Filterable
extend ActiveSupport::Concern
included do
scope :filtered_by, ->(params) {
includes(inclusion_arguments(params))
.then { params[:languages].present? ? _1.where(languages: { name: params[:languages] }) : _1 }
.then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }
.then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }
.then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }
}
scope :ordered, -> { order(updated_at: :asc) }
end
private
def inclusion_args(params)
args = []
args << :languages if params[:industries].present?
args << :industries if params[:industries].present?
args << :location if params[:location].present?
args << :tags if params[:tags].present?
args
end
end
Note that we’ve added a namespace and placed the concern in a separate file.
Final remarks
The solution that was outlined in this article is a powerful yet flexible way to implement a filter system to your Rails application. This is especially useful for applications with multiple filterable models and larger datasets.
It avoids unnecessary database queries and is easy to maintain. Also, as a pleasant side effect, it saves a lot of headaches while developing the frontend.