1. Skip to navigation
  2. Skip to content

The ELC Community Blog

A knowledge exchange on Ruby on Rails and Agile Development


Investigating named_scope

by dpalm on September 12, 2008

This morning I spent quite a while going through the source code that implements named_scopes in ActiveRecord 2.1. The code is short and kind of interesting in itself, so give it a read if you're interested! Kudos to rails-core for getting this in.

What I wanted to achieve is being able to write code like:

   1  milk = Product.find_by_name('milk')
   2  customer.orders.placed.since(1.week.ago).for(milk)

Using vanilla named_scopes works fine for most of the above, but I ran into a gotcha when I tried to add a non-database-based named scope.
I had something like this:

   1  class Cart < ActiveRecord::Base
   2   named_scope :placed, {:conditions => ["status IN (?)", ["Ordered", "Closed"]}
   3   named_scope :since, lambda{|date| {:conditions => ["created_at >= ?", date]} }
   4  end

With that in place I can do:

   1  customer = Customer.first
   2  customer.orders.placed.since(1.week.ago)

Sweet.

Now I needed:

   1  beef = Product.find(123)
   2  customer.orders.placed.since(1.week.ago).for(beef)

If "for" could have been solved by some SQL I could have added it as another bunch of conditions. In my case it's not that simple though ("products" sit on LineItems, and are grouped by categories that are considered equivalent if X, Y and Z are all true and the stars are correctly aligned...) and I need some ruby code in there to check what product a given order was for.

You can extend named_scopes just like with has_many associations, by passing a block or using the ":extend" option:

   1  class Cart < ActiveRecord::Base
   2   named_scope :placed, {:conditions => ["status IN (?)", ["Ordered", "Closed"]}
   3   named_scope :since, lambda{|date| {:conditions => ["created_at >= ?", date]} } do
   4  	def for(product)
   5  	  # stuff
   6  	end
   7   end
   8  end

And here's the gotcha: the scope inside for() changes if you call the named_scope and the :extended method from the class or from a has_many association, i.e.:

   1  Order.placed.since(1.week.ago).for(beef)

doesn't give you the same "self" as:

   1  customer.orders.placed.since(1.week.ago).for(beef)

In actuallity, it's not "self" that is different, but the "proxy_scope". In the former case proxy_scope is the Order class, in the latter it's the Array of orders placed by our customer.

Not what I expected.
:-(

To make it work for both I had to do:

   1  collection = proxy_scope.is_a?(Array) ? proxy_scope : self.send(:load_found)

'load_found' is the private instance method on ActiveRecord::NamedScope::Scope (the "self" inside the for() method above) that actually goes out and retrieves the collection for all the named_scopes.

Not so pretty, but working.

Final version:

   1  class Cart < ActiveRecord::Base
   2   named_scope :placed, {:conditions => ["status IN (?)", ["Ordered", "Closed"]}
   3   named_scope :since, lambda{|date| {:conditions => ["created_at >= ?", date]} } do
   4  	def for(product)
   5  	  collection = proxy_scope.is_a?(Array) ? proxy_scope : self.send(:load_found)
   6  	  collection.reject do |order|
   7  	    # crazy stuff to find out if it's in our out
   8  	  end
   9  	end
  10   end
  11  end

Comments

junior at 2:17 AM on September 18 2008

cool stuff

Duncan Beevers at 7:12 PM on September 26 2008

It’s a little gnarly forcing yourself to only use ‘for’ directly after a .since scope.

Instead, you could just make ‘for’ a class method that invoked a regular find.

class Cart < ActiveRecord::Base def self.for product find(:all).reject do |order| # crazy stuff to find out if it’s in or not end end end

Normally I wouldn’t recommend doing a huge find and then stripping results out in ruby. Instead, I’d typically try and keep a list of ids handy for checking inclusion in specific groups, created using that funky magic, and then using inclusion in that list of ids as an additional condition.

class Cart < ActiveRecord::Base named_scope :for, lambda { |product| { :conditions => { :category_id => product.category_ids } } } end

But the code for generating that list of ids might just be too gnarly. Anyway, if you can use a couple of simple queries to construct the where condition for your main query, you’ll typically be better off that pulling a whole load of records into memory and then culling.

Plus, you keep it chainable!

Add a comment


home | services | Ruby on Rails Development | code | blog | company