Railsで関連レコード数の集計(カウンターキャッシュ)
Railsで関連レコード数を集計するには以下の2つの方法があります
- counter_cache
- counter_culture
前者のcounter_cache
はRails3からの機能で、以下のように設定することにより関連するテーブルのレコード数を簡単にカウントさせることができます
例えばArticleモデルとCommentモデルの以下の様な親子関係があった場合、
comment.rb
class Comment < ActiveRecord::Base belongs_to :article, :counter_cache => true end
article.rb
class Article < ActiveRecord::Base has_many :comments end
Commentが作成されると以下の様なSQLが発行され、関連するArticleのcomments_count
が+1されます
UPDATE `articles` SET `comments_count` = COALESCE(`comments_count`, 0) + 1 WHERE `articles`.`id` = 1
通常は親モデル_count
という名前のカラムにカウント値が入りますが、モデル内で設定している:counter_cache => true
に文字列もしくはシンボルを渡すと、指定したカラムにカウント数を更新します
comment.rb
class Comment < ActiveRecord::Base belongs_to :article, counter_cache: :admin_comments_count end
Commentが削除された場合は当然、カウントは-1されます
これらのcounter_cacheはSUMをしていないという仕組み上、既にデータがあるところに導入するためには初期のカウント数を入れなければ実データとの差異が出てしまいます
それぞれの特徴
counter_cache
- テーブルのカウントが同一トランザクション内で行われるため、デッドロックが発生する可能性がある(結構頻発するという話も)
- カウント対象テーブルが深い階層だと対応できない(1階層のみ対応可)
counter_culture
- テーブルのカウントが対象テーブルの更新と別トランザクションで行われるため、デッドロックは発生しない
- テーブルのカウントが対象テーブルの更新と別トランザクションで行われるため、値の不整合が生じる場合がある
- カウント対象テーブルとカウントを書き込むテーブルのリレーション階層が深い場合でも、モデルのアソシエーションで辿ることができれば対応できる
- カウント数を書き込むカラム名を動的に変更可能
- カウント数を書き込むカラム名の指定にブロックを渡せるため、条件によってカウントさせることができる
後者のcounter_culture
はgemで、counter_cache
よりも高機能で、大変柔軟に対応できるようになっています
counter_cultureの使い方
最初に準備すること
Gemfileにgemの設定を追記します
Gemfile
gem 'counter_culture'
カウント値を格納するカラムをつくります
ジェネレータがあるので以下のようにジェネレータを使ってカラムを作ることができます
rails generate counter_culture Article comments_count
ジェネレータで生成しなくても、以下のようなカラムであれば問題ありません
add_column :articles, :comments_count, :integer, :null => false, :default => 0
通常のカウント
モデルで以下のように定義します
comment.rb
class Comment < ActiveRecord::Base belongs_to :article counter_culture :article end
article.rb
class Article < ActiveRecord::Base has_many :comments end
カウントの保存カラム名を変える場合
カラム名を以下のように指定します
class Comment < ActiveRecord::Base belongs_to :article counter_culture :article, column_name: 'admin_comments_count' end class Article < ActiveRecord::Base has_many :comments end
カウントの保存カラムを動的に変更する場合
カラム名にブロックを渡して保存カラム名を動的に変更できます
comment.rb
class Comment < ActiveRecord::Base belongs_to :article counter_culture :article, column_name: -> (model) { "#{model.comment_type_name}_comments_count" } def comment_type_name comment_type == 1 ? 'admin' : 'user' end end
動的に変更したカラム名にカウントした場合、後で対象テーブルの値を変更した場合にも自動でカウント数を調整してくれます
例えば上記の例の場合、comment_typeを1から2に変更した場合、以下のようなSQLが発行されてadmin_comments_countが-1されてuser_comments_countが+1されます
UPDATE `articles` SET `user_comments_count` = COALESCE(`user_comments_count`, 0) + 1 WHERE `articles`.`id` = 1 UPDATE `articles` SET `admin_comments_count` = COALESCE(`admin_comments_count`, 0) - 1 WHERE `articles`.`id` = 1
条件によってカウントする/しないを分ける場合
動的にカウントカラムを変更する方法と同じくブロックを渡して対応できます
カウントしない場合にはnil
を渡すことにより条件によってカウントさせないということが可能です
comment.rb
class Comment < ActiveRecord::Base belongs_to :article counter_culture :article, column_name: -> (model) { model.comment_type_name == 1 ? 'admin_comments_count' : nil } def comment_type_name comment_type == 1 ? 'admin' : 'user' end end
article.rb
class Article < ActiveRecord::Base has_many :comments end
階層が深い関連のカウント
階層が深い場合は渡すテーブル名のシンボルを配列にして、カウント対象テーブル側から見たリレーションの階層で指定することで対応可能です
comment_like.rb
class CommentLike < ActiveRecord::Base belongs_to :comment counter_culture [:comment, :article], column_name: -> (model) { "#{model.gender_name}_likes_count" } def gender_name gender == 1 ? 'male' : 'female' end end
comment.rb
class Comment < ActiveRecord::Base belongs_to :article counter_culture :article, column_name: -> (model) { model.comment_type_admin? 'admin_comment_count' : nil } end
article.rb
class Article < ActiveRecord::Base has_many :comments end
counter_cultureを使うときの注意点
counter_cultureは対象データ更新のトランザクションの外でカウント処理を行うため、テストを書く際に工夫が必要となります
例えばRSpecでDatabaseCleanerを使っている場合にはDatabaseCleaner.strategy = :trunsaction
の設定だとテストが通りません
これは、strategy = trunsactionだとテスト内でトランザクションが完了しないので、カウンターキャッシュによるカウントアップがされないということだと思います
これは、DatabaseCleaner.strategy = :truncation
にすることで解決できます
全ての設定をDatabaseCleaner.strategy = :truncation
にするのは都合が悪いことがあると思うので、以下のようにすることでRspec上でメタデータを指定した場合のみtruncationで動かすことができます
まず、spec_helperでtruncation: true
のメタデータを受け取った場合にはtruncationモードで動くように追記します
spec_helper.rb
config.before(:each) do DatabaseCleaner.strategy = example.metadata[:truncation] ? :truncation : :transaction DatabaseCleaner.start end
テスト時には対象のテストでtruncation: true
を渡して対応します
spec_helper.rb
describe Article, truncation: true do ここにテストを書きます end
新しいことや難しい課題に挑戦することにやりがいを感じ、安定やぬるい事は退屈だと感じます。 考えるより先に手が動く、肉体派エンジニアで座右の銘は諸行無常。 大事なのは感性、プログラミングにおいても感覚で理解し、感覚で書きます。
TAG
- Android
- AWS
- Bitrise
- CodePipeline
- Firebase
- HTML
- iOS
- IoT
- JavaScript
- KPI
- Linux
- Mac
- Memcached
- MGRe
- MGReのゆるガチエンジニアブログ
- MySQL
- PHP
- PICK UP
- PR
- Python
- Ruby
- Ruby on Rails
- SEO
- Swift
- TIPS
- UI/UX
- VirtualBox
- Wantedly
- Windows
- アクセス解析
- イベントレポート
- エンジニアブログ
- ガジェット
- カスタマーサクセス
- サーバ技術
- サービス
- セキュリティ
- セミナー・展示会
- テクノロジー
- デザイン
- プレスリリース
- マーケティング施策
- マネジメント
- ラボ
- リーンスタートアップ
- 企画
- 会社紹介
- 会社紹介資料
- 勉強会
- 実績紹介
- 拡張性
- 採用
- 日常
- 書籍紹介
- 歓迎会
- 社内イベント
- 社員インタビュー
- 社長ブログ
- 視察
- 開発環境