woshidan's blog

あいとゆうきとITと、とっておきの話。

ActiveRecord::Baseのサブクラスでhas_manyと書いたとき

なんかいまいちだけど、置いとこう。。

まとめ

has_manyメソッドを書くと、has_manyメソッドに与えたパラメータを元に、 has_manyのアソシエーションのメソッドのマクロ名などが登録されたクラスのインスタンスをFactoryPatternっぽい感じで作り、 そのインスタンスActiveRecordの持っている動的に追加したメソッド一覧みたいな_reflectionsというハッシュに追加している。

has_manyのアソシエーションのメソッドのマクロなどが登録されたメソッドが作成されたあと、 それを使った(追いきれてないからそう言う気がするだけ)getterやwriterは その場でModelにメソッドを追加するためのモジュールを作成/呼び出しをして、 そのmoduleのclass_evalメソッドにブロックを与える形で追加してる。

たぶん、belongs_toとかvalidateとか他のオプションも処理をしたいから、 こういう処理になっている気がしましたが...

なんか空っぽのメソッドがあったり、細かい記法が分からなかったりしてなぞが多い。

内容

  • きっかけとして
  • ActiveRecordでhas_manyを呼び出したとき
  • Builder::HasMany.buildの中身をもう少し見る
    • create_reflection
    • define_accessors
    • define_callbacks
  • reflectionを作成すると...

きっかけとして

acts_as_votableというgemで、以下のように、その属性の指定を足すかのようなメソッドを書くと、 その場でModelクラスにクラスメソッドを定義するかのような実装のgemがあるのですが、

# https://github.com/ryanto/acts_as_votable/blob/master/lib/acts_as_votable/extenders/voter.rb
def acts_as_voter(*args)
  require 'acts_as_votable/voter'
  include ActsAsVotable::Voter

  class_eval do
    def self.voter?
      true
    end
  end

end

あ、もしかしてhas_manyなどもそうでは? と思って調べてみる事にしたのでした。

ActiveRecordでhas_manyを呼び出したとき

has_manyDSLの単語ではなくメソッド名としたら、ActiveRecordクラス内でdef has_manyと検索したらどこかで引っかかると思います。

というわけで検索した結果、active_record/lib/active_record/associations.rbで発見。

      # Option examples:
      #   has_many :comments, -> { order "posted_on" }
      #   has_many :comments, -> { includes :author }
      #   has_many :people, -> { where(deleted: false).order("name") }, class_name: "Person"
      #   has_many :tracks, -> { order "position" }, dependent: :destroy
      #   has_many :comments, dependent: :nullify
      #   has_many :tags, as: :taggable
      #   has_many :reports, -> { readonly }
      #   has_many :subscribers, through: :subscriptions, source: :user
      def has_many(name, scope = nil, options = {}, &extension)
        reflection = Builder::HasMany.build(self, name, scope, options, &extension)
        Reflection.add_reflection self, name, reflection
      end

has_manyでassociationのメソッドの名前と、scope, optionsなどそれらしい引数を取っているので、 has_manyはやっぱり文法めいたメソッドのようです。

ところで、reflectionというものを作成して追加しているようですが、実際何をしているのでしょうか。

reflectionというのは文字列を使ってクラスを生成したり、メソッド名の文字列を使ってメソッドを呼び出したりする類いの行為を言っているようで、要するに見たままですね。。

Builder::HasMany.build(self, name, scope, options, &extension)メソッドに、 self(=ここではModelのクラス?), name(メソッド名)を与えて、Modelのクラスに登録したいメソッドを作成しているようです。

もう少し追ってみましょう。

Builder::HasMany.buildの中身をもう少し見る

Builder::HasMany.buildでreflectionを生成(=メソッドに与えた文字列を使って追加したいメソッドを定義している?)している様子を もう少し追ってみます。

Builder::HasMany.buildメソッドの内容は、Builder::HasManyクラス、そのスーパークラスCollectionAssociationクラスを通り越して、 ActiveRecord::Associations::Builder::Assocationクラス(active_record/lib/active_record/associations/builder/association.rb)にあります。

  def self.build(model, name, scope, options, &block)
    if model.dangerous_attribute_method?(name)
      raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \
                           "this will conflict with a method #{name} already defined by Active Record. " \
                           "Please choose a different association name."
    end

    extension = define_extensions model, name, &block
    reflection = create_reflection model, name, scope, options, extension
    define_accessors model, reflection
    define_callbacks model, reflection
    define_validations model, reflection
    reflection
  end
