ActiveRecord の歩き方 - Association 編(1)

まえがき

Rails に出会ってからというもの、私は ActiveRecord の洗練されたインターフェイスに惹かれてきた。特にアソシエーションがどういう風に実装されているのか自分で確かめてみたかった。以下のコード例を見てほしい。

class Entry < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
end

これはあるブログシステムのモデルである。このコードではエントリ(Entry)とコメント(Comment)という2つのモデルがあり、Entry モデルの has_many :comments というメソッド呼び出しによって、コメントがエントリに関連付けられている。これがアソシエーションである。(エントリは、コメントを複数持つ。だから has_many アソシエーションが使われている)


こうやってモデルを定義して、対応するテーブルがデータベースに存在するならば、あとは、

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

などとエントリに関連づけられたコメントに簡単にアクセスできる。もしこの機能がなかったら、

comments = Comment.find_by_sql(
["SELECT * FROM comments WHERE entry_id = ?", entry.id])

などと SQL 文を直書きしなければならないところで、美しくない。


今回、私が試した環境は以下のとおり。

Linux 2.6.9-023stab033.9-smp (CentOS release 4.4 (Final))
ruby 1.8.5 (2006-12-25 patchlevel 12) [i686-linux]
RubyGems 0.9.2
Rails 1.2.2

アソシエーションの謎

私が始めて上のようなコードを見たとき、それはまるで魔法のように思えた。どうやら has_many :comments によって entry.comments というメソッド呼び出しが可能になったようであるが、それがどういう原理で可能になるのかさっぱり想像がつかなかったのだ。


entry.comments の comments っていったい何なのだ?メソッドが定義されているのか?それとも method_missing で処理されているのか?そこで調べてみると・・・。

irb(main):001:0> entry = Entry.find(1)
=> #<Entry:0xb76c1a1c @attributes={"title"=>"entry1", "id"=>"1"}>
irb(main):002:0> entry.method(:comments)
=> #<Method: Entry#comments>


ということで確かにメソッドが Entry クラスに定義されているようだ。この Entry#comments の出力結果は、

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


ということで、戻り値のオブジェクトのクラスは Array である。


ところが、

irb(main):010:0> entry.comments.find(:first)
=> #<Comment:0xb76aeef8 @attributes={"entry_id"=>"1", "title"=>"comment1", "id"=>"1"}>

などと entry.comments に対して find を呼び出すこともできるのである。Arrayクラスに対して find を呼び出しているとは思えない。この場合の entry.comments は、なにか別のクラスのインスタンスを返しているに違いない。

has_many アソシエーションの実装

アソシエーションには has_many のほかにも、has_one, belongs_to, has_and_belongs_to_many があるが、本稿では has_many だけに絞って考えてみる。まずは、Entry クラスのクラス定義文直下に現れた has_many メソッドから見ていくことにする。

# 再掲
class Entry < ActiveRecord::Base
  has_many :comments
end

そもそも has_many メソッドはどこに定義されているのか?普通に考えれば、ActiveRecord::Base で定義されているに違いない。というわけで、base.rb を見てみる。(ActiveRecord 本体のファイル群は、私の環境では、/usr/local/lib/ruby/gems/1.8/gems/activerecord-1.15.2/lib/active_record にある)

$ grep has_many base.rb
$

・・・ない。探してみると、has_many メソッドは、associations.rb に定義されている。

▼ ActiveRecord::Associations::ClassMethods#has_many()
def has_many(association_id, options = {}, &extension)
  reflection = create_has_many_reflection(association_id, options, &extension) # (A)

  configure_dependency_for_has_many(reflection) # (B)

  if options[:through]
    collection_reader_method(reflection, HasManyThroughAssociation) # (C)
  else
    add_multiple_associated_save_callbacks(reflection.name)         # (D)
    add_association_callbacks(reflection.name, reflection.options)  # (E) 
    collection_accessor_methods(reflection, HasManyAssociation)     # (F)
  end

  add_deprecated_api_for_has_many(reflection.name) # (G)
end
(associations.rb)

このメソッドは、ActiveRecord::Association::ClassMethods の下に定義されている。これがどうして ActiveRecord::Base クラスと関係してくるのだろう?ここらへんの事情に関しては、RubyOnRails を使ってみる 【第 3 回】 ActiveRecordの「AR の構成」あたりを見てほしい。ごく簡単いうと、ActiveRecord で最初に読み込まれる active_record.rb において、アソシエーション関連のメソッドが ActiveRecord::Base に追加されるのである。has_many は "ClassMethods" というモジュール名が物語っているように、クラスメソッドとして、Base クラスに追加される。


