レコードの合計値をキャッシュできるGem「counter_culture」を試してみる

こんにちは!kossyです!




今回は、テーブルのレコードの合計値をキャッシュできるGemであるcounter_cultureを試してみたので、
備忘録としてブログに残してみたいと思います。

github.com




環境

Ruby 2.6.6
Rails 6.0.3
MacOS catalina



counter_cultureのユースケース

例えば、一つの支社に何人の従業員が所属しているか表示したいとします。

class Company
  has_many :employees, dependent: :destroy
end

class Employee
  belongs_to :company
end

この場合、何人所属しているかを取得するには、以下のようなActiveRecord Queryを走らせる必要があるかと思います。

$ company = Company.last
  Company Load (4.7ms)  SELECT "companys".* FROM "companys" ORDER BY "companys"."id" DESC LIMIT $1  [["LIMIT", 1]]

$ company.employees.count
   (9.8ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 32]]
=> 4

これはいわゆる、「会社情報詳細画面」向けのデータ取得方法になるかと思うので、クエリが2回で済んでいますが、これが「会社情報一覧画面」になるとどうでしょう。

$ Company.all.map { |company| company.employees.count }
  Company Load (5.2ms)  SELECT "companys".* FROM "companys"
   (6.0ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 1]]
   (4.6ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 2]]
   (4.4ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 3]]
   (5.3ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 4]]
   (3.6ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 5]]
   (3.1ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 6]]
   (4.2ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 7]]
   (3.4ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 8]]
=> [4, 4, 4, 4, 4, 4, 4, 4]

例とコードが極端ではありますが、要は会社一つ一つに対して、countのSQLを走らせないといけないということが言いたかったのです、、、

このようなケースで、合計値(あるいは平均値)をキャッシュ(カラムに値を保存)する方法を提供してくれるのが、counter_coltureの役割になります。


使い方

テーブル構成は以前本ブログで紹介した構成を一部修正して試してみます。

kossy-web-engineer.hatenablog.com

class Department < ApplicationRecord
  belongs_to :company
  has_many :employees
end

class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department

  enum sex: { male: false, female: true }
end

まずはcounter_cultureをインストールします。

# Gemfile

gem 'counter_culture'
$ bundle

次に、Employeeモデルにcounter_cultureを有効化するためのメソッドを追加します。

# app/models/employee.rb

class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department
  counter_culture :department # 追加
end

次に、合計値を保存するためのカラムをdepartmentsテーブルに追加します。

$ rails g counter_culture Depaertment employees_count

これで、以下のようなマイグレーションファイルが生成されます。

class AddEmployeesCountToDepaertments < ActiveRecord::Migration[6.0]
  def self.up
    add_column :depaertments, :employees_count, :integer, null: false, default: 0
  end

  def self.down
    remove_column :depaertments, :employees_count
  end
end

注意事項として、仮にGeneratorを使わずに手動でマイグレーションファイルを作成する場合は、

必ずinteger型でNotNull制約を付与し、default値は0で設定するようにします。

これで準備完了です。

動作確認

$ Employee.new(department: Department.first, company: Company.first).save!
   (0.5ms)  BEGIN
  Company Load (4.6ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (5.9ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Employee Create (10.4ms)  INSERT INTO "employees" ("id", "first_name", "last_name", "first_name_kana", "last_name_kana", "sex", "hired_at", "company_id", "position_id", "department_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING "id"  [["id", 105], ["first_name", "六海"], ["last_name", "上野"], ["first_name_kana", "ムツミ"], ["last_name_kana", "ウエノ"], ["sex", true], ["hired_at", "2006-11-20"], ["company_id", 1], ["department_id", 32], ["created_at", "2021-06-26 03:55:04.380598"], ["updated_at", "2021-06-26 03:55:04.380598"]]
  Department Update All (2.7ms)  UPDATE "departments" SET "employees_count" = COALESCE("employees_count", 0) + 1 WHERE "departments"."id" = $1  [["id", 32]]
   (2.7ms)  COMMIT
=> true

注目すべきは、Department Update All (2.7ms) UPDATE "departments" SET "employees_count" = COALESCE("employees_count", 0) + 1 の部分でしょう。

save!したと同時に、employeesのINSERT文とdepartmentsテーブルの該当レコードに対してupdate文が実行されています。

COALESCEは以下のブログでも紹介していますが、「いくつかの値の中で最初にNULLでない値」を返すSQL関数です。

kossy-web-engineer.hatenablog.com

Departmentレコードのemployees_countが1増えているかどうか検証してみます。

# 実行前

$ Department.find(32)
  Department Load (6.7ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
=> #<Department:0x00005634273a8570
 id: 32,
 name: "沖縄支店",
 ancestry: "8",
 ancestry_depth: 1,
 employees_count: 4,
 company_id: 1,
 created_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00,
 updated_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00>

# 実行後

$ Department.find(32)
  Department Load (6.7ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
=> #<Department:0x00005634273a8570
 id: 32,
 name: "沖縄支店",
 ancestry: "8",
 ancestry_depth: 1,
 employees_count: 5, # 1増えている
 company_id: 1,
 created_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00,
 updated_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00>

きちんと1増えていることが確認できました。


まとめ

ごくごくシンプルに扱うのであれば、上記で十分かなと思いますが、counter_cultureはまだまだ便利なオプションを数多く用意しているので、

一度本家のReadMeに目を通されることをお勧めします。

勉強になりました。