Saturday, January 10, 2009

Named Scopes in Views (Part 1)

Names scopes are awesome. What makes them so awesome is that named scopes are named. They have a label that means something in the current domain.

Granted the Model is Model and View is View and never the twain shall meet. Models should never intrude into the View and this includes named scopes. But...

This does not mean that scope names should stay out of views—they have just as much meaning in the view as they do in the model. If we can exploit the convention of using the same names, so much the better.

Consider a post class with some sweet named scopes:
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 > ?", Time.now.beginning_of_month]}}

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

def published?; status == "published" end
def draft?; status == "draft" end
def archived?; status == "archived" end
end
Like I said, sweet. You can ask for all posts that are published (Post.published), drafted (Post.draft), or even published or drafted together (Post.published_or_draft). You can sort published posts by title (Post.published.by_title). You can even ask for all published posts from this month, sorted by title (Post.published.this_month.sort_by_title).

Ah, the power of named scopes.

It is easy to see how these would come in handy when listing posts (as in an index page). Once we have lots of posts, it will be quite convenient to filter & sort the list to find exact posts. Consider the following generated view code:
<form action="/posts" method="get">
<div id="filter-by-status">
<select name="scopes[]">
<option value="published_or_draft">Published or Draft</option>
<option value="published">Published</option>
</select>
</div>

<div id="filter-by-date">
<select name="scopes[]">
<option value="this_month">This Month</option>
<option value="last_month">Last Month</option>
<option value="last_year">Last Year</option>
</select>
</div>

<div id="sort">
<select name="scopes[]">
<option value="sort_by_title">Title</option>
<option value="sort_by_updated_at">Update Date</option>
</select>
</div>
</form>

Nice, clean HTML with meaningful names. No magic numbers to represent showing published and draft posts at the same time. No need to worry about too many text field names—everything is in scopes[].

Even in the face of all this named scope goodness, the controller remains simple:
class PostsController < ApplicationController
def index
scopes = params[:scopes].reject(&:blank?)
scopes = [:all] if scopes.blank?
@posts = param_scopes.inject(Post){|proxy, scope| proxy.send(scope)}
end
end
Simple, but perhaps warranting some explanation. First, we reject any blank scopes. If no scopes remain, then we are left with the default of :all posts.

The inject statement exploits the chaining nature of named scopes. To see this, consider the case in which scopes == [:published, :sort_by_title]. The first iteration through the inject would set the named scope proxy as the Post class itself. By sending it :published, we return a (published) named scope for assignment in the next iteration. In that next iteration, the :sort_by_title named scope is sent, returning a named scope chain equivalent to:
Post.published.sort_by_title
An astute reader might note that we risk an attacker sending arbitrary messages to our base class. Make no mistake, this is a grave risk. Fortunately, it is easy enough to prevent by selecting only the scopes that are known to the Post class:
class PostsController < ApplicationController
def index
# After rejecting blank scopes, map the remaining to symbols
# so that they can be selected from the list of known Post scopes
scopes = params[:scopes].reject(&:blank?).map(&:to_sym).select{|s| Post.scopes.include?(s)}
scopes = [:all] if scopes.blank?
@posts = param_scopes.inject(Post){|proxy, scope| proxy.send(scope)}
end
end
The only scopes allowed are those known by the Post class as a scope (and not a method).

Scope injection attack thwarted!

We have added a lot of power to our posts controller's index action. Even with that power, we still have a very simple controller, a view with one parameter namespace for all sorting and filtering options that we like, and no magic number coupling between the view and controller. Best of all, we have a bunch of re-usable named scopes in the model.

In part 2, we will add a little bit of complexity to handle named scopes that take arguments.

3 comments:

  1. i really like this idea of passing named scope from the view.

    Very nice for filtering, ordering, searching


    Scope injection attack FTW

    ReplyDelete
  2. @dasch: That's why the article ends with the discussion of scope injection attack prevention, no?

    ReplyDelete