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
Timeline
- Specing Thread Safety In Rspec
- Transform BitmapData into JPEGs (AS3, JPGEncoder, Rails, Rmagick)
- In the Land of the Magic Kingdom you will find... SPORTS!
- ELC Expands to NYC
- Create a jQuery plugin with a spin
- Investigating named_scope
- Liquid Coolness
- Ehcache for JRuby / Rails
- JS Routes plugin
- AWS-S3 gem extensions and Amazon's Copy API
- Warble with Console
Comments
cool stuff
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!