At some point in every web programmer’s life, you will be asked to implement a web form that allows users to apply a set of filters to a table of data.

Depending on the quantity and type of form filter parameters being sent, it can be easy to end up with a sloppy, inflexible solution. You know the client is going to come back in three months and ask for more filters, right? (Just nod your head with me because we both know they will.)

We recently had a client that had a table of orders that needed to be filtered in three optional, combinable ways:

  • order state – can be “pending”, “processing”, or “processed”, or left blank to show all orders regardless of state
  • archived – by default, the table should show non-archived orders, but clicking a “show archived orders” link, it will show just the archived orders
  • created date (using a start date, end date range) –
    This can be a start date with no end date (“show me all the orders created after this date”), an end date with no start date (“show me all the orders created before this date”), or both a start date and end date (“show me all the orders created between these two dates”).

Always Start Naively

Since the filtering will be done in the controller, we’ll start with the path most often taken and simply chain ActiveRecord::Relations.

# app/controllers/orders_controller.rb
def index
  @orders = current_user.orders
              .where(state: params[:state])
              .where(archived: params[:archived])
              .where(created_at: params[:start_date]..params[:end_date])
              .order(created_at: :desc)

Unfortunately, this code does not meet our needs. When a parameter is not present, it would be nil which would cause some queries to be flat out wrong (“show me all the order states where the state is null”) and others to actually throw an error (“show me all the orders with a creation date from one year ago through null”). In addition, our archived scope should always be applied, but it needs to default to the correct value (true or false) depending on what is passed in.

The other significant issue is that we are missing conditionally applied scopes. For instance, we only want to apply an order state scope when the appropriate form parameter is passed in.

Sifting

Let’s take tackle these two issues by moving this logic into a new class we’ll call OrderSifter. It’s responsibility is to take controller parameters (that you have already filtered using Strong Parameters because you are an amazingly diligent developer, right?) and provide access to any ActiveRecord::Relation that it might need using those parameters.

# app/services/order_sifter.rb
class OrderSifter
  attr_reader :params

  def initialize(params)
    @params = params
  end

  def state
    if specific_state = params[:state].presence
      Order.where(state: specific_state)
    end
  end

  def archived
    show_archived_only = (params[:archived] == "true")
    if show_archived_only
      Order.archived
    else
      Order.unarchived
    end
  end

  def created_at
    start_date = parse_date(params[:start_date])
    end_date = parse_date(params[:end_date])

    if start_date && end_date
      Order.where(created_at: start_date..end_date)
    elsif start_date
      Order.where("orders.created_at >= ?", start_date)
    elsif end_date
      Order.where("orders.created_at <= ?", end_date)
    end
  end

  private

  def parse_date(str)
    Date.parse(str) if str.present?
  end
end

This is a step in the right direction. The OrderSifter is easily testable and knows the Right Thing to Do™ based on the form parameters that were provided. Each method is simple because it does a query on unscoped Orders without having to know about any other filters that need applied. If a query shouldn’t be performed at all, the method will return nil.

Chaining and Merging

So, how do we use this to our advantage? We use the often forgotten ActiveRecord#merge feature which provides the intersection of two ActiveRecord::Relations.

pry(main)> Order.where("orders.created_at < ?", 1.day.ago)
                 .merge(Order.where(state: "processed")).to_sql

=> "SELECT "orders".* FROM "orders" WHERE
      (orders.created_at < '2016-11-17 16:58:58.697322')
      AND "orders"."state" = 'processed'"

The interesting thing about merge is that it does a no-op if you merge nil. This makes it perfect for us to still chain our Sifter scopes that we do not want applied.

[39] pry(main)> Order.where("orders.created_at < ?", 1.day.ago)
                      .merge(nil).to_sql

=> "SELECT "orders".* FROM "orders" WHERE
      (orders.created_at < '2016-11-17 17:00:10.598348')"

The not-so-great-thing about merge is that it only takes a single ActiveRecord::Relation as an argument which means you cannot do this:

pry(main)> Order.where("orders.created_at < ?", 1.day.ago)
                 .merge(Order.where(state: "processed"),
                        Order.where(state: "drafted")).to_sql

ArgumentError: wrong number of arguments (2 for 1)

So, let’s add a class method to our Order model that lets us pass multiple scopes to be merged.

# app/models/order.rb
def self.apply_scopes(*filters)
  filters.inject(scoped) do |scope_chain, filter|
    scope_chain.merge(filter)
  end
end

And now we can rewrite our controller method to take advantage of all this awesomesauce.

# app/controllers/orders_controller.rb
def index
  sifter = OrderSifter.new(order_params)
  @orders = current_user.orders
            .apply_scopes(sifter.state, sifter.archived, sifter.created_at)
            .order(created_at: :desc)
end

Treat Yourself

We now end up with a controller method that still allows chaining scopes, but with the power of being conditionally applied. Since only one database query is performed here, there is no performance sacrifice.

This pattern has served us well in solving a very common problem in a flexible, easily understood manner. We hope it serves you well, too!

technology logo

Get a Free Consultation

Your Free Consultation will be packed full of discussions, brainstorming, and hopefully, excitement. The meeting is designed to help uncover your challenges, define your needs, and outline possible solutions so you can make decisions that will lead to the business outcomes you desire.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.