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
のインスタンスが更新されるときはつねに成り立つので、オブジェクトを更新するたびにカウンタキャッシュが減算されてしまいます。