I remember my heart fluttering with a boyish crush the first time I saw Nick Kallen’s has_finder functionality make it into Rails in the form of named_scope. named_scope quickly made its way into my toolset as a great way to encapsulate reusable, chainable bits of query logic. While it had its downsides (namely its lack of first-class chain support for the likes of :joins and :include) it redefined how I thought about structuring my model logic. Once you taste the chainable goodness of named_scope you never go back.
So here we are with Rails 3 completely refactoring the internals of ActiveRecord - what’s up with our beloved named_scope? Well, the simple answer is that it’s been renamed to scope and you can use it just as you’re used to … but that’s taking the easy way out. Let’s see what else we can do with scope in Rails 3.
Basic Usage
Let’s assume a standard Post model with published_at datetime field along with title and body.
In Rails 2.x here’s how we’d have to define the self-explanatory published and recent named scopes:
class Post < ActiveRecord::Base named_scope :published, lambda { { :conditions => ["posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now] } } named_scope :recent, :order => "posts.published_at DESC" end
The reason we need to use a lambda here is that it delays the evaluation of the Time.zone.now argument to when the scope is actually invoked. Without the lambda the time that would be used in the query logic would be the time that the class was first evaluated, not the scope itself.
With Rails 3 the bulk of ActiveRecord is now based on the Relation class. Think of relation as named_scope on steroids, weaving chainable query logic into the very fabric of ActiveRecord.
Let’s see how - here’s how the two named scopes from our previous Post example will look in Rails 3:
class Post < ActiveRecord::Base scope :published, lambda { where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now) } scope :recent, order("posts.published_at DESC") end
class Post < ActiveRecord::Base scope :published, lambda { where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now) } scope :published_since, lambda { |ago| published.where("posts.published_at >= ?", ago) } scope :recent, published.order("posts.published_at DESC") end
class Post < ActiveRecord::Base class << self # Search the title and body fields for the given string. # Start with an empty scope and build on it for each attr. # (Allows for easy extraction of searchable fields definition # in the future) def search(q) [:title, :body].inject(scoped) do |combined_scope, attr| combined_scope.where("posts.#{attr} LIKE ?", "%#{q}%") end end end end
class Post < ActiveRecord::Base class << self # The less-slick but, perhaps, more obvious version def search(q) query = "%#{q}%" where("posts.title LIKE ?", query).where("posts.body LIKE ?", query) end end end
# What's in the db, titles ~= publish date Post.all.collect(&:title) #=> ["1 week from now", "Now", "1 week ago", "2 weeks ago"] Post.published.collect(&:title) #=> ["Now", "1 week ago", "2 weeks ago"] # Search combinations Post.search('1').collect(&:title) #=> ["1 week from now", "1 week ago"] Post.search('1').published.collect(&:title) #=> ["1 week ago"] Post.search('w').published_since(10.days.ago).collect(&:title) #=> ["Now", "1 week ago"] Post.search('w').order('created_at DESC').limit(2).collect(&:title) #=> ["2 weeks ago", "1 week ago"]
class User < ActiveRecord::Base has_many :posts, :foreign_key => :author_id has_many :comments # Get all users that have published a post scope :published, lambda { joins("join posts on posts.author_id = users.id"). where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now). group("users.id") } # Get all users that have commented on a post scope :commented, joins("join comments on comments.user_id = users.id").group("users.id") end
class User < ActiveRecord::Base # Get all users that have published a post scope :published, lambda { joins(:posts). # No need to write your own SQL where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now). group("users.id") } # Get all users that have commented on a post scope :commented, joins(:comments).group("users.id") # Just reference :comments end
# Get all users that have a post published User.published.collect(&:username) #=> ["tim", "dave"] User.published.to_sql #=> SELECT "users".* FROM "users" join posts on posts.author_id = users.id # WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-22 21:33:00.892308') # GROUP BY users.id # Get all users that have commented on a post User.commented.collect(&:username) #=> ["ryan", "john", "tim", "dave"] User.commented.to_sql #=> SELECT "users".* FROM "users" join comments on comments.user_id = users.id # GROUP BY users.id # Combine them to get all authors that have also commented User.published.commented.collect(&:username) #=> ["tim", "dave"] User.published.commented.to_sql #=> SELECT "users".* FROM "users" # join posts on posts.author_id = users.id # join comments on comments.user_id = users.id # WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-22 21:33:00.892308') # GROUP BY users.id
# Increment the views_count for all published posts Post.published.collect(&:views_count) #=> [59, 71, 42] Post.published.update_all("views_count = views_count + 1") Post.published.collect(&:views_count) #=> [60, 72, 43] # Nobody cares about unpublished posts Post.unpublished.size #=> 1 Post.unpublished.destroy_all Post.unpublished.size #=> 0
class Post < ActiveRecord::Base # Ludacris scope :titled_luda, where(:title => 'Luda') end
Post.titled_luda.size #=> 0 Post.titled_luda.build #=> #<Post id: nil, title: "Luda", ...>
class Post < ActiveRecord::Base scope :published, lambda { where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now) } end
class User < ActiveRecord::Base scope :published, lambda { joins(:posts). where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now). group("users.id") } end
class User < ActiveRecord::Base scope :published, lambda { joins(:posts).group("users.id") & Post.published } end
User.published.to_sql #=> SELECT users.* FROM "users" # INNER JOIN "posts" ON "posts"."author_id" = "users"."id" # WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-27 02:55:45.063181') # GROUP BY users.id