ActiveRecord の歩き方 - Association 編(2)

前回の復習

前回の続き。

▼モデルの例
class Entry < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
end

というようなモデルにおいて、has_many メソッドの中身はどうなっているのだろう、という話であった。has_many は ActiveRecord 本体 *1の associations.rb に定義されているのだった。

ActiveRecord::Associations::ClassMethods#has_many(縮約版) 
def has_many(association_id, options = {}, &extension)
  reflection = create_has_many_reflection(association_id, options, &extension) # (A)
  collection_accessor_methods(reflection, HasManyAssociation)     # (F)
end
(associations.rb)

上のコードはとりあえず、本筋から外れたコードを全部外した縮約版である。(A) の部分において、まず、has_many での定義内容をリフレクション(reflection) というオブジェクトにして、ActiveRecord::Base サブクラスに登録している。(ちなみにこれは ActiveRecord::Reflection::reflection_on_association を使って Entry.reflection_on_association(:comments) のようにして取り出せる)


今日は、後半の collection_accessor_methods() から始めよう。

アクセサメソッドの定義

これも前回の復習になるが、上のようにモデルを定義すれば、

entry = Entry.find(1)
# ID 1 のエントリが持つすべてのコメントのタイトルを表示
entry.comments do |comment|
  puts comment.title
end

などとエントリに関連づけられたコメントに簡単にアクセスできる。そして、"entry.comments" の comments は Entry クラスに定義されたメソッドであった。comments メソッドを定義しているのが、collection_accessor_methods() である。

ActiveRecord::Associations::ClassMethods#collection_accessor_methods()
def collection_accessor_methods(reflection, association_proxy_class)
  collection_reader_method(reflection, association_proxy_class)   # (A)

  define_method("#{reflection.name}=") do |new_value|             # (B)
    # Loads proxy class instance (defined in collection_reader_method) if not already loaded
    association = send(reflection.name)
    association.replace(new_value)
    association
  end

  define_method("#{reflection.name.to_s.singularize}_ids") do     # (C)
    send(reflection.name).map(&:id)
  end

  define_method("#{reflection.name.to_s.singularize}_ids=") do |new_value|  # (D)
    ids = (new_value || []).reject { |nid| nid.blank? }
    send("#{reflection.name}=", reflection.class_name.constantize.find(ids))
  end
end
(assocations.rb)

(B)(C)(D) は、この際きっぱり無視してしまおう。重要なのは、(A) の collection_reader_method() である。

ActiveRecord::Associations::ClassMethods#collection_reader_method()
def collection_reader_method(reflection, association_proxy_class)
  define_method(reflection.name) do |*params|
    force_reload = params.first unless params.empty?              # (A)
    association = instance_variable_get("@#{reflection.name}")    # (B)

    unless association.respond_to?(:loaded?)                      # (C)
      association = association_proxy_class.new(self, reflection) # (D)
      instance_variable_set("@#{reflection.name}", association)   # (E)
    end

    association.reload if force_reload                            # (F)

    association                                                   # (G)
  end
end
(assocations.rb)

おお、ついに出たな、メタプログラミングRuby の必殺技、動的メソッド定義である。define_method をブロック付きで呼び出すとメソッドを新たにメソッドを定義することができる。define_method は Module クラスのインスタンスメソッドなので、レシーバはクラスオブジェクトでなければならない。collection_reader_method が実行される環境では、ActiveRecord::Base (のサブクラス)が self に入るので、reflection.name という名前のインスタンスメソッドが作られることになる。*2 われわれの例では、self = Entry, reflection.name = :comments になっている。(前回の最後あたりを参考)だから、Entry#comments というメソッドが作られるわけだ。


ようやく Entry#comments がどう作られるかというところまで来た。ではこのメソッドの中身をもうちょっと詳しく見てみよう。

  • (A) Entry#comments の第1引数があればそれを force_reload というローカル変数に入れている。force_reload == true なら (F) でアソシエーションをリロードするためだ。
  • (B) では、キャッシュされているアソシエーションオブジェクトを取り出している。
  • (C) では、取り出したアソシエーションオブジェクトが :loaded? というメソッドに反応するかチェックしている。何で素直に association が nil かどうかチェックしないんだろうか?よくわからない。
  • (D) いずれにしろ、適切にアソシエーションが設定されていない場合は アソシエーションオブジェクトを生成し(後述)
  • (E) これを reflection.name という名のインスタンス変数として ActiveRecord::Base インスタンスに登録している。*3 われわれの例では、Entry クラスインスタンスに @comments という名前でこのアソシエーションを入れている。
  • (G) アソシエーションをそのまま返している。

何気なくアソシエーションと言ったけれども、実体は何だろう?has_many の定義を見ると association_proxy_class = HasManyAssociation である。つまり(D) では、HasManyAssociation のインスタンスが作られている。


結局 collection_reader_methods() は has_many アソシエーションについては、HasManyAssociation インスタンスを返すのだ。しかし、まだ疑問は残る。前回見たように

irb(main):008:0> entry.comments
=> [#<Comment:0xb76bec54 @attributes={"entry_id"=>"1", "title"=>"comment1", "id"=>"1"}>, 
#<Comment:0xb76be038 @attributes={"entry_id"=>"1", "title"=>"comment2", "id"=>"2"}>]
irb(main):009:0> entry.comments.class
=> Array

irb では、 entry.comments は Array を返すのである。これはいまだに謎だ。これはどういうわけだろうか。

(次回に続く)

*1:UNIX なら典型的には /usr/local/lib/ruby/gems/1.8/gems/activerecord-(version)/lib/active_record あたりにある。

*2:ここらへんの話を詳しく知りたい人は yugui 氏の Rubyの呼び出し可能オブジェクトの比較 (2) - というよりコンテキストの話 を読んでみよう。

*3:define_method に対するブロックの中では self == ActiveRecord::Base サブクラスインスタンス(ここでは entry)になっていることに注意してほしい。これは ActiveRecord::Base サブクラスのインスタンスメソッドだからだ。その外側(collection_reader_method() の地の部分)では self == ActiveRecord::Base サブクラス(ここでは Entry)なのだが。Ruby では self が指す対象がコロコロ変わるので注意が必要である。