肉 > 鶏肉 > むね肉のような多階層カテゴリーがあった時、
レシピにむね肉カテゴリーを登録したら、親カテゴリーである肉 > 鶏肉もカウントする機能を実装してみた。
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
実行している内容は以下のとおり。
- カウンターをすべて0にする
- 中間テーブルにあるcategory_idを配列で取得
- 個数を計算する => { 1: 10, 10: 3, 20: 1 }
- それぞれのカテゴリーの親子カテゴリーIDの配列を取得
- 親子カテゴリーすべてのカウントを個数分増やす
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や改善点がありましたら、コメントで教えていただけると助かります。