ActiveRecordのcounter_cacheに条件を与える

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

背景

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

rb Tagging.belongs_to :tag, counter_cache: true

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

問題

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

counter_culture

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

rb 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を導入したり、という解決策がありますが、どれも手元の環境では力及ばず成功できませんでした。

conditionalcountercache

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

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

まとめ

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