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
# 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.
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
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
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.
 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
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!