pure class

Posted by jcarroll

Jul 17

Specifying your domain model associations in Rails is all done in Ruby and looks nice.

Say in this app users can write reviews (it doesn’t matter what they’re reviewing, but we could say music):

1
2
3
4
5
6
7
8
9
10
11
class User < ActiveRecord::Base

  has_many :reviews

end

class Review < ActiveRecord::Base

  belongs_to :user

end

Now #has_many and #belongs_to are class methods available on ActiveRecord::Base subclasses that generate some instance methods for the subclass that provide support for the association.

I want a method on my User’s reviews association that gives me all a User’s Review s that have a rating of ‘good’. Something like:


  user.reviews.good

There’s a couple ways I can do this:

You can pass a block to #has_many:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class User < ActiveRecord::Base

  has_many :reviews do

    def good
      find :all,
        :conditions => ['rating = ?', Review::GOOD]
    end

  end

end

class Review < ActiveRecord::Base

  POOR, AVERAGE, GOOD = 0, 1, 2

  belongs_to :user

end

I’m not a huge fan of this because its syntactically ugly.

You can also put the extensions in a separate module and file and specify the module name as a parameter in the #has_many call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module UserExtensions

  def good
    find :all,
      :conditions => ['rating = ?', Review::GOOD]
  end

end

class User < ActiveRecord::Base

  has_many :reviews, :extend => UserExtensions

end

class Review < ActiveRecord::Base

  POOR, AVERAGE, GOOD = 0, 1, 2

  belongs_to :user

end

This is syntactically cleaner but now your User model behavior is spread over 2 files, since I put the module in a separate file in say (lib/user_extensions.rb).

Those 2 techniques are well documented and known. However, this 3rd one I’ve run into accidentally.

Define the extensions as class methods on the associated class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class User < ActiveRecord::Base

  has_many :reviews

end

class Review < ActiveRecord::Base

  POOR, AVERAGE, GOOD = 0, 1, 2

  belongs_to :user

  def self.good
    find :all,
      :conditions => ['rating = ?', Review::GOOD]
  end

end

Apparently what happens is that ActiveRecord wraps that call to Review#good in a block that’s passed to ActiveRecord::Base#with_scope in order to find only Review s for a specific User.

Something along the lines of:

1
2
3
User.with_scope(:find => { :conditions => ['user_id = ?', id] }) do
  Review.good
end

I’m not a fan of this 3rd way because I don’t like seeing a class method on Review that’s never directly called. It’s only indirectly called through a User’s Review association.


Comments on this post

Derek

Jul 17

Derek said,

So… you said you’re not exactly enamored with any of the methods you described above. Which design choice did you ultimately decide on?

Gaius

Jul 17

Gaius said,

So what syntax would you love? There’s always the option of defining the method on the class using the association: class User < ActiveRecord::Base def good_reviews ... end end

but that doesn’t seem any nicer than option #1 above (though it does have the benefit of reducing chaining, and thus allowing refactoring more easily).

jare care

Jul 17

jare care said,

I ended up just defining an instance method #good_reviews on User and decided


user.good_reviews

looked better than


user.reviews.good
James Moore

Jul 17

James Moore said,

I think it’s interesting that what you present as the third variant (and don’t like very much) is what I think of as the correct/standard way to do this. The advantage is that it’s available to anyone who has_many of the thing with the class method: class Affiliate < ActiveRecord::Base has_many :reviews end

And with the def self.good method, both user.reviews.good and affiliate.reviews.good share the same code.

You left out another way to do your other options – add another association, rather than a method: class User < ActiveRecord::Base has_many :reviews has_many :good_reviews, :conditions => ['rating = ?', Review::GOOD] end
Eric Mill

Jul 17

Eric Mill said,

I think Jared’s objection to using defining Review.good is that its only use is to call it indirectly, through user.reviews.good. At least this is what you’ve said previously.

Mr eel

Jul 17

Mr eel said,

I like the last option the most. That it’s not called directly isn’t an issue for me. In fact I consider that a useful side-effect — I might want to grab all good reviews with Reviews.good.

jare care

Jul 18

jare care said,

James,

Good point on the fact that the 3rd way is DRYer is some circumstances e.g. when you have a many-to-many with a join model such as:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class User < ActiveRecord::Base

  has_many :reviews

end

class Song < ActiveRecord::Base

  has_many :reviews

end

class Review < ActiveRecord::Base

  POOR, AVERAGE, GOOD = 0, 1, 2

  belongs_to :user
  belongs_to :song

  def self.good
    find :all,
      :conditions => ['rating = ?', Review::GOOD]
  end

end

That would allow me to put that #good method in 1 place and be usable from both a User’s and Song’s reviews association.

Also good point on the fact that I could of let ActiveRecord write the #good_reviews instance method for me by calling #has_many again.

Tammer Saleh

Jul 19

Tammer Saleh said,

I know that the Review.good method is just for illustration, but I would tend to take it a step further and make it a find_good() method:

1
2
3
4
5
def self.find_good(*args, &blk)
  self.with_scope(:find => {:conditions => {:rating => Review::GOOD}}) do
    find(*args, &blk)
  end
end

This gives you more freedom, and flexibility. Also, I prefer the hash syntax for :conditions.


Sorry, comments are closed for this article.

© 2000 - 2009 by thoughtbot, inc.
written by a bushel of tiny robots