The awesomest filter and sort ever

Courtenay : August 26th, 2008

Update 2: seems like only one or two people knew about what can_search does :) I hope we’re all a little better educated.

Update: yes, I’m using these named scopes throughout the app in other places – they aren’t used only in this one controller.

Often you have an index action where you want to sort records, filter by a parameter, and maybe join on some other tables to get a result. Let’s say you’re looking at a videos controller (where videos are acts_as_taggable) and you want to filter by user_id, filter by tag name, order by video title, or rating. Maybe later, you’ll add a roles (hm:t) association and need to only show videos viewable by a certain user. How complex!

To solve this, we’re going to play with some things you may know, and finish up with a bam! pow! that’ll take your breath away.

Rather than build up some form of frankenquery with all sorts of conditionals and cases, joins, and other messing about, let’s use a brand-new bleeding edge feature of Rails: named scopes.

First, build up individual named scopes for each axis on which you wish to filter. Make sure and put the table name in that query.



    named_scope :by_user, lambda { |user_id| 
      { :conditions => ['videos.user_id = ?', user_id] }
    }

    named_scope :tag_name, lambda { |tag_name|
      { :joins => { :taggable => :tag },
      { :conditions => ['tags.name = ?', tag] }
    }

    named_scope :rating, lambda { |rating| 
      { :conditions => ['ratings_count > ?', rating] }
    }

OK, I cheated on the last one, but let’s assume you have a counter_cache on ratings count.

Now, if you have more than one scope with joins in it, you’ll need to apply this patch to your rails installation, or upgrade past 2.1.1. This will allow you to have as many joins as you like in your scopes.

Now, here’s where the magic happens: in the controller. Big shout out to protocool for this method. Let’s build up a set of all the possible scopes that we might want to use, in an array form like [ named_scope, argument ]

def index
  scopes = []
  scopes << [ :by_user, params[:user_id] ] if params[:user_id]
  scopes << [ :tag_name, params[:tag_name] ] if params[:tag_name]
  scopes << [ :rating, params[:rating] ] if params[:rating]
end

Easy, right? Very readable.

How about some ordering?

  order = { 'name' : 'videos.name ASC' }[params[:order]] || 'videos.id DESC'

Now, as you know, you can chain named scopes. So you could say Video.by_user(2).tag_name('monkeys') Let's take advantage of this, building up a chain of scopes dynamically using 'inject', starting from Video, and adding each scope we added to the array above. This is really fun magic, because it doesn't run any of the queries until the whole thing is built. I don't even know how this works, but it does. Swimmingly.

  @videos = scopes.inject(Video) {|m,v| m.scopes[v[0]].call(m, v[1]) }.paginate(:all, :order => order)

The final method looks like this:

def index
  scopes = []
  scopes << [ :by_user, params[:user_id] ] if params[:user_id]
  scopes << [ :tag_name, params[:tag_name] ] if params[:tag_name]
  scopes << [ :rating, params[:rating] ] if params[:rating]

  order = { 'name' : 'videos.name ASC' }[params[:order]] || 'videos.id DESC'

  @videos = scopes.inject(Video) {|m,v| m.scopes[v[0]].call(m, v[1]) }.paginate(:all, :order => order, :page => params[:page])
end

One final caveat. Sometimes :joins doesn’t know where to get the video id from, so if you’re using id in your app, you’ll need a slight workaround involving manually getting the pagination count, and forcing :select => &#8216;distinct videos.*&#8217; in the paginate call.

If this works for you, it’s really easy to add new filtering, ordering, or even scoping to your query. For example, you can add some form of role hackery to your video


    named_scope :viewable_by, lambda { |user| 
      { :joins => { :permissions => :roles },
        :conditions => [ "roles.user_id = ? AND permissions.role = ?", user.id, "view"
    }

Controller, you replace the first scope definition with this

scopes = [ :viewable_by, current_user ]

Or, you modify the scope inject statement


    @videos = scopes.inject(Video.viewable_by(current_user)) { |m,v| ... }

If you consider this a giant hack, you’re probably at least partly right. However, the alternative in building up a complex query with many possible moving parts is just hideous. And consider this: you can unit test each part of the query on its own, in the model specs.

12 Responses to “The awesomest filter and sort ever”

  1. rick Says:

    Hey, my can_search plugin was written to handle this kind of stuff. It doesn't have scope types for tags and ratings, but that can be easily added.

    http://github.com/technoweenie/can_search/tree/master

    Dude, I wrote this plugin for you :(

  2. Garry Says:

    It starts to get a little bit complex in the controller, but I like it nonetheless!

  3. Tom Says:

    Cute, but kind of perverse -- the inject is no more compact (and less clear) than just starting out with scope = Video and conditionally reassigning it (e.g. scope = scope.byuser(params[:userid]) if params[:user_id]) before calling scope.paginate at the end.

    In other words, why go to the trouble of packaging up a bunch of message selectors and arguments into an array if all you're going to do is iterate through the array unpacking and sending those messages? Ruby has perfectly lovely message sending syntax.

  4. Leigh Says:

    Not sure if this is the right place to ask this (as it's only semi-related), but I applied the patch mentioned here, and tried chaining together named scopes with joins in them. It appears as though joins in later scopes clobber the joins in previous ones. Is this behavior other people are seeing as well?

  5. Tim Says:

    Really cool idea. Rick's plugin nails it though.

  6. Rhonda Starke Says:

    +1 for the can_search plugin (or any other plugin for that matter).

    There's no point in creating a named scope if you're only going to use it in one place. You could as well use a regular 'find'.

    Your search code litters the controller to an your inject stunt further obscures the code.

    It's the named scopes that are awesome, not what you make of it.

  7. Leigh Says:

    Wrong patch was listed for getting multiple joins to work.

    Here's the right one: http://rails.lighthouseapp.com/projects/8994/tickets/501-merge-joins-instead-of-clobbering-them

  8. Dr Nic Says:

    @courtenay + rick ... don't you two sit next to each other? :P

  9. Ryan Bates Says:

    If you want to build up scopes conditionally, check out my scope builder plugin:

    http://github.com/ryanb/scope-builder/tree/master

  10. rick Says:

    dr nic: courtenay's just giving me shit for not actually blogging about can_search anywhere. Follow my github activity feed, damnit :)

    No srsly, point taken.

  11. Fredrik W Says:

    Here's my take on it (published in the comments to Ryan's Railscast about anonymous scopes):

    http://pastie.org/231954

  12. Gabe da Silveira Says:

    Here's a completely different approach I took with an app that had extensive searching capabilities:

    http://darwinweb.net/articles/48-implementingadvancedsearchin_railsusingsearchmodels

    Definitely needs some cleanup, but the idea of a search itself getting a model provided a nice way to abstract all search logic out of the controller entirely.

Sorry, comments are closed for this article.