Useful #with_scope technique
I just stumbled upon something very interesting. In my application, we accept anonymous testimonials from the web, but they must not be shown until they have been reviewed and approved by an administrator.
The first API I designed was this:
test/unit/testimonial_test.rb
1 def test_can_get_approved_only 2 testimonials = Testimonial.find(:completed) 3 assert_equal [@approved_testimonial], testimonials 4 end
That worked well enough, even after I started adding code to take care of :all and :first, and modifying #count to also use the same scoping rules.
But then, I hit a snag with scoped #has_many accesses. The following failed:
test/unit/testimonial_test.rb
1 def test_can_count_approved_testimonials_for_a_single_contact 2 testimonials = @contact.testimonials.count(:completed) 3 assert_equal 1, testimonials 4 end
This generated an invalid statement:
1 test_can_count_approved_testimonials_for_a_single_contact(TestimonialTest::AbilityToCountTest): 2 ActiveRecord::StatementInvalid: Mysql::Error: Unknown column 'approved' in 'where clause': SELECT count(*) AS count_all FROM testimonials WHERE (testimonials.contact_id = 1 AND (approved))
I did a bit of debugging and found that #has_many had already modified the #count parameters even before my #count_with_extensions method was called. So I decided to change my API. I decided I wanted something like this:
test/unit/testimonial_test.rb
1 def test_can_get_approved_only 2 testimonials = Testimonial.completed.find 3 assert_equal [@approved_testimonial], testimonials 4 end
This new API first defines the scope, then calls a normal ActiveRecord::Base method. My first implementation was:
app/models/testimonial.rb
1 def self.completed 2 with_scope(:find => {:conditions => ["approved_at IS NOT NULL"]}) do 3 yield self 4 end 5 end
When I ran my tests, my error was immediately apparent:
1 test_can_get_approved_only(TestimonialTest::AbilityToCountTest): 2 LocalJumpError: no block given
Ooops… So I thought a bit more. What I wanted to return from #completed was an object that could stand in for my class (Testimonial in this case), but for which the scope had been defined. I ended up with this implementation:
app/models/testimonial.rb
1 def completed 2 returning Object.new do |proxy| 3 class << proxy 4 def method_missing(method, *args) 5 Testimonial.with_scope(:find => {:conditions => ["approved_at IS NOT NULL"]}, 6 :create => {:approved_at => Time.now}) do 7 Testimonial.send(method, *args) 8 end 9 end 10 end 11 end 12 end
What this implementation does is:
- It starts by creating a proxy object;
- It redefines
#method_missingon that object; - The new
#method_missingmethod creates the scope we originally wanted and calls the method we were originally targetting; - Then the proxy object is returned.
With that implementation, I get something nice:
1 $ rake test:recent 2 (in /home/francois/src) 3 /usr/local/bin/ruby -Ilib:test "/usr/local/lib/ruby/gems/1.8/gems/rake-0.7.2/lib/rake/rake_test_loader.rb" "test/unit/testimonial_test.rb" 4 Started 5 ............... 6 Finished in 3.041738 seconds. 7 8 15 tests, 31 assertions, 0 failures, 0 errors