FIVETEESIXONE

ActiveRecord のカウンタキャッシュはどのように設定されるのか


「Rails3レシピブック」を読んでいて、「カウンタキャッシュ」の設定方法が出てきた。

class Entry < ActiveRecord::Base
  belongs_to :blog, :counter_cache => true
end

このように、モデルの関連において参照元で belongs_to:counter_cache => true というパラメータを与えると、そのモデルのコールバックで参照先のカウンター用カラム (entries_count) が自動的に増加/減少するというもの。

「Rails3レシピブック」では、上記の :counter_cache => true という書き方だけが紹介されていたけど、RailsGuides で該当箇所 (4.1.2.3 :counter_cache) を参照したみたところ、(Rails 4 で追加されたのかもしれないけど) 以下のように指定することで、任意の参照先のカウンタ用カラム名を任意のものにすることもできるみたいだった。

class Order < ActiveRecord::Base
  belongs_to :customer, counter_cache: :count_of_orders
end
class Customer < ActiveRecord::Base
  has_many :orders
end

「ほう、するとカウンタキャッシュはどうやって設定されてるんだろう?」と思ってコードを少し眺めてみたら、これがなかなか面白そうだったのでちゃんと読んでみることに。もしかしたら、最近 Ruby コードの Crystal 移植をやったりしてたので、こういう型が自由 (ハッシュの値が bool だったりシンボルだったりっていう) なところに引っかかりやすい気分だったのかもしれない。

ということで、いつものように gtags に頼りつつちょこちょこ読んでみました。ちなみに、バージョンは 4.2.3 のコードベースを使ってます。

TL;DR

  • belongs_to 宣言で設定した内容は、BelongsToReflection クラスのオブジェクトに保持される
  • BelongsToReflection クラスには counter_cache_column メソッドがある
  • counter_cache_column メソッドは、パラメータで指定したハッシュの :counter_cache の値が true の場合は、デフォルトのカラム名を返す
  • counter_cache_column メソッドは、パラメータで指定したハッシュの :counter_cache の値がシンボルの場合は、そのシンボル名のカラム名を返す
  • BelongsToReflection オブジェクトの内容にしたがって、対象のモデルクラスにカウンタキャッシュのためのアクセサメソッドやコールバックメソッドが追加される

ActiveRecord::Associations::belongs_to

:counter_cache => :count_of_orders | true というのは belongs_to メソッドの引数でしかないので、belongs_to を見てみないとどうしようもない。これは、ActiveRecord::Base で mix-in されている ActiveRecord::Associations で定義されているのがすぐわかった。

rails/activerecord/lib/active_record/associations.rb

...
def belongs_to(name, scope = nil, options = {})
  reflection = Builder::BelongsTo.build(self, name, scope, options)
  Reflection.add_reflection self, name, reflection
end
...

いきなり何かすげー違和感。belongs_to :blog, :counter_cache => true と呼び出したら、これは引数 scope{ :counter_cache => true } が入るよね?これスコープなの?っていう。うーん。でもここで悩んでても仕方ないので、素直に読み進めることにする。belongs_to に渡されてきた引数リストは以下。

  • name -> :blog
  • scope -> { :counter_cache => true }
  • options -> {} (未設定)

これらの引数がほとんどそのまま次の行で Builder::BelongsTo#build に渡される。

..
  reflection = Builder::BelongsTo.build(self, name, scope, options)
..

これはクラスメソッドのコンテキストなので、self はモデルのクラス自身、つまりこの例だと Entry というクラスオブジェクトになる。その他の引数は上記した通り。そしてアサインされる先の変数名は reflection か…これは早速メタくなってくる予感がするぜ…

ActiveRecord::Associations::Builder::BelongsTo::build

さて、呼ばれている ActiveRecord::Associations::Builder::BelongsTo#build メソッドを探そう。しかし残念なお知らせですが、Builder::BelongsTo クラスには build というメソッドは存在していません。ってことで継承チェーンを辿る。

...
  class BelongsTo < SingularAssociation #:nodoc:
...

辿る…

...
  class SingularAssociation < Association #:nodoc:
...

辿る…

...
  class Association #:nodoc:
    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

      builder = create_builder model, name, scope, options, &block
      reflection = builder.build(model)
      define_accessors model, reflection
      define_callbacks model, reflection
      define_validations model, reflection
      builder.define_extensions model
      reflection
    end
...

お、build はここにいました。SingularAssociation というのは特に (n) 対 1 の関連を表現するための中間クラス的なものなのかな。別に CollectionAssociation というのもあるようなのでなんとなくそういうものかなと感じる。この辺、Ruby には抽象クラスといった概念がないのでパッと見では設計が少し把握しづらい。

前のとほとんど変わらないんだけど、このメソッドの引数リストは以下です。

  • model -> Entry
  • name -> :blog
  • scope -> { :counter_cache => true }
  • options -> {} (未設定)
  • block -> nil (未設定)

