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 => ‘distinct videos.*’ 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”
Sorry, comments are closed for this article.
August 26th, 2008 at 02:07 PM
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 :(
August 26th, 2008 at 03:24 PM
It starts to get a little bit complex in the controller, but I like it nonetheless!
August 26th, 2008 at 03:27 PM
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.
August 26th, 2008 at 07:53 PM
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?
August 27th, 2008 at 12:16 AM
Really cool idea. Rick's plugin nails it though.
August 27th, 2008 at 01:18 AM
+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.
August 27th, 2008 at 03:12 PM
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
August 27th, 2008 at 05:30 PM
@courtenay + rick ... don't you two sit next to each other? :P
August 28th, 2008 at 09:18 AM
If you want to build up scopes conditionally, check out my scope builder plugin:
http://github.com/ryanb/scope-builder/tree/master
September 6th, 2008 at 03:15 AM
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.
September 8th, 2008 at 01:31 AM
Here's my take on it (published in the comments to Ryan's Railscast about anonymous scopes):
http://pastie.org/231954
September 9th, 2008 at 07:48 PM
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.