Filtering, sorting, paging... oh, my!
posted by andy, Thu Aug 13 15:00:00 UTC 2009
The Problem
What do you do when your end user needs to filter? In some cases the answer is easy: provide a custom method for the sort criteria. For example, when you know that your end user wants to know "my jobs" as distinct from "all jobs" a custom 'mine' method might work just fine. Don't worry that the custom routes are not RESTful; they are, they are just not the default Rails notion of REST. That's okay; Rails is not necessarily right.
The filtering that I run into is rarely that simple. Right or wrong end users tend to think more options is better. In the context of filtering that means they often want to filter by a combination of ad-hoc attribute values. Clearly a single custom method won't work there because you can't out guess the combination and permutations that a given user might want to use.
Sorting and paging further complicate the issue. Even when you can pick the 'right' values the user may want them back in the 'right' order. Paging is just a small wrinkle on top of that but yet another piece to layer in.
The Context
The most frequent context for the type of filtering/sorting/paging that I'm talking about is when the user is presented with a list of things that needs to be pared down and organized before the user can make any sense out of it. Maybe they're looking at a list of members in a club or a set of subscriptions or a bunch of invoices. Whatever the case, you've given them a set of things to review in the order you thought best but it doesn't make sense to them. Rather than haggle over who's right (you... of course!), you decide to give them a filtering pane and some click-to-sort headers in the grid. Great. The UI is done. But where does it all go? Where do you send the values?
More often than not, this context leads me right back to where it started. You began presenting the user a list that naturally flowed through the #index action of your controller. Why should the filtered or sorted or paged list be any different? It's still just a list, isn't it?
The Solution
In my mind the answer to that question is yes. A filtered list is still just a list so if the original list came from the index action of my controller then the filtered version of it should come that way, too.
The way that I've settled on implementing most of these scenarios lately is through a combination of named scopes, anonymous scopes, and some recommendations from Uncle Bob's Clean Code.
Scopes
Named scopes and anonymous scopes are hardly new territory. Named scopes came into Rails at version 2.1 courtesy of a very popular plugin. The idea behind named scopes, if you're not familiar with them, is that you create a method that 'scopes' a query against your ActiveRecord-based model. This is really handy if you have a scope like 'current' or 'active' that you can use over and over in your application; you just declare it once on the class and use it wherever you need it.
class Order < ActiveRecord::Base
named_scope :recent, {:limit=>10, :order=>'id DESC'}
named_scope :since, lambda{|date| {:conditions=>['created_at > ?', date]}}
end
Order.recent
#=> [...up to 10 orders]
Order.since(1.month.ago)
#=> [... all orders created in the last month ...]
Just as a quick refresher in case you're not using named scopes (as you should be!), you create a named scope on a class by calling the named_scope method in the class definition. The first parameter to the method is the name of the 'scope' and the second argument is either a hash of find options or a lambda that returns a hash of find options.
Anonymous scopes are pretty much the same thing. Really, the only difference is that named scopes are not saved against the model. You create them only where you need them and let the Garbage Collector have them when you are done with them.
Order.scoped( {:limit=>10, :order=>'id DESC'} )
#=> same results as Order.recent
What makes named and anonymous scopes so useful for the type of ad hoc filtering and sorting that we're considering is that you can chain them. By chaining named and anonymous scope calls you build up the query conditions. Thus you do not have to worry too much about the what combination of filering, sorting, and paging the user might want, you simply accumulate them. The query is not actually run against the database until you first try to use the data.
Order.recent.since(1.week.ago)
#=> Up to ten orders from the last week
Clean code
So what does clean code have to do with this? In the third chapter of his 2009 book by the same name, Bob Martin makes an excellent case for code that's organized into a series of very tiny, top-down methods. This, again, is not really new territory for most developers. Martin just pushes it to an end that I'd not really considered before and the discussion of the top-down organization somehow clicked with me in a way that it had not before.
The clean code approach to the filtering/sorting/paging problem would look at the demonstration in Ryan Bates' Railscast* and say it's too big. The filtering should be refactored into it's own method, so that the index can focus solely on getting the right type of view to render out the values. That filtering method should be broken down into a series of smaller methods, each of which performs one specific type of filtering/sorting/paging.
class ProductsController < ApplicationController
def index
filter_products
sort_products
respond_to do |format|
format.html # render index.html.erb
format.xml { render :xml=>@products.to_xml }
end
end
private
def filter_products
initialize_scope
filter_by_name
filter_by_category
filter_by_price
end
def initialize_scope
@products = Product.scoped {}
end
def filter_by_name
return if params[:name].blank?
@products = @products.scoped :conditions=>[ 'name LIKE ?', "#{params[:name]}%" ]
end
def filter_by_price
filter_by_minimum_price unless params[:minimum_price].blank?
filter_by_maximum_price unless params[:maximum_price].blank?
end
def filter_by_minimum_price
return if params[:minimum_price].blank?
@products = @products.scoped :conditions=>['price < ?', params[:minimum_price]]
end
def filter_by_maximum_price
return if params[:maximum_price].blank?
@products = @products.scoped :conditions=>['price < ?', params[:maxiumum_price]]
end
def filter_by_category
return if params[:category_id].blank?
@products = @products.scoped, :conditions=>{:category_id=>params[:category_id]}
end
end
Conclusion
Okay, so nothing too earth-shattering if you've been deep into Rails for a while but hopefully a help if you're a relative novice. Have we gained anything? Sure, we've gained a few things. The use of named and anonymous scopes has greatly simplified our ability to respond to the user who desires to do ad hoc filtering against our data. They also make sorting and paging relatively simple -- just add an appropriate scope that users :order, :limit, and :offset appropriately and you can sort or page any of the target result sets the user could dream up. Just as importantly the top-down organization of very tiny (generally private) methods makes the top-most levels of the program (the index and initial filter method) read somewhat like a newspaper article: the headline (index) describes the main event, the first paragraph (#filter) provides the most important details, and the subsequent 'paragraphs' (lower methods in the chain) progressively reveal the implementation details. We have a simple solution to a problem with many combinations that should be easy to read and maintain in the future.
A side benefit
This approach saved me a lot of work earlier today. I had a rather inelegant solution to a particular problem in which I needed to filter and sort a list of members. The twist was that I needed the exact same sorting and filtering for two completely different contexts with very different views representing the results. The initial approach was to send the filtering request to the #index method of the MembersController; it seemed like a natural place to organize the member-specific logic. The method chose the view for rendering the results based on the 'commit' value (the name of the submit button). Unfortunately, a certain "non-modern browser" was not supplying the commit value. Ugh!
How did we keep it DRY? Simple. Since the code was organized with this top-down approach I simply extracted the methods from the controller class into a module and included the module in the two controllers that needed the search logic. The big benefit was that I only needed to move the lower-level functions; the higher functions (e.g., #index) remained in place. Actually the #index method was simplified since it could now focus on rendering the list of Members in only one way; the other controller picked up the single call to filter and rendered the results as it needed to. Simple code, simple refactoring, DRY solution.
Anyone have a glass of water?
* No offense intended to Ryan or his Railscast, which was focused on the mechanics of using anonymous scopes and immensely helpful in helping me get a handle on how to use them.