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::Relation
s.
# 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::Relation
s.
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!