Sonntag, 16. November 2014

Extend has_many association proxy!

ActiveRecord::Associations::ClassMethods#has_many and its companion ActiveRecord::Associations::ClassMethods#has_and_belongs_to_many in Ruby on Rails are both extendible associations. It is a convenient approach to extend Proxy objects for various reasons:
  1. encapsulates logic which applies to the association
  2. provides more expressive access to associated model objects
  3. is combinable with collection proxy object methods
Assuming the original classes:
class Cook < ActiveRecord::Base
  has_many :recipes
end

class Recipe < ActiveRecord::Base
  def self.named_like name
    where("#{table_name}.name LIKE ?", "%#{name}%")
  end

  def name_with_time
    "#{name} (#{time})"
  end
end
whereas a cook has many recipes.

Encapsulate logic

Getting all recipes as a comma separated String is coded quickly:
Cook.first.recipes.map(&:name_with_time).join(', ')
=> "Chocolate Chunk Cookies (30), Oatmeal Shortbread (45)"
but can be refactored from the reuseability perspective by moving the logic into the Cook association to Recipe:
class Cook < ActiveRecord::Base
  has_many :recipes do
    def to_s
      map(&:name_with_time).join(', ')
    end
  end
end
Now the logic, how to get recipes String does not need to be reproduced any more:
Cook.first.recipes.to_s
=> "Chocolate Chunk Cookies (30), Oatmeal Shortbread (45)"

Increase Expressiveness

Another scenario is to search all the cooks recipes by a term:
Cook.first.recipes.named_like('cookie')
=> [#<Recipe id: 1, name: "Chocolate Chunk Cookies", time: 30>]
is pretty expressive, but can be improved by extending the association once again:
class Cook < ActiveRecord::Base
  has_many :recipes do
    def [] term
      named_like term
    end
  end
end
and sending the message:
Cook.first.recipes['cookie']
=> [#<Recipe id: 1, name: "Chocolate Chunk Cookies", time: 30>]

Combine extensions

Proxy objects extensions also can be combined easily, which makes logic flexible and reusable:
class Cook < ActiveRecord::Base
  has_many :recipes do
    def to_s
      map(&:name_with_time).join(', ')
    end

    def [] term
      named_like term
    end
  end
end
and combining both:
Cook.first.recipes['cookie'].to_s
=> "Chocolate Chunk Cookies (30)"
whereas the chain order matters.
Of course the association proxy extensions also can be combined with other CollectionAssociation methods like:
Cook.first.recipes['cookie'].count
=> 1
which generates a different SQL statements projection part as expected. Accessing the Proxy objects inside the extension is also easy as:
class Cook < ActiveRecord::Base
  has_many :recipes do
    def [] term
      named_like term
    end

    def shallow_copy
      proxy_association.proxy.map(&:dup)
    end
  end
end
The message receiver Cook#shallow_copy simply clones the associated objects:
Cook.first.recipes['cookie'].shallow_copy
=> [#<Recipe name: "Chocolate Chunk Cookies", time: 30>]
Please note the cloned recipe has no ID, since it is a new record.
The collection proxy object provides some more helpful accessors like ActiveRecord::Associations::HasManyAssociation#owner and ActiveRecord::Associations::HasManyAssociation#reflection
Further articles of interest:

Supported by Ruby 2.1.1 and Ruby on Rails 3.2.17