「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->:blogscope->{ :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->Entryname->:blogscope->{ :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_toname->:blogscope->niloptions->{ :counter_cache => true }ar->Entry
macro が belongs_to なので、klass 変数には BelongsToReflection クラスが設定される。そして、すぐにその BelongsToReflection クラスのインスタンスを生成している。
...
reflection = klass.new(name, scope, options, ar)
...
ActiveRecord::Reflection::BelongsToReflection#initialize では、そのままスーパークラスの AssociationReflection の initialize が実行され、その中では更にそのスーパークラスである MacroReflection の initialize が呼ばれている。
...
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 を追っていく上で重要なのは、インスタンス変数 @options に AssociationReflection がアサインされている、ということである。なぜかというと、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_accessors と define_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 ですけどね!