ActiveRecordのcounter_cacheに条件を与える

counter_cacheに条件を付けたい、あるいはcounter_cultureを利用しているが不満がある、という方向けの投稿です。

背景

ActiveRecordには、公式のチュートリアルActive Record Associations — Ruby on Rails Guidesにもあるように、counter cacheと呼ばれる機能が用意されています。

Tagging.belongs_to :tag, counter_cache: true

例えば上記のように投稿とタグの関係性を表すような中間テーブル用のモデルTaggingにcounter_cacheを設定しておくと、taggingsテーブルにレコードが作成/削除されたときに tags.taggings_count が自動的に更新されるようになります。便利ですね。

問題

他人に見せたくない投稿を表現するためにprivateモードというものが存在したとしましょう。このとき、privateモードの投稿は、tag.taggings_count には反映させたくありませんが、counter_cacheは投稿の状態に関わらず値を更新してしまいます。

counter_culture

magnusvk/counter_culture を使えば、条件を設定することができます。他にも、deadlockを避けるためにCountを更新するタイミングをトランザクションのCOMMIT後に置いていたり、便利な機能が沢山入っています。

Tagging.belongs_to :tag
Tagging.counter_culture :tag, column_name: ->(tagging) do
  if tagging.item.private?
    nil
  else
    :items_count
  end
end

しかし、rspec-railsのデフォルトの機能であるtransactional fixturesが有効化されていると、テスト環境ではcounter_cultureが動作しません。これは、テスト中にCOMMITが発生しないためです。transactional fixturesを無効化したり、DatabaseCleanerを利用している場合はtruncation strategyを利用したり、更にテスト中にCOMMIT相当のコールバックを発生させるためのGemを導入したり、という解決策がありますが、どれも手元の環境では力及ばず成功できませんでした。

conditional_counter_cache

本来の目的は「ActiveRecordのcounter_cacheに条件を与える」ことなので、結局、トランザクションには触れず条件だけを与えることを目的とした r7kamura/conditional_counter_cache というライブラリをつくりました。belongs_toに与える :counter_cache オプションに条件を与えられるように拡張し、条件に一致しない場合にCountの動作を無効化する処理を加えています。

Tagging.belongs_to :tag, counter_cache: { condition: -> { !item.private? } }

まとめ

counter_cacheに条件が必要になるケースと、その解決策としてのcounter_cultureの概要と問題点、それから拙作のconditional_counter_cacheについて簡単に説明しました。 条件付きのcounter_cacheを実現したいとき、トランザクションを必要としているならcounter_cultureを、何処の馬の骨とも知れないライブラリに一石投じる気概があればconditional_counter_cacheを、それ以外の場合は自前の実装を利用するのが良いでしょう。