Thursday, July 23, 2009

Looking into rails named_scope

named scope?

Nick Kallen’s has_finder plugin has found it’s way into rails 2.x as named_scope. Named scope is all about scoping or narrowing the database query.

Let me go through an example to make things easy.

class Post < ActiveRecord::Base
  belongs_to :author,   :class_name => 'User'
  has_many   :comments, :dependent  => :destroy

  named_scope :published,   :conditions => { :is_published => true }
  named_scope :draft,       :conditions => { :is_published => false }
  named_scope :has_comment, :conditions => [ 'comments_count > 0' ]

  named_scope :recent,    lambda { |*date| {:conditions => { :created_at => date.first || 1.weeks.ago } } }
  named_scope :before,    lambda{ |date| { :conditions => ['created_at > ?',  date] } }
end
You can scope your query and make it more readable.
Post.published #equivalant to Post.all.published

You can also scope on finder methods like,

Post.find(:all, :conditions => { :created_at => 1.weeks.ago }).published

You can use finder on scope like,

Post.published.find(:all, :conditions => { :created_at => 1.weeks.ago })

Even scope on the relations.

class Comment < ActiveRecord::Base
  belongs_to :post
  belongs_to :author, :class_name => 'User'

  named_scope :unread, :conditions => {:is_unread => true}
  named_scope :read, :conditions => {:is_unread => false}
end

So now you can write like,

Post.find(id).comments.unread

Chaining

Now would not it be wonderful if you could write like “Get all posts which are recently published” and it would return you what it means.

Using named_scope that dream will come true ;). You can finally write code like this,

Post.recent.published

You can chain up to your hearts content ;). You can do all sort of combination like,

Post.recent.published.has_comment

Which will return you posts which are recently published and has comments. See how much readable the code has become.

Passing Arguments

You can pass arguments using lambda.

Post.published.before(1.days.ago)

Named Scope Extensions

Extend named scope and add methods to it.

class Post < ActiveRecord::Base
  named_scope :draft,       :conditions => { :is_published => false } do
    def publish
      each { |post| post.update_attribute(:is_published, true) }
    end
  end
end
Post.recent.draft.publish

This will find all recent draft posts and publish them.

Anonymous Scopes

Creating named scopes on the fly.

# Store named scopes
published = Post.scoped(:conditions => { :is_published => true })
recent    = Post.scoped(:conditions => { :created_at => 1.weeks.ago })

# Which can be combined
recent_published = recent.published

Why use named scope?

  • Increases readability
  • Reduces number of queries as it can be chained
  • Very helpful for dynamic queries(like using pagination or dynamic filter)

A common issue

Named scope is a class methods. And you should always be very careful for class methods. Here is an example where most people does it wrong.

class Post < ActiveRecord::Base
  belongs_to :author,   :class_name => 'User'
  has_many   :comments, :dependent  => :destroy
  
  named_scope :recent,  :conditions => { :created_at => 1.weeks.ago } 
end

Now I can call Post.recent and expect to get all Posts within one week. But in times you will find that this query is starting to give wrong results.

Why???!!!

Named scope is a class method and so 1.weeks.ago is evaluated when the class was loaded. Now it does not matter when you call the named scope it will always run the query with the same created_at date. We also made this mistake :( and learned from it :)

A lambda block is not evaluated when the class is loaded. So there is a work around it.

class Post < ActiveRecord::Base
  belongs_to :author,   :class_name => 'User'
  has_many   :comments, :dependent  => :destroy

  named_scope :recent,    lambda { |*date| {:conditions => { :created_at => date.first || 1.weeks.ago } } }
end

Taking the lambda input like this will give you flexibility of not providing the parameter at all. For now ruby does not provide facility to take default parameter for lambda.

Deep inside named scope

The implementation of Named scope is a beauty of rails. Although the result that named scope produces seems like an Array, but actually it is not an Array but a simulation of an Array. Calling a named scope over a finder object actually returns a ActiveRecord:NamedScope:Scope object. When we chain scopes on that object, appends the query logic in the scope object. The query is not evaluated until an Array method(like each, first, last) etc is called. And when these Array methods are called the scope object then evaluates the query and delegates the method which was called as an array to the real array that has been evaluated. Now you can not chain the scope any more, as it is already been evaluated. That is how the named scope magic works in ruby on rails.

Reference

http://ryandaigle.com/articles/2008/3/24/what-s-new-in-edge-rails-has-finder-functionality

http://railscasts.com/episodes/108-named-scope

http://apidock.com/rails/ActiveRecord/NamedScope/ClassMethods/named_scope

5 comments:

Unknown said...

Really nice and helpful one.

Unknown said...

Thank you for sharing a comprehensive post on named_scope. Its interesting that using hard coded conditions with date_time may yield wrong results.
Other than date time, I think its fine to use hardcoded values instead of lambdas in named scopes.

Jitu said...

Using hard coded values in named scope for dynamic data(which can change over time), will give wrong results. But on the contrary it is efficient for the static values as it is evaluated once on project load.

rubyhunt42 said...

Very helpful info about named_scope 'wrong results' when date is used without lambda.

Unknown said...

Very well said, thanks a lot for sharing. There are many ROR developers in India. Cryptex Technologies is have experts ROR developers. They are having 9 years of experience on ROR and experts in developing web and mobile app. If you need any help from us email at: info@cryptextechnologies.com