なにこれ
筆者が業務でrailsのcounter_cultureというgemを使用する機会がありました。
そこで機能が正しく動作するかを確認するために、gemの処理内容を理解しようとソースコードを読みました。
それらをまとめた備忘録です!
お願いしたいこと
筆者はこういった処理を追ってみる解説が初めてです。
記事を読みやすくするために、不要なオプションなどの記述を削除して、必要な機能の解説に留めているつもりです!
また、間違っている点などがありましたら、ご指摘いただけますと幸いです。
それでは、よろしくお願いいたします。
前提条件
この記事は↓の3つを理解しているものとして進めます。(継承、proc、アソシエーションなど)
- Rubyのチェリー本
- Railsチュートリアル
- その他、オブジェクト指向に関する知識
また、今回説明するcounter_cultureのバージョンは2.7
です。(2021/02/19現在)
そもそもcounter_cultureってなに
Ruby on Railsには、counter_cacheという親レコードが持ってる子レコードの数を測定してカラムに吐き出してくれる便利な機能があります。
参考
Railsガイド counter_cache
https://railsguides.jp/association_basics.html#belongs-to%E3%81%AE%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3-counter-cache
ただし、counter_cacheは↓の2つの仕様なので、使い勝手が良いとは言えませんでした。
- counter_cacheは子レコードの数しか図ることが出来ない
- 途中からcounter_cacheを追加しても、既存のデータの数は集計できない
それらを解決するためにcounter_cacheの拡張版として、
counter_cultureというgemがあります。
counter_cultureができること
- 条件に一致した子レコード数を集計
- 途中からカウンターを追加して、現在の値を集計
その他の細かな内容はGithubを参照ください。
https://github.com/magnusvk/counter_culture
それでは本番へ↓
counter_cultureの処理
ディレクトリ構造
counter_cultureは基本的に3つのファイルで構成されています。
lib/counter_culture/extentions.rb
lib/counter_culture/counter.rb
lib/counter_culture/reconciler.rb
extentions.rb
は全体的な管理
counter.rb
はカウントの条件の処理
reconciler.rb
は再集計の処理
今回はextentions.rb
とcounter.rb
を中心に見ていきます。
reconciler.rb
までは追う力がなかった
extentions.rb
まずはextentions.rbを見て大枠を掴みます。
一部省略しています。
module CounterCulture
module Extensions
extend ActiveSupport::Concern
module ClassMethods
# this holds all configuration data
def after_commit_counter_cache
config = @after_commit_counter_cache || []
if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
config = superclass.after_commit_counter_cache + config
end
config
end
# called to configure counter caches
def counter_culture(relation, options = {})
unless @after_commit_counter_cache
# initialize callbacks only once
after_create :_update_counts_after_create
before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?
after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?
# we keep a list of all counter caches we must maintain
@after_commit_counter_cache = []
end
if options[:column_names] && !options[:column_names].is_a?(Hash)
raise ":column_names must be a Hash of conditions and column names"
end
# add the counter to our collection
@after_commit_counter_cache << Counter.new(self, relation, options)
end
クラスメソッドとして、after_commit_counter_cache
とcounter_culture
というのを定義しているのが分かりました。
先にcounter_culture
メソッドを見ます。
def counter_culture(relation, options = {})
unless @after_commit_counter_cache
# initialize callbacks only once
after_create :_update_counts_after_create
before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?
after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?
# we keep a list of all counter caches we must maintain
@after_commit_counter_cache = []
end
# add the counter to our collection
@after_commit_counter_cache << Counter.new(self, relation, options)
end
@after_commit_counter_cache
というインスタンス変数に値がなかったら、
after_createやらbefore_destroyなど、アクション時にコールバックでプライベートメソッドを実行してそうです。
メソッド名から見て、こいつらがカウンターの処理をやってそうだと判断します。
@after_commit_counter_cache << Counter.new(self, relation, options)
下から2行目でCounterクラスのインスタンスで作成したものを、
@after_commit_counter_cache
に突っ込んでるのが分かりました。
この処理はinitializeみたいな感じなんですかね?正直このあたりが完全に理解できてないです。。。
一応、counter.rbを作成した時のinitializeの中身を見ておきます。
CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude]
ACTIVE_RECORD_VERSION = Gem.loaded_specs["activerecord"].version
attr_reader :model, :relation, *CONFIG_OPTIONS
def initialize(model, relation, options)
@model = model
@relation = relation.is_a?(Enumerable) ? relation : [relation]
if options.fetch(:execute_after_commit, false)
fail("execute_after_commit was removed; updates now run within the transaction")
end
@counter_cache_name = options.fetch(:column_name, "#{model.name.demodulize.tableize}_count")
@column_names = options[:column_names]
@delta_column = options[:delta_column]
@foreign_key_values = options[:foreign_key_values]
@touch = options.fetch(:touch, false)
@delta_magnitude = options[:delta_magnitude] || 1
@with_papertrail = options.fetch(:with_papertrail, false)
end
なんかいっぱいインスタンス変数を定義してる。笑
githubのドキュメントを見た感じだと、使うオプションとかを定義してるっぽい。
extentions.rbの戻って、一番上の_update_counts_after_create
メソッドから見てみようと思います!
# called by after_create callback
def _update_counts_after_create
self.class.after_commit_counter_cache.each do |counter|
# increment counter cache
counter.change_counter_cache(self, :increment => true)
end
end
selfのクラスメソッドのafter_commit_counter_cacheを実行して、
その結果をeach文で回して、カウント用のchange_counter_cache
メソッドを実行してるって感じです。
今はまだ解説しませんが、このあたりの引数はのちのち重要になってきます!
では、after_commit_counter_cacheメソッドの中身を見てみます。
def after_commit_counter_cache
config = @after_commit_counter_cache || []
if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
config = superclass.after_commit_counter_cache + config
end
config
end
変数configを定義して、after_commit_counter_cacheがあれば処理を実行する。ってぽいですねー。
configに色々設定する値を突っ込んでるのかな?
そして返り値としてconfigを定義していました。
次にカウント処理を行っているcounter.change_counter_cache(self, :increment => true)
というメソッド読んでみます。
(読みやすくするために、今回の解説に関係ない一部のオプションの記述を削除しています。)
counter.rb
def change_counter_cache(obj, options)
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
# default to the current foreign key value
id_to_change = foreign_key_value(obj, relation, options[:was])
if id_to_change && change_counter_column
delta_magnitude = if delta_column
(options[:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0
else
counter_delta_magnitude_for(obj)
end
# increment or decrement?
operator = options[:increment] ? '+' : '-'
klass = relation_klass(relation, source: obj, was: options[:was])
# MySQL throws an ambiguous column error if any joins are present and we don't include the
# table name. We isolate this change to MySQL because sqlite has the opposite behavior and
# throws an exception if the table name is present after UPDATE.
quoted_column = if klass.connection.adapter_name == 'Mysql2'
"#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
else
"#{model.connection.quote_column_name(change_counter_column)}"
end
# we don't use Rails' update_counters because we support changing the timestamp
updates = []
# this updates the actual counter
updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
primary_key = relation_primary_key(relation, source: obj, was: options[:was])
klass.where(primary_key => id_to_change).update_all updates.join(', ')
end
end
すごいいっぱい書いてある(語彙力)
とりあえず一行ずつ潰していきます!
def change_counter_cache(obj, options)
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
change_counter_cache
の引数として渡っているのが、objとoptionsでした。
counter.change_counter_cache(self, :increment => true)
objはcounter自身、optionsは:increment => true
などのオプションですね。
ちなみに、counter自身とは、counter_cultureを記述したモデルのことになります。
↓の例だと、Productクラスののインスタンスになります。
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category
end
変数change_counter_columnに突っ込んでる値を確認します。
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
今読んでるのが_update_counts_after_create
メソッドで、それにoptions[:counter_column]
はないので、counter_cache_name_for
が呼び出されることになります。
引数は(obj)なので、クラスのインスタンスになります。
# Gets the name of the counter cache for a specific object
#
# obj: object to calculate the counter cache name for
# cache_name_finder: object used to calculate the cache name
def counter_cache_name_for(obj)
# figure out what the column name is
if counter_cache_name.is_a?(Proc)
# dynamic column name -- call the Proc
counter_cache_name.call(obj)
else
# static column name
counter_cache_name
end
end
counter_cache_name.is_a?(Proc)
とあります。
今回はelseなので、インスタンス自身のcounter_cache_nameつまりproduct_count
が返されるようになります。
(counter_cultureはデフォルトでモデル名 + _count
を見るため。procを渡す方法は後述)
返り値が分かったので、戻って次に進みます。
def change_counter_cache(obj, options)
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
# default to the current foreign key value
id_to_change = foreign_key_value(obj, relation, options[:was])
変数id_to_change
にforeign_key_value(obj, relation, options[:was])
というメソッドの返り値を代入してるので、これを見ます。
変数relation
には、端的に言うとリレーション先が格納されています。(initialize時にattr_readerで定義されてる)
モデルにcounter_culture :category
と書いた場合は、[:category]
が入ります。
(なぜアソシエーション先が単体なのに配列なのかは調査中)
def foreign_key_value(obj, relation, was = false);
relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
first_relation = relation.first
if was
first = relation.shift
foreign_key_value = attribute_was(obj, relation_foreign_key(first))
klass = relation_klass(first, source: obj, was: was)
if foreign_key_value
value = klass.where(
"#{klass.table_name}.#{relation_primary_key(first, source: obj, was: was)} = ?", foreign_key_value
).first
end
else
value = obj
end
while !value.nil? && relation.size > 0
value = value.send(relation.shift)
end
return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))
end
先に結果を言うと、このメソッドの返り値はカウンターのカラムを追加してる親レコードのIDの数値を返します。(10など)
なぜ10が返されるのかを考えたほうが分かりやすいと思います!
relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
first_relation = relation.first
if was
# ~~今は省略~~
else
value = obj
end
変数relationを再定義して、relationを複製しています。
relation.firstでrelationを取り出しています。
今の所はrelation.first
には:relation
が格納されています。
(余談:毎回配列で変えるのにis_a?(Enumerable)
してるのは何故か?次の行でfirstで配列から取得してるから、Enumerableなのは確定してるのに。謎みが深い。)
ifでoptionsに[:was]があればtrueを返しますが、今回はないのでelseになります。
変数valueにobj自身を代入していますね。
while !value.nil? && relation.size > 0
value = value.send(relation.shift)
end
return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))
valueがあってrelation配列が0以上ならwhile文が走ります。
valueにrelation.firstをsendする。
つまりproduct.category
という状態になります。
最後のreturnで、valueを良い感じに処理してます。
relation_primary_key
メソッドに引数で:category, obj
を渡してますね!(wasはfalse)
def relation_primary_key(relation, source: nil, was: false)
reflect = relation_reflect(relation)
klass = nil
reflect.association_primary_key(klass)
end
relation_reflect
メソッドはおそらくrelationの情報を返しているんだと思います。
ざっと確認します!
def relation_reflect(relation)
relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
# go from one relation to the next until we hit the last reflect object
klass = model
while relation.size > 0
cur_relation = relation.shift
reflect = klass.reflect_on_association(cur_relation)
raise "No relation #{cur_relation} on #{klass.name}" if reflect.nil?
if relation.size > 0
# not necessary to do this at the last link because we won't use
# klass again. not calling this avoids the following causing an
# exception in the now-supported one-level polymorphic counter cache
klass = reflect.klass
end
end
return reflect
end
またrelationを複製してますね。
klassにmodel(obj自身のProductクラス)を代入してますね。
relationのsizeが0になるまでwhileします
ちなみにklassっていうのは、classって書くと予約語に引っかかるので、それの対策のメタプロっていうらしいです(昨日知った)
cur_relationに先頭のrelation(:category)を突っ込んでますね。
reflect = klass.reflect_on_association(cur_relation)
で、product.category
を再現してreflectに代入してます。
klass = reflect.klass
でリフレクトしたクラスをクラスに突っ込んでますね。
これは最後のリレーションのクラスを突っ込むってことだと思います。
whileが終わったらreflect(今回はcategory)を返してます。
かなり冒険しましたが、relation_primary_keyに戻ります。
def relation_primary_key(relation, source: nil, was: false)
reflect = relation_reflect(relation)
klass = nil
reflect.association_primary_key(klass)
end
reflectにはcategoryの親レコード(インスタンス)が入ってます。
それに対してassociation_primary_keyを返してます(今回の場合はID)
途中でklassにnilを代入して引数に渡してる理由までは追ってないです。。。
やっと本来のforeign_key_value
メソッドに返ってきました。
def foreign_key_value(obj, relation, was = false);
# ~~省略~~
return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))
valueでtryしたのはIDでした。それをto_symでシンボルにして、value(:id)という形にして、親レコード(変更対象のID)を取得してますね。
かなり長くなりましたが、これがIDの数値(10)などを返す仕組みです。
思ったより長くなってきたので、さくさく進めます。笑
def change_counter_cache(obj, options)
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
id_to_change = foreign_key_value(obj, relation, options[:was])
if id_to_change && change_counter_column
# 〜〜処理〜〜
end
change_counter_columnにはproduct_count、id_to_changeには10という数値が入っている状態です。
ifでこれらの値が存在するならtrueを返すようにしています。
次の処理を見ていきます。
delta_magnitude = if delta_column
(options[:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0
else
counter_delta_magnitude_for(obj)
end
operator = options[:increment] ? '+' : '-'
klass = relation_klass(relation, source: obj, was: options[:was])
# MySQL throws an ambiguous column error if any joins are present and we don't include the
# table name. We isolate this change to MySQL because sqlite has the opposite behavior and
# throws an exception if the table name is present after UPDATE.
quoted_column = if klass.connection.adapter_name == 'Mysql2'
"#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
else
"#{model.connection.quote_column_name(change_counter_column)}"
end
変数delta_magnitudeには、結論だけ言うとオプションを指定しない限り「1」が代入されると考えてください。
optionsにincrementがあれば、+
を返す。なければ-
を返すって処理があります。
これがレコードの作成、更新、削除のカウントに対応できる仕組みです。
klass = relation_klass(relation, source: obj, was: options[:was])
でrelation先のクラス(親レコード)を返してます。今回の場合はcategoryになります。
if klass.connection.adapter_name == 'Mysql2'
だったら変数quoted_columnに↓の値を返します
"#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
↑の内容は、railsのメソッドが多様されてるので省略しますが、”
categorys.
product_count"
のような値が代入されます。
これが実行されるSQL文です。
updates = []
# this updates the actual counter
updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
updatesっていう配列を定義して、そこに実行するSQL文を突っ込んでいます。
今回は↓のようなSQLが実行されることになります。
UPDATE `category` SET `category`.`product_count` = COALESCE(`category`.`product_count`, 0) + 1 WHERE `category`.`id` = 10
続きを見ていきます!
def change_counter_cache(obj, options)
# ~~省略~~
primary_key = relation_primary_key(relation, source: obj, was: options[:was])
klass.where(primary_key => id_to_change).update_all updates.join(', ')
end
変数primary_keyには、relation_primary_keyの返り値(IDなどの主キーの値)が入ります。
先程解説したメソッドと同じのを使いまわしてます!
そして、ようやく最後にレコードを特定した後に、update_allでSQLが実行されて、カウントされることになります。
destroy時の処理
今まではcreate時のアクションを解説しました。
次はdestroy時のアクションを見ていきます。
private
# called by after_destroy callback
def _update_counts_after_destroy
self.class.after_commit_counter_cache.each do |counter|
# decrement counter cache
counter.change_counter_cache(self, :increment => false)
end
end
createと一番の違いは、change_counter_cacheメソッドの引数が:increment => false
になってることです。
これは、レコードが削除される➡️カウントが減る➡️引き算を行う。っていう考えになります。
初めて知った時に「ほー」って思いました。笑
update時の処理
ここが最難関だと思いますが、逃げずに立ち向かいます。笑
# called by after_update callback
def _update_counts_after_update
self.class.after_commit_counter_cache.each do |counter|
counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
counter_cache_name = counter.counter_cache_name_for(self)
if counter.first_level_relation_changed?(self) ||
(counter.delta_column && counter.attribute_changed?(self, counter.delta_column)) ||
counter_cache_name != counter_cache_name_was
# increment the counter cache of the new value
counter.change_counter_cache(self, :increment => true, :counter_column => counter_cache_name)
# decrement the counter cache of the old value
counter.change_counter_cache(self, :increment => false, :was => true, :counter_column => counter_cache_name_was)
end
end
end
ifが置かれていますが、現状だと確実にこのifはtrueになるので割愛します
(counter_cache_name != counter_cache_name_was
は確実にtrueになるハズ)
updateの一番の特徴は、変更前と変更後を比較していることです。
比較しているのは、counter_cache_name_was
とcounter_cache_name
です。(メソッド名通り!)
counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
counter_cache_name = counter.counter_cache_name_for(self)
counter_cache_name_forメソッドは先程解説しました。
カウンター(self)のカウンター対象のカラムを文字列で返すメソッドです。
変数counter_cache_name_was
を定義してる時は、counter.previous_model(self)の返り値を突っ込んでますが、これが何の値を返してるか確認します。
def previous_model(obj)
prev = obj.dup
changes_method = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? :saved_changes : :changed_attributes
obj.public_send(changes_method).each do |key, value|
old_value = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? value.first : value
prev[key] = old_value
end
prev
end
まずobjをdupしてprevに代入してます。最後にprevをreturnしてるので、このprevに何か処理を加えることが予想されます。
次に変数changes_methodを定義していますが、これの条件はbinding.pryだと分からずで、結論:saved_changes
というシンボルを代入しています。
どうやら:changed_attributes
はRailsのバージョン5.1以前で使用されてたメソッドで、現在はattributes_in_database
というメソッドに置き換えられてるそうです。
obj.public_send(changes_method).each do |key, value|
で、
実行してる内容としてはobj(:saved_changes)
になります。
それをハッシュで取得されるので、key valueでeachを回すっていう感じです。
変数old_valueには、条件でtrueを返します。
で、value.first
とありますが、valueの中身には↓のような値が格納されています。
{ “updated_at"=>[Thu, 18 Feb 2021 13:36:00 JST +09:00, Thu, 18 Feb 2021 13:38:48 JST +09:00] }
配列の0番目に変更前、配列の1番目に変更後の値が確認できます。
今回は変更前の値を取得したいので、value.firstを実行してる。というわけです。
変更前のカラムを再現するために、eachで回してるprev[key]にvalueを突っ込みます。
それを繰り返した後にreturnする。という感じです!
なぜこんな処理をしているのか
筆者も最初は理解できませんでした。
結論を先にいうと、変更前と変更後の差分を検知するためです。
例として下記のようなcounter_cultureを設定したとします。
counter_culture :user,
column_name: proc { |model| model.is_special == true ? 'sp_post_count' : nil },
# 今回は解説しませんが、column_namesはcounter_culture_fix_countsというメソッドを実行するために必要な記述です。
column_names: {
Post.is_special => :sp_post_count,
Post.not_is_special => nil
}
scope :is_special, ->{ where(is_special: true) }
scope :not_is_special, ->{ where(is_special: false) }
簡単に説明すると、
userは複数のpostを持っています。
postにはis_specialというboolean型のカラムがあります。
一人のユーザーがis_specialなpostを何個持ってるかカウントするために、userにsp_post_countというカラムがあります。
実際の例で何が起こるかを説明します。
まずはpostのupdateを行います。
内容は、is_specialをfalseからtrueに変更する。というものです。
counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
counter_cache_name = counter.counter_cache_name_for(self)
上記のコードを実行した時に、counter_cache_name_wasには変更前の値つまりis_special == false
のものが取得できます。
counter_cache_nameには更新後の値つまりis_special == true
のものが格納されます。
上記の条件でcounter_cache_name_forメソッドを呼び出すと返り値は、
変更前の値はnil、変更後はsp_post_countが入ります。
念のためにcounter_cache_name_forメソッドの処理を確認します。
def counter_cache_name_for(obj)
if counter_cache_name.is_a?(Proc)
counter_cache_name.call(obj)
else
counter_cache_name
end
end
上記のsp_post_countを持った例だと、column_nameはprocなので、callが実行されることになります。
procを使う理由は、カウントする時に条件を加えるためです。
counter_culture :user,
column_name: proc { |model| model.is_special == true ? 'sp_post_count' : nil },
counter.counter_cache_name_for(counter.previous_model(self))
を行う時は変更前の値で確認することになります。
今回の場合だとfalseです。falseでprocを実行すると、nilが返されることになります。
つまり、counter_cache_name_wasにはnilが入ります。
counter_cache_nameには更新後の値が入る(true)ので、procでtrueのsp_post_countという文字列を返すことになります。
counter.change_counter_cache(self, :increment => false, :was => true, :counter_column => counter_cache_name_was)
counter.change_counter_cache(self, :increment => true, :counter_column => counter_cache_name)
上記の変数が確定した状態で、change_counter_cacheメソッドを呼び出しましょう。
この時に引数に注目してください。
counter_cache_nameを引数に渡した時は:increment => true
になっています。
反対にcounter_cache_name_wasを渡したときには:increment => false
になっています。
これの理由は、今更新したものがカラムを持っている(nilじゃない)なら、新しくカウントする対象が増えるからたし算をする。
逆に変更前のものがカラムを持っていたら、「カウントの対象外になった」ということを表します。
はい、難しいですね。
change_counter_cacheメソッドを確認します。
def change_counter_cache(obj, options)
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
id_to_change = foreign_key_value(obj, relation, options[:was])
if id_to_change && change_counter_column
# ~~省略~~
end
end
if id_to_change && change_counter_column
というコードがありますが、ここでchange_counter_columnがnilだった場合は条件外となって処理が終了することになります。
なので、先程のコードで問題なく動作する。ということになります。
差分を見てるということをイメージすると分かりやすいかと思います!