Help us understand the problem. What is going on with this article?

【Rails】カテゴリ検索機能の実装で学んだこと

はじめに

某プログラミングスクールでフリマアプリを作っています。
そこで、カテゴリ別検索機能の実装を頑張りました。
ので、そこで学んだことだったり、実装方針についてまとめようかな。って思います。

やったこと

これを実装しました。

実装の手順としては、大きく分けて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を渡せるようになってます。

実際のソースも一応下に書いてます。解説もしてますが、本題じゃないので結構ざっくりです。
お節介焼きのスピードワゴンがみたらクールに去るなんて言ってられないですね。

親カテゴリのリストの実装抜粋.html.haml
- @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のものを拾ってきて、変数につっこんどります。
こんな感じ↓

controller.rb
  def set_category_list
    @category_parent_array = Category.where(ancestry: nil)
  end

子カテゴリ

categories.js
  // 子カテゴリーを追加するための処理
  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アクションをベースに解説します!
ボリューム感に「ウエッ」となるかと思いますが、頑張って部分部分で解説していきます!

まずは、クリックしたリストが、親カテゴリーなのか、子カテゴリーなのか、孫カテゴリーなのかで条件分岐しています。

items_controller.rb
  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行目

items_controller.rb
 @category = Category.find_by(id: params[:id]) #3行目
 if @category.ancestry == nil #6行目

まず、Categoriesテーブルから、該当のレコードを拾ってきます。
params[:id]にはリストでクリックしたカテゴリのidを渡しています。
※ナニ言ってんだ?って方はこの記事の「親カテゴリのリストの実装抜粋.html.haml」あたりをみていただければいいかも。

if文の解説

(抜粋)item_controller.rb
 # 親カテゴリーを選択していた場合の処理
 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文で切り分けた後の処理

(親カテゴリ、子カテゴリの条件に一致していた時にまずやること)items_controller.rb
#~(中略)~
  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_idsancestry gemが用意してくれてるインスタンスメソッドです。

.indirect_idsは、対象のレコードに紐づいた孫レコードのIDを引っ張ってきてくれます。
.child_idsは、対象のレコードに紐づいた子レコードのIDを引っ張ってきてくれています。
引っ張ってきたものを配列でcategory変数に入れてくれるんです。
めっちゃ便利〜!

なので、まとめると
8行目、21行目ともに孫カテゴリーのidが、category変数の中に配列として大量に突っ込まれるわけですね。

え、なんで21行目では.child_idsと書いてあるのに孫カテゴリーのidが入るのかだって?
それは、21行目は子カテゴリーのリストをクリックした時の処理だからです。
もし、ここで.indirect_idsのメソッドを使っていたら、拾ってくるidは子カテゴリーの孫カテゴリーに値するひ孫カテゴリーになってしまいます。
今回は孫カテゴリーを取得したい。孫カテゴリーって子カテゴリーからみて、子要素ですよね。
だから、ここでは子カテゴリーの子要素を取得したいので、.child_idsにしています。

図にしてみました。(汚いとか言わないで)
子カテゴリーからしてみたら、孫カテゴリーは子カテゴリーだ。の図↓

その後それぞれの行の下に、@itemsという配列用のインスタンス変数を定義します。
itemが複数入ることを想定して配列オブジェクトとして用意しています。

(抜粋)item_controller.rb
@items = []

さてさて、次は独自で作ったfind_itemメソッドにcategory変数を渡して、何やら処理をするそうです。
ここについて次のセクションで解説しますね。

find_itemの解説

わかってます。
これ完全に読者置いてきぼりパターンです。
こんな長い記事誰が読むねん。って自分でも思っています。

でも解説し始めたら最後までしっかり解説しないとダメです。中途半端。ダメ、絶対。

(find_itemメソッド抜粋)item_controller.rb
  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行目

(find_itemメソッド抜粋)item_controller.rb
 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行目

(find_item)items_controller.rb
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配列同士で検索する(外部結合)

dr_tensyo
エンジニアを目指すために2020年2月17日から本格的に勉強開始しました。 現在勉強中の言語 HTML,CSS,Haml,Scss,Ruby,Ruby on Rails,Swift,java 趣味はドラム演奏と服と靴とコーヒーとアニメとゲーム
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした