My Profile Photo

Fabio Pitino


full stack software developer & code crafter, musician & food lover


Enhanced Filterable concern for Rails models

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:

  1. Create a scope in the dedicated model class.
  2. Permit the scope in the controller’s parameters for the filter method.
  3. 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:

  1. There was no need to create extra controller tests.
  2. It helped enforcing security as only the search scopes were automatically permitted.
  3. 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.
comments powered by Disqus