前置きはこれくらいにして、has_many メソッドの中身を見てみる。RHG の青木峰郎さんを見習って、「読みやすさ最適化オプション」を ON にして、本筋と関係ない処理を削って見やすくしよう。

  • (B) :dependency オプションがらみの処理。
  • (C) :through オプションがらみの処理。
  • (D) ActiveRecord の保存時のコールバックの設定。
  • (F) :before_add オプションや :after_add オプションにコールバックを指定した場合の処理
  • (G) deprecated な API の追加

はとりあえず、「entry.comments は何か?」という問題には関係ないので、ばっさり切ってしまう。すると has_many メソッドは次のようになる。

▼ 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)

というわけで、結局 create_has_many_reflection() (A) と collection_accessor_methods() (F)が残った。これから詳しく見ていくが、先回りしてまとめておくと、

  • (A) リフレクションを作るメソッド
  • (F) Entry#comments のようなアクセサを定義するメソッド

ということになる。

リフレクションとは

リフレクションとは、どうやら、has_many で定義された内容を正確に記憶するためのオブジェクトらしい。create_has_many_reflection() の中身を見てみよう。

ActiveRecord::Associations::ClassMethods#create_has_many_reflection()
def create_has_many_reflection(association_id, options, &extension)
  options.assert_valid_keys(                                                             # (A)
    :class_name, :table_name, :foreign_key,
    :exclusively_dependent, :dependent,
    :select, :conditions, :include, :order, :group, :limit, :offset,
    :as, :through, :source, :source_type,
    :uniq,
    :finder_sql, :counter_sql, 
    :before_add, :after_add, :before_remove, :after_remove, 
    :extend
  )

  options[:extend] = create_extension_module(association_id, extension) if block_given?  # (B)

  create_reflection(:has_many, association_id, options, self)                            # (C)
end
(associations.rb)

前半(A)の部分ではオプションのチェックをしているらしい。(B) の部分ではブロックを与えた場合の処理だ。(A) も (B) もここでは無視しよう。問題は (C) の create_reflection() だが、これは reflection.rb で定義されている。(なぜここから呼び出されているかといえば、上で述べたように associations.rb と reflection.rb で定義されたメソッドは、ともに ActiveRecord::Base に取り込まれているからだ)

ActiveRecord::Reflection::ClassMethods#create_has_many_reflection()
def create_reflection(macro, name, options, active_record)
  case macro
    when :has_many, :belongs_to, :has_one, :has_and_belongs_to_many                # (A)
      reflection = AssociationReflection.new(macro, name, options, active_record)   
    when :composed_of
      reflection = AggregateReflection.new(macro, name, options, active_record)
  end
  write_inheritable_hash :reflections, name => reflection                          # (B)
  reflection
end
(reflection.rb)

ここでは、macro = :has_many なので、AssociationReflection クラスのインスタンスを作り reflection に代入している。(前半(A)) (B) でやっていることは少しわかりずらい。write_inheritable_hash() は ActiveSupport の core_ext/class/inheritable_attributes.rb で定義されている。簡単に言ってしまうと、{ name => reflection }というハッシュを reflections という名前で、Base サブクラスにクラス変数として登録する、という仕事をしている。*1 Base サブクラスにおいて、reflection の名前から任意のリフレクションオブジェクトが取れるようにする仕掛けである。


結局、associations.rb の has_many() において、create_has_many_reflection() は AssociationReflection インスタンスを返している。では、AssociationReflection とはなんだろうか?ここでは深入りはしない。AssociationReflection は次のようなインターフェイスを持っているとだけ述べておこう

#entry.comments の場合

reflections = Entry.read_inheritable_attribute(:reflections)
r = reflections[:comments]
p r  
# => #<ActiveRecord::Reflection::AssociationReflection:0xb7741078 @macro=:has_many, @primary_key_name="entry_id", @name=:comments, @options={}, @active_record=Entry>
p r.name         # => :comments
p r.macro        # => :has_many
p r.options      # => {}
p r.klass        # => Comment
p r.class_name   # => "Comment"
p r.table_name   # => "comments"
p r.primary_key_name # => "entry_id"
p r.association_foreign_key # => "comment_id"

AssociationReflection#primary_key_name だけが少し引っかかる。これはいわゆる foreign key のことだと思うのだが・・・。なぜ primary_key_name と呼ばれているのかよくわからない。

とりあえずリフレクションはこんな程度にしておく。collection_accessor_methods の説明に移りたかったが、力尽きた。また明日以降に。

*1:実は正確にはクラスそのものに登録されたインスタンス変数である。Ruby では、クラス変数は、継承されてしまうので、単一テーブル継承(Single Table Inheritance) 周りで問題を起こすからかもしれない