counter_cultureというカウンタキャッシュ用のgemがあります。
magnusvk/counter_culture: Turbo-charged counter caches for your Rails app.
このgemは条件付きカウンタキャッシュ機能を提供していて便利です。ただ、gemの実装の都合上、条件の中でActive Recordの関連を参照するとカウンタの計算がうまく動かないケースが避けられないことがあるので気をつけたほうがいいかもしれません。
以下、次のバージョンのライブラリを使います。
発生する問題
条件付きカウンタキャッシュの条件が関連先に依存していると、そのオブジェクトを更新するたびにカウンタの値が減算されてしまいます。
# Customerはorders_countを持つ
class Customer < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
belongs_to :customer
has_one :invoice
counter_culture :customer, column_name: ->(model) {
# 関連先のinvoiceがあれば、その状態を確認している
!model.invoice&.canceled? ? :orders_count : nil
}
end
class Invoice < ApplicationRecord
belongs_to :order
def canceled?
# ...
end
end
# ...
# ひとつでもカウンタキャッシュの条件がfalseになる関連を持っているとする
customer.orders.first.invoice.canceled? #=> true
# このとき、Orderのインスタンスorderを更新するたびに
# 親のcustomerのカウンタが減算されてしまう
customer.orders_count #=> 1
order.update! price: 100
customer.orders_count #=> 0
order.update! price: 100
customer.orders_count #=> -1
原因と対策
counter_cultureの内部では、カウンタ計算用にActive Record(以降ARと表記します)の #dup を利用しています。このメソッドはオブジェクトの関連はコピーしない仕様になっています。
Note that this is a “shallow” copy as it copies the object's attributes only, not its associations.
その仕様が原因で Order が更新されたときに発火する CounterCulture::Extensions::_update_counts_after_update がつねにカウンタを減算すると誤判定してしまいます。
解決方法としては、条件付きカウンタキャッシュの条件では、そのオブジェクトの持つ属性だけを使うようにするとよいです。
# Customerはordered_countをアトリビュートとして持つ
class Customer < ApplicationRecord
has_many :orders
end
# Orderはcanceled_atをアトリビュートとして持つ
class Order < ApplicationRecord
belongs_to :customer
has_one :invoice
counter_culture :customer, column_name: ->(model) {
# 関連のinvoiceを参照しないようにした
!model.canceled_at ? :orders_count : nil
}
end
# ...
# orderを2件持つが、1件はカウンタキャッシュの条件がfalseになるとする
customer.orders.count #=> 2
customer.orders.first.canceled_at #=> Sun, 17 Feb 2019 00:00:00 UTC +00:00
# 何度orderを更新してもカウンタは正しい値のまま
customer.orders_count #=> 1
order.update! price: 100
customer.orders_count #=> 1
問題が発生するロジック
counter_cultureのしくみ
gemによって、ARにクラスメソッド counter_culture が生えます。これを実行すると、そのクラスにカウンタ増減用のコールバックが入ります。更新時には after_update のコールバックとして _update_counts_after_update が設定されます。
今回のケースでは column_name をProcとして指定しているので、 _update_counts_after_update で Order 更新前後の該当カラム名を counter_cache_name_was / counter_cache_name として取得し、相違があれば(たとえば nil -> orders_count に変化したなら)増減算します。
更新前の状態のインスタンスを取得するしくみ
Order 更新前のカラム名を Counter#counter_cache_name_for というメソッドで取得するときには、Counter#previous_model という更新前の属性値を持つ Order のインスタンスを取得するメソッドを用いています。#previous_model の中ではARのインスタンスを #dup しており、こうすると関連は nil で属性値だけがコピーされたインスタンスができます。
更新前のカウンタキャッシュカラム名取得時の問題
上述したような、関連は nil で属性値だけがコピーされたインスタンスに対して、column_name に指定した以下の条件を評価すると、invoice が nil であることから !model.invoice&.canceled? #=> true となり、カラム名として :orders_count を得ます。
!model.invoice&.canceled? ? :orders_count : nil
一方、Order 更新後のカラム名取得時は #dup していない元々のインスタンスを用いているので、関連もたどることができ、!model.invoice&.canceled? #=> false となって、カラム名として nil を得ます。
以上から、Order 更新前後のカラム名が異なり、counter_cache_name != counter_cache_name_was が成り立つので、更新前のカラム名 :orders_count に対応するカラムが減算されます。この流れは Order のインスタンスが更新されるときはつねに成り立つので、オブジェクトを更新するたびにカウンタキャッシュが減算されてしまいます。