dangerous_attribute_method? というのは何か気になる名前だけど一旦無視することにして、

...
      builder = create_builder model, name, scope, options, &block
...

を見てみる。ここも、受け取った引数をほとんどそのまま渡して、create_builder というメソッドを実行している。

...
    def self.create_builder(model, name, scope, options, &block)
      raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol)
      new(model, name, scope, options, &block)
    end
...

create_builder はこれ。name はシンボルなので例外を raise することはなく、またもや引数パスで次は new メソッドが実行される。クラスのコンテキストで new だから、これは実際の自分のクラスをレシーバにして new を実行する、つまり通常インスタンスを生成するときに行う Entry.new と同義となる。したがって、これが実行されると、暗黙的にインスタンスメソッドの initialize が呼ばれる。その initialize はすぐ下に定義されている。何やら気になる TODO コメントが書かれているけど、とりあえず気にしない方向で…

...
    def initialize(model, name, scope, options)
      # TODO: Move this to create_builder as soon we drop support to activerecord-deprecated_finders.
      if scope.is_a?(Hash)
        options = scope
        scope   = nil
      end

      # TODO: Remove this model argument as soon we drop support to activerecord-deprecated_finders.
      @name    = name
      @scope   = scope
      @options = options

      validate_options

      if scope && scope.arity == 0
        @scope = proc { instance_exec(&scope) }
      end
    end
...

ここで、scope がハッシュの場合には、そのハッシュを options にそのまま代入する処理がある。あーそうなってるのか!最初の方で scope という引数にハッシュが入るのが違和感、と感じたけど、ここでそれが解消するみたいだ。正直、ちょっと微妙な気がする実装だけど、キーワード引数を使わずに belongs_to を自然な宣言的記法にするには仕方ない、ってことなのかもしれない。それとも俺が何か勘違いしてるのかも。

とりあえずそれは置いといて、実装的には難しいところはなくて、ここまで引き渡されてきたオブジェクトをそれぞれインスタンス変数に格納し、それから validate_options でオプションの正当性チェックを行っている。Valid なオプションは以下。

...
    self.valid_options = [:class_name, :anonymous_class, :foreign_key, :validate]
...

あれ?:counter_cache がないぞ?と一瞬思ったけど、上記はあくまでも親クラスの ActiveRecord::Associations::Builder::Association クラスの定義であって、実際にはこれを継承した実クラスの ActiveRecord::Associations::Builder::BelongsTo クラスでオーバーライドされている。

...
    def valid_options
      super + [:foreign_type, :polymorphic, :touch, :counter_cache]
    end
...

なので、:counter_cache は (当たり前だけど) 正当なオプションとしてバリデーションをパスする。

その下の scope のチェックのところは、実際に scope 引数に Proc オブジェクトが渡されたときの処理なので今回はスルーします。

さて、これで ActiveRecord::Associations::Builder::BelongsTo クラスのインスタンスが生成され、ActiveRecord::Associations::Builder::BelongsTo::build メソッドまで戻る。

...
      builder = create_builder model, name, scope, options, &block
      reflection = builder.build(model)
      define_accessors model, reflection
      define_callbacks model, reflection
      define_validations model, reflection
      builder.define_extensions model
      reflection
    end
...

これで、builder 変数には ActiveRecord::Associations::Builder::BelongsTo クラスのインスタンスが格納された。そのときのインスタンス状態は以下。

  • @name=:user
  • @scope=nil
  • @options={:counter_cache=>true}

次に builder をレシーバとして build メソッドが実行される。さっきの build はクラスメソッドだったけど、今度は ActiveRecord::Associations::Builder::Association#build というインスタンスメソッドで、別物。

...
    def build(model)
      ActiveRecord::Reflection.create(macro, name, scope, options, model)
    end
...

おっとー!ここでちょっと今までと趣を異にする ActiveRecord::Reflection というクラスが出てきましたね。じゃあこの ActiveRecord::Reflection::create メソッドを見てみる。

ActiveRecord::Reflection::create

rails/activerecord/lib/active_record/reflection.rb

...
    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
...

ActiveRecord::Reflection::create の定義はこんな感じで、一応このときの引数リストを書いておくと以下となっている。

  • macro -> :belongs_to
  • name -> :blog
  • scope -> nil
  • options -> { :counter_cache => true }
  • ar -> Entry

macrobelongs_to なので、klass 変数には BelongsToReflection クラスが設定される。そして、すぐにその BelongsToReflection クラスのインスタンスを生成している。

...
      reflection = klass.new(name, scope, options, ar)
...

ActiveRecord::Reflection::BelongsToReflection#initialize では、そのままスーパークラスの AssociationReflectioninitialize が実行され、その中では更にそのスーパークラスである MacroReflectioninitialize が呼ばれている。

