Filtering with named scopes
- June 12th, 2009
- Posted in Ruby . Ruby on Rails . Tips
- Write comment
Suppose you have an index page with people and you want to have a series of neat filters to show a selection of people. For example only the people still alive of only the adults. How would one do that?
I like the method of using a named_scope and delegating to specified filters. This way, you can structure your filters properly and get clean URLs. Also, you can chain other named scopes to the filter.
This is an example of how I would do that.
The view
In your index view, add a list of all filters:
%h3= t(:people, :scope => :filter_titles)
%ul
- Person.available_filters.each do |filter|
%li= link_to t(filter, :scope => [:filter_names, :people]), people_path(:filter => filter)
This will generate links that go to your index page (e.g. /people?filter=adults). You can even make a route that will clean up your views even more.
map.connect "/people/filter/:filter", :controller => "people", :action => "index"
I use i18n to get the displayed link text for each link, so my locale file might look something like:
en:
filter_titles:
people: Select a subset
filter_names:
people:
deceased: Select deceased people
alive: Select people that are (still) alive
adults: Select people over 18
The controller
Add the named_scope to your query:
def index @people = Person.filter(params[:filter]).paginate(:page => params[:page]) end
The model
Here’s the interesting stuff. Define the available filters as a class method:
def self.available_filters [ :deceased, :alive, :adults ] end
Then, define class methods for each those filters, specifying what they need to do. I like to prepend them with “filter_“, so it shows more intent. You can go crazy with these filter methods if you’d like. Just return valid ActiveRecord find-options.
def self.filter_deceased
{ :conditions => "deceased_on IS NOT NULL" }
end
def self.filter_alive
{ :conditions => "deceased_on IS NULL" }
end
def self.filter_adults
{ :conditions => ["birthday <= ?", 18.years.ago.to_date] }
end
And finally, add the named scope that uses these filters:
named_scope :filter, lambda { |f| available_filters.include?(f) ? send("filter_#{f}") : {} }
We check to see if the filter is available, excluding any invalid filter. Also, by default no filter is given from the controller. Then params[:filter] will be nil and so it won’t try to call Person.filter_. You can replace the empty hash with a default filter if you like.
Conclusion
These predefined filters can really help the usability of your new fancy web application. And I like the code too, because it looks very clear and it’s easy to test.
Named scopes can get quite messy, certainly if you use a lambda and some logic. Delegating the body of the lambda to a class method is a good idea. Just be sure that the method returns a hash of some sort.
named_scope :foo, lambda { |*args| foo_parameters(*args) }
You can make this into a named_scope generator even, but I’ll save that for another time and post. Also, stay tuned for the encore: DRYing up the code for re-use!
Nice article, but I’m wondering if this is the right way to go. The purpose of named_scope is to defined named scopes. Just defining a filter scope which accepts a name for the scope isn’t really the way to go in my opinion.
You can find my approach on the BlueTools weblog (dutch). As you can see, I’m using a new named_scope for each filter. Only for the advanced filtering in which the user can define the conditions for the filter, I’m using the approach you are taking.
@Edwin I understand your concern.
I like the way it works in DataMapper, in which you can chain any method. The query only gets executed once you try to read a record from the set.
There is a proper use and balance for everything. So with this. I like the flexibility of it, because you can use the “filter_*”-methods in any place.
And I don’t think controllers is the place to use “send”. I like to keep the more rubyesque methods out of there. But then again, there is no absolute rule for anything.