顯示具有 activerecord 標籤的文章。 顯示所有文章
顯示具有 activerecord 標籤的文章。 顯示所有文章

2007/5/19

has_ :favorite, :recipes

Lately gugod and I have stumbled on a situation where the famous has_many :through wasn't of much help. So we have come up with our own has_ thingy. That's right, it's called has_.

The Problem

The classic example of has_many :through is that in AWDwR 2nd ed: an object of class X has many objects of class Y through another join table. So an Article has many Readers through the table Reading, a Group has many Users through the table Membership, and so on.

has_many :through is a wonderful invention. It beats the needs for another famous habtm idiom, and delivers what it promises... so long as you follow its naming convention.

But life is often not perfect. The biggest constraint for has_many :through is that both the :class and :foreign_key options available to has_many are not there. Now that's a bad thing.

To explain why that is so, just look at what we need:

class User < ActiveRecord::Base
  has_many :recipes
  has_many :recipes, :through => :favorite_recipes   # d'oh!
  ...
end

So here a User has many recipes (probably of his or her own creation) and many favorite recipes (probably created by others). Because of the constraint mentioned about, this is a no-goer:

has_many :fav_recipes, :class => "Recipe", :through => :favorite_recipes, :foreign_key => :recipe_id

We had tried to fall back on the less elegant way

has_many :favorite_recipes

Then we collected the real recipe id's with this:

@fav_recipes = me.favorite_recipes.collect { |r| r.recipe }

Very soon, we were collecting these kind of proxy objects all over our models and controllers.

What about has_and_belongs_to_many?

In theory we could have used has_and_belongs_to_many :foo, :join_table => :blah as a workaround. But semantically, that's not really what we want. It is true that when a join table like FavoriteRecipe comes to exist, it's logically correct to say User has_and_belongs_to_many Recipes. But hey, since when did we want to have a Recipe that has many users?

Another problem that we have run into is that, well, it turns out that we love single-table inheritance (STI). And FavoriteRecipe, FavoriteCook, FavoriteShow, FavoriteSupermarket and FavoriteSpice all belong to the same Favorite base class that has three fields: the sine-qua-non :type, and the :user_id and ... :item_id. We then specify the concrete class using :belongs_to in each subclass. For example, we define FavoriteRecipe as:

class FavoriteRecipe < Favorite
  belongs_to :recipe, :class_name => "Recipe", :foreign_key => "item_id"
end

And has_and_belongs_to_many could result in such behemoth in User:

  has_and_belongs_to_many :fav_recipes, :join_table => :favorite, :class => "Recipe", :associated_foreign_key => :item_id, :conditions => { :type => "FavoriteRecipe" }

That is not elegant at all.

The Workaround

The more natural solution would be, of course, to come up with a method like this:

class User < ActiveRecord::Base
  has_many :_favorite_recipes, :class => "FavoriteRecipes"
  def favorite_recipes
    _favorite_recipes.collect { |r| r.recipe }
  end
  ...

But then writing the same thing again and again for your favorite show, favorite cook and favorite spice is a pain. Not very DRY, hmm.

So gugod asked: is there a better way to do this? Could we possible come up with some has_blah that does the magic for us? Such as writing the "def favorite_recipes" for us ?

This is where Ruby's dynamism shines. And it turns out that Rails itself loves such kind of tricks too. So after some deliberation and work, here is what we called has_, and with it you can have as many favorite items as you want it.

The Code: has_

module HasUnderline
  def has_(prefix, item)
    prefix = prefix.to_s
    item = item.to_s

sy = "#{prefix}_#{item}" mo = sy.camelcase.singularize self.class_eval <<EOE has_many :_#{sy}, :class_name => "#{mo}" def #{sy} _#{sy}.map { |f| f.#{item.singularize} } end EOE end end

To use the module in your ActiveRecord model class, simply aggregate it into your class object with:

class User < ActiveRecord::Base
  extend HasUnderline
  has_ :favorite, :recipes
  has_ :favorite, :cooks
  has_ :favorite, :spices
  has_ :favorite, :shows
  ...

That's what we call elegant.

Voilà. So now we are happy since a User has_ many favorite recipes, favorite cooks, favorite spices, favorite shows, and so on and so forth.

Voot, that's eval! (à Frau Farbissina)

class_eval is one of Ruby's many evil vices (and hence the name) and is recommended by books like Ruby for Rails. One advantage of class_eval over method_missing is that the class in question will have a true method, whereas the dynamic dispatched behavior in method_missing cannot be known in advance.

We have just put that nifty snippt into our Asynapse library, and we're expecting more such idioms will go into this project.

Enjoy and stay tuned!