[Rails]子カテゴリを登録した時に、親カテゴリもカウントするカウンターを実装する(ancestry, counter_culture)

`肉 > 鶏肉 > むね肉`のような多階層カテゴリーがあった時、
レシピにむね肉カテゴリーを登録したら、親カテゴリーである`肉 > 鶏肉`もカウントする機能を実装してみた。

ancestryを用いた多階層カテゴリーにカウンターをつけている記事が見つからず、自分で調べてみて実装したので解説してみる。

同じような人がいたらぜひ参考にしてください。

## はじめに
### 環境

```
Ruby: 2.6.6
Rails: 6.0.2.2
```

### 前提

* レシピを掲載するサイトを作成している。

* レシピDBとカテゴリーDBがある。

* レシピには複数のカテゴリーがついている。（中間テーブル）

* カテゴリーは多階層カテゴリになっている。
例: 肉 > 鶏肉 > むね肉
※ ancestryで実装(経路列挙モデル) 
* 1カテゴリーに何個レシピがあるかカウンターを実装している

### 使用しているgem

```
ancestry: 3.0.7
counter_culture: 2.5.1
```

counter_cultureの導入はこちらを参照。
[関連レコード数の集計（カウンターキャッシュ） - Qiita](https://qiita.com/kanekomasanori@github/items/8155dff193e961828d02)

### 現状

Recipes Table

```ruby
Table name: recipes

 id            :bigint       not null, primary key
 title         :string       not null
 description   :string       not null
```

RecipeとCategoryの中間テーブル
1つのレシピに対して、複数のカテゴリーが登録される関係になっている。

```ruby
Table name: recipe_categories

 id          :bigint           not null, primary key
 recipe_id   :bigint           not null
 category_id :bigint           not null
```

Categories Table

```ruby
Table name: categories

 id            :bigint           not null, primary key
 name          :string           not null
 ancestry      :string
 recipes_count :integer          default(0), not null
```

Model

```ruby
class Recipe < ApplicationRecord
  has_many :recipe_categories, dependent: :destroy
end
```

```ruby
class Category < ApplicationRecord
  has_ancestry
  has_many :recipe_categories, dependent: :destroy
end
```

```ruby
class RecipeCategory < ApplicationRecord
  belongs_to :recipe
  belongs_to :category
  counter_culture :category, column_name: :recipes_count
end
```

## 現状の問題点

1:1の関係だと下記を中間テーブルに入れるだけでカウントされる。

```ruby
counter_culture :category, column_name: :recipes_count
```

今回は親カテゴリーも同時にカウントされる機能をつけたい。

例えば
照り焼きチキンのレシピに、むね肉のカテゴリーがついている。

```ruby
Recipe.find(1)
=> #<Recipe
 id: 1,
 title: "照り焼きチキン"
>

RecipeCategory.find(1)
=> #<RecipeCategory
 id: 1,
 recipe_id: 1,
 category_id: 20
>

Category.find(20)
=> #<Category
 id: 20,
 name: "むね肉",
 ancestry: "1/10",
 recipes_count: 1
>
```

むね肉カテゴリーは以下の関係になっている。

id:1 > id:10 > id:20
肉 > 鶏肉 > むね肉

1:1のカウンターだと関連付けした「むね肉」はカウントされるが、「肉」と「鶏肉」にカウントされない。
親カテゴリーである「肉」と「鶏肉」もカウントされるように実装をしてみた。

## 親子カテゴリのカウンター実装方法

結論からいうと、こう実装した。

```ruby
class RecipeCategory < ApplicationRecord
  belongs_to :recipe
  belongs_to :category
  counter_culture :category, column_name: :recipes_count,
    foreign_key_values: proc { |category_id| Category.find(category_id).path_ids }
end
```

`foreign_key_values`は外部キーを上書きするオプション。

通常だと関連づいている`category_id: 20`が外部キーとなり、Categoryのid:20のカウントが増減する。
`foreign_key_values`に配列形式で数値を渡すと、渡した値を外部キーとして対象すべてのカウントを増減する。

親子カテゴリーのすべてをカウントしたいため、例だと`[1, 10, 20]`の値を渡す形で実装する。

### 実装の解説

公式のリファレンスと翻訳解説されている記事を参考にした。
[GitHub - magnusvk/counter_culture: Turbo-charged counter caches for your Rails app.](https://github.com/magnusvk/counter_culture#dynamically-over-writing-affected-foreign-keys)
[Rails向け高機能カウンタキャッシュ gem 'counter_culture' README（翻訳）｜TechRacho](https://techracho.bpsinc.jp/hachi8833/2017_08_03/43698)

```ruby
foreign_key_values: proc { |category_id| Category.find(category_id).path_ids }
```

`proc { |category_id| }`
まずprocで引数を渡す、この時に渡されるのは通常の時の外部キー(今回だと`20`)

`Category.find(category_id)`
対象のレコードオブジェクトを取得する。

`.path_ids`
ancestryの機能で`path_ids`を行うとオブジェクトの親子関係のIDをリストで取得することができる。

参照: [【翻訳】Gem Ancestry公式ドキュメント - Qiita](https://qiita.com/Rubyist_SOTA/items/49383aa7f60c42141871)

実行してみると`=> [1, 10, 20]`が返ってくることがわかる。

```ruby
Category.find(20).path_ids
  Category Load (1.0ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2  [["id", 20], ["LIMIT", 1]]
=> [1, 10, 20]
```

`[1, 10, 20]`を`foreign_key_values:`にわたすことで親子カテゴリーすべてをカウントできるようになった。

## カウント再計算の機能実装

`counter_culture`には`counter_culture_fix_counts`というカウントを再計算する機能が実装されている。 
すでにあるデータをカウントしたり、カウントのズレを修正するために使うのだが、`foreign_key_values`オプションを使うとこの機能が使えない。

```ruby
RecipeCategory.counter_culture_fix_counts
=> Fixing counter caches is not supported when using :foreign_key_values;
you may skip this relation with :skip_unsupported => true
```

調べたところ、`foreign_key_values`を使った場合、自分で再計算機能を実装するしかないようなので実装してみた。

結論から、こう実装した。
すべて計算し直しているため、件数が多いと時間がかかるかもしれない。
良い実装方法があったら教えてください。

```ruby
class RecipeCategory < ApplicationRecord

  #...
  
  # Categoryのrecipes_countをすべて再計算する
  def self.fix_counts
    Category.update_all(recipes_count: 0)

    target_categories = pluck(:category_id)
    # categoriesの個数を計算する => { 1: 10, 10: 3, 20: 1 }
    count_categories = target_categories.group_by(&:itself).transform_values(&:size)
    count_categories.each do |category_id, count|
      count_up_categories = Category.find(category_id).path_ids
      Category.update_counters(count_up_categories, recipes_count: count)
    end
  end
end
```

実行している内容は以下のとおり。

1. カウンターをすべて0にする
2. 中間テーブルにあるcategory_idを配列で取得
3. 個数を計算する => { 1: 10, 10: 3, 20: 1 }
4. それぞれのカテゴリーの親子カテゴリーIDの配列を取得
5. 親子カテゴリーすべてのカウントを個数分増やす

3の個数を計算する方法は、以下の記事を参考にした。
[配列に同じ要素が何個あるかを数える - patorashのブログ](https://patorash.hatenablog.com/entry/2018/12/06/124628)

5のカウントを個数分増やす方法は、以下の記事を参考にした。
[[Rails+MySQL] カウンターの実装 【なければ新規作成したいし、あれば適切にインクリメントしたい】 - Qiita](https://qiita.com/sensuikan1973/items/dcb79201cc87e76a65e9)
[ActiveRecord::CounterCache::ClassMethods](https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html#method-i-update_counters)

これにて、`RecipeCategory.fix_counts`を実行するとカウントの再計算を行うようになった。

## 結論

RecipeCategoryを以下にすることで親子カテゴリーのカウンター機能を実装できた。

```ruby
class RecipeCategory < ApplicationRecord
  belongs_to :recipe
  belongs_to :category
  counter_culture :category, column_name: :recipes_count,
                             foreign_key_values: proc { |category_id| Category.find(category_id).path_ids }

  # Categoryのrecipes_countをすべて再計算する
  def self.fix_counts
    Category.update_all(recipes_count: 0)

    target_categories = pluck(:category_id)
    # categoriesの個数を計算する => { 100: 3, 102: 2 }
    count_categories = target_categories.group_by(&:itself).transform_values(&:size)
    count_categories.each do |category_id, count|
      count_up_categories = Category.find(category_id).path_ids
      Category.update_counters(count_up_categories, recipes_count: count)
    end
  end
end

```

ぜひ、参考にしてみてください。
FBや改善点がありましたら、コメントで教えていただけると助かります。