Reusable filtering concern for Rails models

I wanted to implement a generic filtering concern for Rails models, and decided to do things a bit differently from what Justin Weiss and Fabio Pitino did.

Tags : Ruby on Rails Concerns

Published: June 28, 2022

Justin Weiss laid the foundation for a Filterable concern in 2016 in Search and Filter Rails Models Without Bloating Your Controller.

Then I read Fabio Pitino’s post (Enhanced Filterable concern for Rails models) in which he describes a way to improve it using a search_scope class method. It makes it clear in the model which scopes are used for filtering, and simplifies listing the permitted params.

However, I found that Fabio’s code didn’t work in Rails 6 (the search_scope wasn’t initialized on time), and naming didn’t quite reflect what was happening.

Here is what I ended up doing, building upon Justin and Fabio’s work.

  1. Adjust the name and syntax of the concern for clarity:

    # app/models/concerns/filterable.rb
    module Filterable
      extend ActiveSupport::Concern
    
      class_methods do
        attr_reader :filter_scopes
    
        def filter_scope(name, *args)
          scope name, *args
          @filter_scopes ||= []
          @filter_scopes << name
        end
    
        def filter_by(params)
          filtered = all
          params.permit(*filter_scopes).each do |key, value|
            filtered = filtered.public_send(key, value) if value.present?
          end
          filtered
        end
      end
    end
    
  2. Include this concern in all models inheriting from ApplicationRecord.

    # app/models/application_record.rb
    class ApplicationRecord
      include Filterable
    end
    
  3. Add filtering scopes in the models that need them.

    # app/models/widget.rb
    class Widget < ApplicationRecord
      filter_scope :foo, { where(foo: "bar") }
    end
    
  4. Apply filters in controllers.

    # app/controllers/widgets_controller.rb
    class WidgetsController < ApplicationRecord
      def index
        @widgets = Widget.filter_by(params)
      end
    end
    

I’m still not completely satisfied with this solution because it seems a little crude, so I looked into HeartCombo’s has_scope gem for inspiration. Controllers need to declare which scopes can be used in that gem, which adds noticeable overhead.

I can see a few ways to improve this pattern:

  • Automating filtering with a before_action in controllers, like CanCanCan does in load_and_authorize_resource.
  • Creating a FormObject which form_for could use to preselect form values from the current request query.
  • Hardening security, which has_scope does more preemptively, to prevent SQL injections.
  • Simplify querying boolean scopes, and those which take more than one parameter (start/end date for instance).