「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
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 ですけどね!