create_reflection
reflection = create_reflection model, name, scope, options, extension

ここは、受け取ったパラメータからhas_manyメソッドに必要な条件のバリデーションや性質を表す値のセットを持った メソッドのProductに当たるクラスを返しているようです(Factoryパターンっぽい)。

def self.create(macro, name, scope, options, ar)
  klass = case macro
          when :composed_of
            AggregateReflection
          when :has_many
            HasManyReflection
          when :has_one
            HasOneReflection
          when :belongs_to
            BelongsToReflection
          else
            raise "Unsupported Macro: #{macro}"
          end

  reflection = klass.new(name, scope, options, ar)
  options[:through] ? ThroughReflection.new(reflection) : reflection
end

class HasManyReflection < AssociationReflection # :nodoc:
  def initialize(name, scope, options, active_record)
    super(name, scope, options, active_record)
  end

  def macro; :has_many; end

  def collection?; true; end
end
define_accessors model, reflection
define_callbacks model, reflection

では、それぞれModelのクラスにメソッドを追加しているように見えます。

define_accessors
  # Post.first.comments and Post.first.comments= methods are defined by this method...
  def self.define_accessors(model, reflection)
    mixin = model.generated_association_methods # Modelクラスのassociationメソッド追加用のmoduleを取得/作成
    name = reflection.name
    define_readers(mixin, name)
    define_writers(mixin, name)
  end

  def self.define_readers(mixin, name)
    # class_evalメソッドは、ブロックをクラス定義やモジュール定義の中のコードであるように実行
    mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
      def #{name}(*args)
        # ここのassocitaionはHasManyのインスタンスのような気がしたけど、気だけ...
        association(:#{name}).reader(*args)
      end
    CODE
  end
# ActiveRecord::Core module
# Modelクラスのassociationメソッド追加用のmoduleを取得/作成
  def generated_association_methods
    @generated_association_methods ||= begin
      mod = const_set(:GeneratedAssociationMethods, Module.new)
      include mod
      mod
    end
  end
define_callbacks
def self.define_callbacks(model, reflection)
  if dependent = reflection.options[:dependent]
    check_dependent_options(dependent)
    add_destroy_callbacks(model, reflection)
  end

  Association.extensions.each do |extension|
    extension.build model, reflection
  end
end

# before_destroyのコールバックが追加されているような気がするのですが、
# 詳細が追えなかった...
def self.add_destroy_callbacks(model, reflection)
  name = reflection.name
  model.before_destroy lambda { |o| o.association(name).handle_dependency }
end

なお、define_validations model, reflectionはただ置いてあるだけみたいです。

def self.define_validations(model, reflection)
  # noop
end

reflectionが持っているname属性等を使って、modelクラスにgetterやコールバックを追加しているみたいなのは分かりました。 では、builderが返したreflectionをどうしているかもう少し見てみます。

reflectionを作成すると...

def has_many(name, scope = nil, options = {}, &extension)
  reflection = Builder::HasMany.build(self, name, scope, options, &extension)
  Reflection.add_reflection self, name, reflection
end

modelにgetterやsetterをその場でmodelに登録していた感じですが、 buildメソッド内で作成したアソシエーションのメソッドそのものはまだクラスに登録していなさそうです。

# ActiveRecord::Reflection
def self.add_reflection(ar, name, reflection)
  ar._reflections = ar._reflections.merge(name.to_s => reflection)
end

というわけで、上記のように、ActiveRecordのクラスが持っているらしい、self._reflectionsというハッシュに メソッド名をキー、アソシエーションのメソッドの中身と値とした要素を追加していそうなのですが、 ここで時間切れです。というか、まあ、じつは

module Reflection # :nodoc:
    extend ActiveSupport::Concern
  included do
    class_attribute :_reflections
    class_attribute :aggregate_reflections
    self._reflections = {}
    self.aggregate_reflections = {}
  end

この記法の意味が分からないのですが。。