#はじめに
某プログラミングスクールでフリマアプリを作っています。
そこで、カテゴリ別検索機能の実装を頑張りました。
ので、そこで学んだことだったり、実装方針についてまとめようかな。って思います。
実装の手順としては、大きく分けて2つです。
言葉で説明すると、以下の実装をしました!
ただ、両方説明すると FF7リメイクで遊んでいる時間がなくなる 内容が膨大になるので、
今回は②にフォーカスを絞って説明します!
-
①ヘッダーに実装しているカテゴリーボタンをクリックすると、親カテゴリーのリストを表示する
-
親カテゴリーリストにマウスを重ねると、その親カテゴリーに紐づいた子カテゴリーを表示する(非同期通信処理)
-
子カテゴリーリストにマウスを重ねると、その子カテゴリーに紐づいた孫カテゴリーを表示する(非同期通信処理)
-
②クリックした階層に合わせて、その階層以下の該当するアイテムを拾ってくる(コントローラー側の処理)
-
クリックしたリストがカテゴリーの階層のどこに当たるかを条件分岐で切り分ける
-
クリックしたリストのカテゴリー以下の該当するidを全て拾ってくる
-
拾ったidをもとに繰り返し処理をして、
@items
変数に全て入れる
##前提
ざっくりとテーブルの構成を書いておきます(カラムは結構省きまくり)
###テーブル
テーブル名 | カラム(ざっくり) | アソシエーション |
---|---|---|
Items | id、name、category_id、とかとか他にも色々 | has_many :images, belongs_to :category |
Categories | id、name、ancestry だけ | has_many :items, has_ancestry |
Images | id、image、item_id だけ | belongs_to :item |
出品したアイテムは、Categoriesテーブルに登録されているカテゴリーのidと紐づいています。
基本的に孫カテゴリーのidに紐づくような実装になっています。
例を挙げると、メンズ(親カテゴリー)のトップス(子カテゴリー)のTシャツ(孫カテゴリー)といった感じです。
itemのcategory_idはこの例でいうとTシャツ(孫カテゴリー)のidと紐づくような実装になっているって感じです。
なので、メンズカテゴリーを選択して検索した時に、そこに内包されている孫カテゴリーと一致したレコード(商品)をとってくるのが結構大変だったんです。
今回はここについて具体的にどうやって実装したのかを説明します。
Categoriesテーブルについて
ancestry
というgemを使っています。アソシエーションにある「has_ancestry」はこのgemのおかげで使えてます。
簡単に言うと、カテゴリが「親要素」「子要素」「孫要素」みたいな構造になっているものをこのテーブルに用意したancestry
というカラムの中だけで良い感じに管理してくれる便利な子です。うん。良い感じ。
そんな説明でわかるかよって方は、この方の記事を!公式ドキュメントを和訳してくれてます。ありがたやぁ。
【翻訳】Gem Ancestry公式ドキュメント
###コントローラー
今回は7つのアクションとは別に、items_controllerにselect_category_index
というものを作ってその中で検索結果のレコードを拾ってくるように処理しています。
こいつについて詳しく解説予定。
##ビューの実装
###親カテゴリ
親カテゴリのリストでも、子カテゴリのリストでも、孫カテゴリのリストでも、全てリンク先は同じです。
itemコントローラーに設定した「select_category_index」というアクションに飛ぶようにしています。
その時に、params[:id]でカテゴリーのidを渡せるようになってます。
実際のソースも一応下に書いてます。解説もしてますが、本題じゃないので結構ざっくりです。
お節介焼きのスピードワゴンがみたらクールに去るなんて言ってられないですね。
- @category_parent_array.each do |parent_categories|
.TopHeader__Inner__Lists__Left__Category__ParentCategories__List{data: {id: "#{parent_categories.id}"}}
= link_to "#{parent_categories.name}",select_category_index_item_path(parent_categories.id), class: "parent_category",id: "#{parent_categories.id}"
リンク先をselect_category_index_item_path(parent_categories.id)
とすることで、アクションにparamsでカテゴリーのidを送るように実装しています。
####補足 @category_parent_array について
Categoriesテーブルから親カテゴリのレコードを引っ張ってきています。
ancestry
のgemに従ったテーブルでは、親カテゴリに該当するレコードはancestry
カラムがnilとなります。
ので、where句を使ってnilのものを拾ってきて、変数につっこんどります。
こんな感じ↓
def set_category_list
@category_parent_array = Category.where(ancestry: nil)
end
###子カテゴリ
// 子カテゴリーを追加するための処理
function buildChildHTML(child){
var html =`<a class="CildrenMenu__List" id="${child.id}"
href="/items/${child.id}/select_category_index">${child.name}</a>`;
return html;
}
ごめんなさい、jQueryでリストを追加しているのでこっちはHTMLの記述です(笑
こっちもやっていることは同じ。${child.id}
に子カテゴリーのidが入るようになってます。
###孫カテゴリ
子カテゴリと同じことやっているので割愛しますね。
別に書くの疲れたとかじゃないから
#さてさて本題。カテゴリの階層別のとってき方をどうしたのか。
実際に記述したselect_category_index
アクションをベースに解説します!
ボリューム感に「ウエッ」となるかと思いますが、頑張って部分部分で解説していきます!
まずは、クリックしたリストが、親カテゴリーなのか、子カテゴリーなのか、孫カテゴリーなのかで条件分岐しています。
def select_category_index
# カテゴリ名を取得するために@categoryにレコードをとってくる
@category = Category.find_by(id: params[:id])
# 親カテゴリーを選択していた場合の処理
if @category.ancestry == nil
# Categoryモデル内の親カテゴリーに紐づく孫カテゴリーのidを取得
category = Category.find_by(id: params[:id]).indirect_ids
# 孫カテゴリーに該当するitemsテーブルのレコードを入れるようの配列を用意
@items = []
# find_itemメソッドで処理
find_item(category)
# 孫カテゴリーを選択していた場合の処理
elsif @category.ancestry.include?("/")
# Categoryモデル内の親カテゴリーに紐づく孫カテゴリーのidを取得
@items = Item.where(category_id: params[:id])
# 子カテゴリーを選択していた場合の処理
else
category = Category.find_by(id: params[:id]).child_ids
# 孫カテゴリーに該当するitemsテーブルのレコードを入れるようの配列を用意
@items = []
# find_itemメソッドで処理
find_item(category)
end
end
select_category_indexの解説
select_category_indexアクションですが、
if文で処理を分けています。その中で同一の処理が一部あったので、find_itemメソッドとして切り分けています。
###3行目
@category = Category.find_by(id: params[:id]) #3行目
if @category.ancestry == nil #6行目
まず、Categoriesテーブルから、該当のレコードを拾ってきます。
params[:id]にはリストでクリックしたカテゴリのidを渡しています。
※ナニ言ってんだ?って方はこの記事の「親カテゴリのリストの実装抜粋.html.haml」あたりをみていただければいいかも。
###if文の解説
# 親カテゴリーを選択していた場合の処理
if @category.ancestry == nil #6行目
#~(中略)~
# 孫カテゴリーを選択していた場合の処理
elsif @category.ancestry.include?("/") #15行目
#~(中略)~
# 子カテゴリーを選択していた場合の処理
else # 20行目
ancestryカラムの中身にはカテゴリーの階層によって規則性があります。
なのでそれを利用して条件を分岐させています。
規則性はこんな感じ。
カテゴリ | ancestryのカラムの中身 |
---|---|
親カテゴリ | NULL(nil) |
子カテゴリ | 1とか2とか |
孫カテゴリ | 1/1とか1/2とか2/2とか2/10とか |
なので、6行目では
「ancestryカラムがnilだったらそのidは親カテゴリだぞ!」
15行目では
「ancestryカラムに"/"が含まれていれば孫カテゴリだぞ!」
20行目では
「6行目、15行目どっちにも該当しないから子カテゴリだぞ!」
と、ここで切り分けを行っています。
###if文で切り分けた後の処理
#~(中略)~
category = Category.find_by(id: params[:id]).indirect_ids #8行目
@items = []
# find_itemメソッドで処理
find_item(category)
#~(中略)~
category = Category.find_by(id: params[:id]).child_ids #21行目
@items = []
# find_itemメソッドで処理
find_item(category)
それぞれの行の末尾にある.indirect_ids
、.child_ids
は ancestry
gemが用意してくれてるインスタンスメソッドです。
.indirect_ids
は、対象のレコードに紐づいた孫レコードのIDを引っ張ってきてくれます。
.child_ids
は、対象のレコードに紐づいた子レコードのIDを引っ張ってきてくれています。
引っ張ってきたものを配列でcategory
変数に入れてくれるんです。
めっちゃ便利〜!
なので、まとめると
8行目、21行目ともに孫カテゴリーのidが、category
変数の中に配列として大量に突っ込まれるわけですね。
え、なんで21行目では.child_ids
と書いてあるのに孫カテゴリーのidが入るのかだって?
それは、21行目は子カテゴリーのリストをクリックした時の処理だからです。
もし、ここで.indirect_ids
のメソッドを使っていたら、拾ってくるidは子カテゴリーの孫カテゴリーに値するひ孫カテゴリーになってしまいます。
今回は孫カテゴリーを取得したい。孫カテゴリーって子カテゴリーからみて、子要素ですよね。
だから、ここでは子カテゴリーの子要素を取得したいので、.child_ids
にしています。
図にしてみました。(汚いとか言わないで)
子カテゴリーからしてみたら、孫カテゴリーは子カテゴリーだ。の図↓
その後それぞれの行の下に、@itemsという配列用のインスタンス変数を定義します。
itemが複数入ることを想定して配列オブジェクトとして用意しています。
@items = []
さてさて、次は独自で作ったfind_itemメソッドにcategory変数を渡して、何やら処理をするそうです。
ここについて次のセクションで解説しますね。
##find_itemの解説
わかってます。
これ完全に読者置いてきぼりパターンです。
こんな長い記事誰が読むねん。って自分でも思っています。
でも解説し始めたら最後までしっかり解説しないとダメです。中途半端。ダメ、絶対。
def find_item(category)
category.each do |id|
item_array = Item.includes(:images).where(category_id: id)
# find_by()メソッドで該当のレコードがなかった場合、itemオブジェクトに空の配列を入れないようにするための処理
if item_array.present?
item_array.each do |item|
if item.present?
else
# find_by()メソッドで該当のレコードが見つかった場合、@item配列オブジェクトにそのレコードを追加する
@items.push(item)
end
end
end
end
end
###2~3行目
category.each do |id|
item_array = Item.includes(:images).where(category_id: id)
ここです。ここが肝です。
category
変数には何が入っているんでしたっけ?
そうです。孫レコードのIDが配列で入っているんでした。
それをeach文で該当する孫(または子)レコード一つ一つをひっぱり出して、見つかったらitem_array
メソッドに入れます。
引っ張り出したidと、category_idが一致しているitem(出品した商品)をwhere句で拾ってます。
idとcategory_idが一緒の商品達、みんな集まれ〜!って処理です。
「あつまれ category_idの森」ってことですね。あつもり。
####find_byで拾わない理由
find_byは最初に一致したレコードだけを拾ってくる人です。
例えば、category_id: 1 はTシャツだとします。
カテゴリーをTシャツに設定している商品は一つとは限りません。いっぱいあるはずです。このフリマアプリを使ってくれる人がいればw
もし、find_byで一致する商品を拾うと、複数あるうちの1つしか拾えませんよね?
今回はcategory_idとidが一致している全ての商品を引っ張り出したいです。
メンズのTシャツなら全てのメンズのTシャツを拾ってきたい訳です。
なので、where句を使って該当する商品を全て引っ張ってくるように記述しています。
###5~12行目
if item_array.present?
item_array.each do |item|
if item.present?
# find_by()メソッドで該当のレコードが見つかった場合、@item配列オブジェクトにそのレコードを追加する
@items.push(item)
end
end
end
2~3行目で実行したeach文ですが、
繰り返し処理をした結果「孫レコードのIDに該当するItemがなかったの(´・ω・`)」ってこともあると思います。
無かった場合はシカトして、レコードがあった場合のみ@items
変数の仲間入りをさせてあげたいです。
それをするために、5行目でまたif文を使ってふるいがけをしています。
ここを日本語に言い換えると
「変数item_array
の中身があった場合(言い換えると該当する商品があった場合)に実行するよ」
と言ってます。
item_array
には複数の商品情報が配列で入っているんですよね。
なので、それをまたeach文を使って引っ張り出して、一つ一つ丹精込めて@items
に入れ直してあげています。
####なんでこんなまどろっこしい事をε=(´Д ) ここでif文を使ってふるいがけする理由はなぜでしょうか。 もし、孫レコードのIDに該当するItem(商品)が無かったら、空っぽの配列が爆誕します。 んで、その中身の無い空っぽな奴が
@itemsの仲間に入ろうとします。 中身の無い ~~何の価値もない~~ やつが仲間入りすると、
@items`の中身は↓みたいになる可能性があります。
[[],[],[],[idと一致したcategory_idを持っている商品],[],[],[idと一致したcategory_idを持っている商品],....]
どうです?[]
こいつ邪魔じゃ無いですか?笑
と思って、if item.present?
で弾くようにしました。
この処理で弾く事で、@items
の中身は下のようになります。
[[idと一致したcategory_idを持っている商品],[idと一致したcategory_idを持っている商品],[idと一致したcategory_idを持っている商品],....]
スッキリ!!!
はい。あとは拾ってきたレコードが入った@item
をviewで使って必要な情報を表示してあげればOKです!
長々とお疲れ様でした・・・笑
#お疲れ様でした。
お付き合いありがとうございました!
改めてまとめるとこんな感じですね。
- クリックしたリストがカテゴリーの階層のどこに当たるかを条件分岐で切り分ける
- クリックしたリストのカテゴリー以下の該当するidを全て拾ってくる
- 拾ったidをもとに繰り返し処理をして、
@items
変数に全て入れる
やってることについては、たった3行で説明できるんですよね(笑
この内容が誰かの手助けになれば良いな。と思っています。
加えて、「いや、ここもっとこうできるけど」とかあれば是非とも教えていただきたいです。
#最後に
長々とお付き合いいただきありがとうございました。
エンジニアの勉強を初めて約60日程度。60日前にはこんなことできるなんて思っていなかったです。
もちろんうまくいかないことがあって嫌になることも時々ありますが、それでも解決した時の喜びだったり、解決の兆しが見えた時は熱中してしまいますね。もっと楽しめるように色々知識をつけたいです!
#参考にさせていただいた記事
最後に実装する上ですごい助けなった記事をリンクしておきます。大変ためになりますので、僕の記事は良いからこちらの記事をみていただきたいです(笑
多階層カテゴリの検索ウインドウを作る
【翻訳】Gem Ancestry公式ドキュメント
「Rails 5」where配列同士で検索する(外部結合)