...
    class BelongsToReflection < AssociationReflection # :nodoc:
      def initialize(name, scope, options, active_record)
        super(name, scope, options, active_record)
      end
      ...
    end
...
    class AssociationReflection < MacroReflection #:nodoc:
      ...
      def initialize(name, scope, options, active_record)
        super
        @automatic_inverse_of = nil
        @type         = options[:as] && (options[:foreign_type] || "#{options[:as]}_type")
        @foreign_type = options[:foreign_type] || "#{name}_type"
        @constructable = calculate_constructable(macro, options)
        @association_scope_cache = {}
        @scope_lock = Mutex.new
      end
      ...
    end
    class MacroReflection < AbstractReflection
      ...
      def initialize(name, scope, options, active_record)
        @name          = name
        @scope         = scope
        @options       = options
        @active_record = active_record
        @klass         = options[:anonymous_class]
        @plural_name   = active_record.pluralize_table_names ?
                            name.to_s.pluralize : name.to_s
      end
      ...
    end

ただ、ここでこれらの初期化処理の内容をすべて追っていくのは大変すぎるので、とりあえずこういった流れで BelongsToReflection がインスタンス化されている、ということだけ把握したら先に進む。ということであっさりここはスルーするけど、間違いなく、この Reflection 周りが ActiveRecord のアソシエーションの情報を管理する実装の中心っぽい、という感触があるので、いずれ改めて詳しく見てみたい。

話を戻して、上記した初期化処理の中で、今回の counter_cache を追っていく上で重要なのは、インスタンス変数 @optionsAssociationReflection がアサインされている、ということである。なぜかというと、AssociationReflection クラスには counter_cache_column というズバリな名前のインスタンスメソッドがあり、そのメソッドが options[:counter_cache] を参照しているから。

    class AssociationReflection < MacroReflection #:nodoc:
      ...
      def counter_cache_column
        if options[:counter_cache] == true
          "#{active_record.name.demodulize.underscore.pluralize}_count"
        elsif options[:counter_cache]
          options[:counter_cache].to_s
        end
      end
      ...
    end
...

もう今回の調査はここまでで終わったかな。counter_cache_column メソッドは、options[:counter_cache]true の場合には #{active_record.name.demodulize.underscore.pluralize}_count を返す。メソッドチェーンの各メソッドの説明は省略するけど、これはつまり、今回の場合は entries_count を返すものになっている。それ以外の場合は、options[:counter_cache] に設定された値を to_s してそのまま返す。つまり、もし counter_cache: count_of_entries が値であれば "count_of_entries" が戻り値となる。まさに belongs_to のパラメータに指定したときに期待される動作となっている。

Re: ActiveRecord::Associations::Builder::BelongsTo::build

一応、もう少し追っていくと、また ActiveRecord::Associations::Builder::BelongsTo::build に戻る。

...
  class Association #:nodoc:
    def self.build(model, name, scope, options, &block)
      ...
      builder = create_builder model, name, scope, options, &block
      reflection = builder.build(model)
      define_accessors model, reflection
      define_callbacks model, reflection
      define_validations model, reflection
      builder.define_extensions model
      reflection
    end
...

ここまでで作った reflection オブジェクトを引数として、define_accessorsdefine_callbacks を実行している。

ActiveRecord::Associations::Builder::BelongsTo#define_accessors

ActiveRecord::Associations::Builder::BelongsTo#define_accessors で、add_counter_cache_methods を実行している。add_counter_cache_method でやっていることは、対象のモデルクラスで class_eval することによる動的なメソッド定義。難しいコードではないけれど、Rails の魔術的な部分にはこれ以上触れない方向にしたい気持ちでいっぱいなので、気になる人は見てみてください、としてお茶を濁すことにする。

...
    def self.define_accessors(mixin, reflection)
      super
      add_counter_cache_methods mixin
    end
...

ActiveRecord::Associations::Builder::BelongsTo#define_callbacks

こっちも、add_counter_cache_callbacks というメソッドが実行されているので想像がつくと思う。こっちも同様に、その中でやっていることについては省略!

...
    def self.define_callbacks(model, reflection)
      super
      add_counter_cache_callbacks(model, reflection) if reflection.options[:counter_cache]
      add_touch_callbacks(model, reflection)         if reflection.options[:touch]
    end
...

おわりに

これで、カウンタキャッシュが設定される仕組みは大体理解できた。実際にカウンタキャッシュがどう動くのか、などについては、ActiveRecord::CounterCache というそのままのクラスがあるのでそれを見ればわかるかな、と思う。見てないけど。

ActiveRecord のコードをまともに眺めるのは正直はじめてだったけど、なんか黒魔術がバリバリという先入感があったせいか、「あら意外とそんなに難しくはなくてわかりやすいな」という印象。もちろん今日見たのはごく一部なので、前言撤回する準備はいつでも OK ですけどね!