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:
1
2
3
4
|
def test_can_get_approved_only
testimonials = Testimonial.find(:completed)
assert_equal [@approved_testimonial], testimonials
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:
1
2
3
4
|
def test_can_count_approved_testimonials_for_a_single_contact
testimonials = @contact.testimonials.count(:completed)
assert_equal 1, testimonials
end |
This generated an invalid statement:
1
2
|
test_can_count_approved_testimonials_for_a_single_contact(TestimonialTest::AbilityToCountTest):
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:
1
2
3
4
|
def test_can_get_approved_only
testimonials = Testimonial.completed.find
assert_equal [@approved_testimonial], testimonials
end |
This new
API first defines the scope, then calls a normal ActiveRecord::Base method. My first implementation was:
1
2
3
4
5
|
def self.completed
with_scope(:find => {:conditions => ["approved_at IS NOT NULL"]}) do
yield self
end
end |
When I ran my tests, my error was immediately apparent:
1
2
|
test_can_get_approved_only(TestimonialTest::AbilityToCountTest):
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:
1
2
3
4
5
6
7
8
9
10
11
12
|
def completed
returning Object.new do |proxy|
class << proxy
def method_missing(method, *args)
Testimonial.with_scope(:find => {:conditions => ["approved_at IS NOT NULL"]},
:create => {:approved_at => Time.now}) do
Testimonial.send(method, *args)
end
end
end
end
end |
What this implementation does is:
- It starts by creating a proxy object;
- It redefines
#method_missing on that object;
- The new
#method_missing method 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
2
3
4
5
6
7
8
|
$ rake test:recent
(in /home/francois/src)
/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"
Started
...............
Finished in 3.041738 seconds.
15 tests, 31 assertions, 0 failures, 0 errors |
October 12th, 2007 at 10:25 PM
That’s an interesting approach. You could also just define find_complete on the association, as well. I talk about this technique in a post—http://errtheblog.com/post/42
October 12th, 2007 at 10:25 PM
I really like the scope_out plugin for this.
http://code.google.com/p/scope-out-rails/