Monday, January 12, 2009

Named Scopes in Views (Part 2)

In part 1, we explored the use of named scopes in views (more precisely named scoped names). The named scopes that we used were relatively simple in that they took no arguments.

But what if we have a requirement to filter blog posts by substrings in the title? The named scope would be something of the form:
named_scope :with_title, lambda { |*args| {:conditions => ["title like ?", '%'+args[0]+'%']} }
This makes our entire post class look like this:
class Post < ActiveRecord::Base
named_scope :published, :conditions => {:status => 'published'}
named_scope :draft, :conditions => {:status => 'draft'}
named_scope :archived, :conditions => {:status => 'archived'}

named_scope :published_or_draft, :conditions => ["status in ('published', 'archived')"]

named_scope :last_month, lambda { {:conditions => ["updated_at > ?", 1.month.ago]} }
named_scope :last_year, lambda { {:conditions => ["updated_at > ?", 1.year.ago]} }
named_scope :this_month, lambda { {:conditions => ["updated_at > ?",]}}

named_scope :sort_by_title, :order => "title DESC"
named_scope :sort_by_updated, :order => "updated_at DESC"

named_scope :with_title, lambda { |*args| {:conditions => ["title like ?", '%'+args[0]+'%']} }

def published?; status == "published" end
def draft?; status == "draft" end
def archived?; status == "archived" end
Recall from part 1 that we passed all our scopes into the controller in the form:
{:scopes => ["published", "sort_by_title"]}

Ideally, I would like to introduce parameterized named scopes to the controller in the form:
{:scopes => [ ["published"],
["with_title", "foo"] ]}
If such a data structure were easily built in forms, then we could almost use the controller code from part 1 without change. Sadly, that is not quite possible.

As a workaround, I choose the convention of passing in named scopes with parameters as hashes.
<div id="filter-by-title-substring">
<input type="text" name="scopes[][with_title]"/>
This will give us parameters of the form:
{:scopes => [ "published",
{"with_title" => "foo"} ]}
Since we are already adding complexity, why not generalize a bit as well? The following code will handle the list-of-strings-and-hashes data structure, plus it will work for any class:
class PostsController < ApplicationController
def index
@posts = scopes_for(Post)

def scopes_for(klass)
scopes_from_params.inject(klass){|proxy, scope| proxy.send(*scope)}

def scopes_from_params
returning scopes = [] do
(params[:scopes] || []).reject(&:blank?).each do |scope|
case scope.class.to_s
when "String"
scopes << [scope] if klass.scopes.include?(scope.to_sym)
when "Hash", "HashWithIndifferentAccess"
msg, arg = scope.first # {:foo => 'bar'}.first => [:foo, "bar"]
scopes << [msg, arg] if klass.scopes.include?(msg.to_sym) && !arg.blank?

scopes.blank? ? [:all] : scopes
The scopes_for() private method handles the proxy scope injection that was done entirely in the index action from part 1.

The actual scope calculation is now done in the scopes_from_params method. It still checks the supplied scopes to ensure that they are scopes and not arbitrary methods. It still returns the :all scope if no scopes are supplied. But it also handles our "Hash" case for invoking named scopes with arguments.

The last change is back in the scopes_for method, which now splats the scopes returned from scopes_from_params. If we access the action with the parameters:
{:scopes => [ "published",
{"with_title" => "foo"} ]}
We will get the following back from scopes_from_params():
[ ["published"],
["with_title", "foo"] ]
Injecting these into Post in scopes_for() will result in Post.send("published").send("sort_by_title").send("with_title", "foo"), which is equivalent to Post.published.sort_by_title.with_title("foo)—exactly what we want.

We now have all the benefits from part 1 (simple views, simple controllers, no magic number coupling between view & controller to handly combo cases like showing published and drafted posts at the same time), plus the ability to use named scopes that take arguments.

No comments:

Post a Comment