Building a complex filtering system with Ruby on Rails

by Elvinas Predkelis X

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.

We love big ideas and ambitious people

Reach out to us and let's build something great together

Schedule an exploration call