Blog by Railsware

Criteria pattern applied for organizing complex filtering

When a complex filtering solution is required to be built, the code becomes a mess more often than not. Filtering records is a straightforward task on its own: just apply the required ActiveRecord scopes based on certain conditions of the given parameters. However, when dealing with intricate systems, such as platforms powering sports betting sites not on gamstop, these conditions can escalate in complexity, requiring robust and scalable solutions to ensure accurate filtering and seamless performance. As a result, the code often spreads across controllers, models, and logic layers, making it hardly readable and maintainable. Further down, I’ll describe a simple approach that will help avoid such annoyances.

First off, we want to delegate all the filtering, ordering, pagination and other collection presentation stuff to a separate class so that controller code became as simple as possible (example code uses CanCan gem, which is augmenting collection pattern usage):

class LeadsController < ApplicationController
  def index
    # current_ability – CanCan method
    collection = LeadsCollection.new(current_ability, current_user, params)
    @leads = collection.eager_loaded.paginated.items
  end
end

I assume that this is not a single occurrence of collection functionality in the system. So, common behavior can be extracted to some base class:

class BaseCollection
  attr_reader :ability, :user, :params

  module Authorization
    def items
      # CanCan stuff
      super.accessible_by(ability)
    end
  end

  module Ordering
    def items
      if order_key
        super.order(ordering_arel)
      else
        super
      end
    end

    private

    def ordering_arel
      self.arel_table(order_key.to_sym).send(order_dir)
    end

    def order_key
      params[:order_key]
    end

    def order_dir
      params[:order_dir] =~ /\Adesc\z/i ? :desc : :asc
    end
  end

  module Pagination
    def items
      super.page(params[:page]).per(params[:per])
    end
  end

  module Search
    def items
      # Assuming search_for is implemented for model
      super.search_for(params[:search])
    end
  end
end

Here comes Criteria Pattern in play. The idea is to encapsulate collection aspects into small manageable pieces of code and chain them by means of extending collection object with those modules:

class LeadsCollection < BaseCollection
  def initialize(ability, user, params)
    @ability = ability
    @user    = user
    @params  = params

    extend Authorization, Ordering, Pagination, Search
  end
end

Certainly, my example above is oversimplified and does not differ from the base class. Let’s add some functionality to make this example have a bit more sense:

class LeadsCollection < BaseCollection

  module LeadsStatusScope
    def items
      # Here and below some scopes are applied. 
      # We assume they all properly implemented in model
      super.in_status(params[:lead_status])
    end
  end

  module DepartmentScope
    def items
      if department_id_given?
        super.with_department(department_id)
      else
        super
      end
    end

    private

    def department_id
      params[:department]
    end

    def department_id_given?
      department_id && Department.where(id: department_id).exists?
    end
  end

  module RoleScope
    def items
      params[:role].present? ? super.with_role(params[:role]) : super
    end
  end

  module HasQualificationScope
    def items
      if params[:has_qualification].present?
        super.with_qualification(params[:has_qualification])
      else
        super
      end
    end
  end

  def initialize(ability, user, params)
    @ability = ability
    @user    = user
    @params  = params

    extend Authorization,
           Ordering, 
           Search, 
           LeadStatusScope, 
           DepartmentScope, 
           RoleScope, 
           HasQualificationScope
  end

  def items
    # default scope for collection can be applied here
    Lead.scoped_for_user(user, params[:scope])
  end
end

For the sake of making the code reusable (e. g. for reports, exports etc.) some modules can be applied by calling separate method. You can see it in the first code insert for controller: methods #paginated and #eager_loaded. Let’s add them to our collection:

class LeadsCollection < BaseCollection

  ...

  def EagerLoaded
    def items
      super.includes(:details, :matches, :comments)
    end
  end

  ...

  def eager_loaded
    extend EagerLoaded
    self
  end

  def paginated
    # Pagination module is defined in BaseCollection class
    extend Pagination
    self
  end
end

Sure thing, you have to be accurate with chaining #items methods. Some things will not work together, e.g. using include for eager loading in one module and defining custom select fields in another and then chaining them together.

So, this is it. I hope this collection approach will come in handy in your work.

Exit mobile version