3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2020-05-20

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

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

現状

Recipes Table

Table name: recipes

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

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

Table name: recipe_categories

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

Categories Table

Table name: categories

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

Model

class Recipe < ApplicationRecord
  has_many :recipe_categories, dependent: :destroy
end
class Category < ApplicationRecord
  has_ancestry
  has_many :recipe_categories, dependent: :destroy
end
class RecipeCategory < ApplicationRecord
  belongs_to :recipe
  belongs_to :category
  counter_culture :category, column_name: :recipes_count
end

現状の問題点

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

counter_culture :category, column_name: :recipes_count

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

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

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

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

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

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.
Rails向け高機能カウンタキャッシュ gem 'counter_culture' README(翻訳)|TechRacho

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

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

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オプションを使うとこの機能が使えない。

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を使った場合、自分で再計算機能を実装するしかないようなので実装してみた。

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

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のブログ

5のカウントを個数分増やす方法は、以下の記事を参考にした。
[Rails+MySQL] カウンターの実装 【なければ新規作成したいし、あれば適切にインクリメントしたい】 - Qiita
ActiveRecord::CounterCache::ClassMethods

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

結論

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

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や改善点がありましたら、コメントで教えていただけると助かります。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?