🦊

 【RubyonRails】form_withを使って検索機能を実装する方法

2024/08/12に公開

はじめに

RubyonRailsで検索機能を実装するまでのチュートリアルを共有します。
具体的には、以下のような内容で進めます。

  1. テーブルの設定
  2. Viewの設定
  3. Controllerの設定
  4. Modelの設定

0. 完成イメージ

Image from Gyazo

1. テーブル設定

今回はfoodsテーブルとfood_countriesテーブル、food_categoriesテーブルを作成します。
foodsテーブルにfood_country_idとfood_category_idを外部キーとして設定します。

Image from Gyazo

Foodモデル作成

Foodモデルをマイグレーションし、以下のカラムを追加します。

カラム名 データタイプ
name string
spice_level integer
price decimal{10,2}
title string
body string
docker-compose exec web bin/rails g migration CreateFoods name:string spice_level:integer price:decimal{10,2} title:string body:string

# マイグレーション
docker-compose exec web bin/rails db:migrate

FoodCategoryモデル作成

カラム名 データタイプ
name string
docker-compose exec web bin/rails g migration CreateFoodCategories name:string
docker-compose exec web bin/rails g migration AddReferenceFoodCategoriesIdToFoods

マイグレーションファイルの内容

  • add_reference :foods, :food_category
    • foodsテーブルにfood_categoryという参照カラムを追加
    • これはfood_categoriesテーブルのidを参照する外部キー
  • foreign_key: { on_update: :restrict, on_delete: :restrict }
    • 外部キー制約を追加
    • on_update: :restrict:
      • food_categoriesのidが更新されると、関連するfoodsのレコードも更新されることを制約
    • on_delete: :restrict
      • food_categoriesのidが削除されると、関連するfoodsのレコードも削除されることを制限
class AddReferenceFoodCategoriesIdToFoods < ActiveRecord::Migration[7.0]
  def change
    add_reference :foods, :food_category, foreign_key: { on_update: :restrict, on_delete: :restrict }, comment: '食品カテゴリ'
  end
end

マイグレーション

docker-compose exec web bin/rails db:migrate

Railsのアソシエーションを定義

  • 関連付け:FoodモデルがFoodCategoryモデルに属していることを示す
  • 外部キー:foodsテーブルにfood_category_idカラムが存在し、food_categoriesテーブルのidを参照
  • 一対多の関係:一つのFoodCategoryは複数のFoodを持つことができ、各Foodは一つのFoodCategoryに属する
class Food < ApplicationRecord
  belongs_to :food_category
end

FoodCountryモデル作成

カラム名 データタイプ
name string
docker-compose exec web bin/rails g migration CreateFoodCountries name:string
docker-compose exec web bin/rails g migration AddReferenceFoodCountriesIdToFoods

マイグレーションファイルの内容

  • add_reference :foods, :food_country
    • foodsテーブルにfood_countryという参照カラムを追加します。
    • これはfood_countriesテーブルのidを参照する外部キーです。
  • foreign_key: { on_update: :restrict, on_delete: :restrict }
    • 外部キー制約を追加
    • on_update: :restrict:
      • food_countriesのidが更新されると、関連するfoodsのレコードも更新されることを制限します。
    • on_delete: :restrict
      • food_countriesのidが削除されると、関連するfoodsのレコードも削除されることを制限します。
class AddReferenceFoodCountriesIdToFoods < ActiveRecord::Migration[7.0]
  def change
    add_reference :foods, :food_country, foreign_key: { on_update: :restrict, on_delete: :restrict }, comment: '国名'
  end
end

マイグレーション

docker-compose exec web bin/rails db:migrate

Railsのアソシエーションを定義

  • 関連付け:FoodモデルがFoodCountryモデルに属していることを示します。
  • 外部キー:foodsテーブルにfood_country_idカラムが存在し、food_countriesテーブルのidを参照します。
  • 一対多の関係:一つのFoodCountryは複数のFoodを持つことができ、各Foodは一つのFoodCountryに属します。
class Food < ApplicationRecord
  belongs_to :food_category
  belongs_to :food_country
end

2. Viewの設定

form_withの設定

form_withはRailsでフォームを作成するためのヘルパーメソッドです。

  • scope: :search
    • フォームのパラメータにsearchをプレフィックスとして追加します。これにより、送信されたパラメータがparams[:search]でアクセスできるようになります。
  • url: foods_path
    • フォームの送信先URLを指定します。
  • method: :get
    • フォームのHTTPメソッドを指定します。
  • local: true
    • 非同期でなく、通常のフォーム送信を行う設定です。
<%= form_with(scope: :search, url: foods_path, method: :get, local: true) do |f| %>

<% end %>

各フィールドの設定

  • collection_select(プルダウン選択)
    • f.collection_select
      • フォームビルダーを使ってセレクトボックスを生成します。
    • :food_country_id
      • フォームのパラメータ名です。選択された値はこのキーで送信されます。
    • FoodCountry.all
      • セレクトボックスの選択肢として表示するデータの集合です。ここではFoodCountryモデルの全てのレコードを取得します。
    • :id
      • セレクトボックスの値として使用するカラムです。選択された時に送信される値になります。
    • :name
      • セレクトボックスに表示されるラベルとして使用するカラムです。
    • { selected: @search_params[:food_country_id], include_blank: true }
      • selected: @search_params[:food_country_id]
        • デフォルトで選択される項目を指定します。
      • include_blank: true
        • 最初の選択肢を空白にします。
<%= f.collection_select(:food_country_id, FoodCountry.all, :id, :name, { selected: @search_params[:food_country_id], include_blank: true }) %>

  • number_field(価格の範囲指定)
    • f.number_field
      • 数値入力用のテキストフィールドを生成します。
    • :price_from
      • フォームのパラメータ名です。入力された値はこのキーで送信されます。
    • value: @search_params[:price_from]
      • フィールドの初期値を設定します。
<%= f.number_field :price_from, value: @search_params[:price_from] %> ~ <%= f.number_field :price_to, value: @search_params[:price_to] %>

  • text_field(テキストフィールド)
    • f.text_field
      • フォームビルダーを使用してテキストを入力します。
    • :keyword
      • フォームのパラメータ名です。入力された値はこのキーで送信されます。
    • value: @search_params[:keyword]
      • フィールドの初期値を設定します。
<%= f.text_field :keyword, value: @search_params[:keyword] %>

app/views/foods/search.html.erb

<!-- 検索フォーム -->
<h1> 検索フォーム</h1>

<!-- 条件検索機能-->
<%= form_with(scope: :search, url: foods_path, method: :get, local: true) do |f| %>
  <!-- 国から探す(セレクトボックス) -->
  <div>
    国から探す
    <%= f.collection_select(:food_country_id, FoodCountry.all, :id, :name, { selected: @search_params[:food_country_id], include_blank: true }) %>
  </div>

  <!-- カテゴリから探す(セレクトボックス) -->
  <div>
    カテゴリから探す
    <%= f.collection_select(:food_category_id, FoodCategory.all, :id, :name, { selected: @search_params[:food_category_id], include_blank: true }) %>
  </div>

  <!-- 値段から探す(範囲指定) -->
  <div>
    値段から探す
    <%= f.number_field :price_from, value: @search_params[:price_from] %> ~ <%= f.number_field :price_to, value: @search_params[:price_to] %>
  </div>

  <!-- キーワードから探す(テキストフィールド) -->
  <div>
    キーワードから探す
    <%= f.text_field :keyword, value: @search_params[:keyword] %>
  </div>

  <!-- 検索する -->
  <div>
    <%= f.submit "検索する" %>
  </div>

<% end %>

3. Controllerの設定

Controllerで以下の手順を実行します。

  1. 受け取った検索パラメータをチェックする(Strong Parameters)
  2. チェックした検索パラメータを保持する(インスタンス変数)
  3. 検索パラメータを元に検索する(Scope, includes)
  4. 検索結果を返す(インスタンス変数)

1. 受け取った検索パラメータをチェックする(Strong Parameters)

検索パラメータは、params[:search]のなかにKey, Valueのセットで検索パラメータが渡されるHash形式で受け取ります。

  • food_search_paramsメソッド
    • フォームから送信された検索パラメータを安全に受け取ります。
    • fetch
      • :searchキーのパラメータを取得し、存在しない場合は空のハッシュを返します。
    • petmit
      • 許可されたパラメータのみを受け取ります。
      • :food_country_id:国のIDを指定します。
      • :food_category_id:食品カテゴリのIDを指定します。
      • :proce_from:検索する価格の下限を指定します。
      • :price_to:検索する価格の上限を指定します。
      • :keyword:検索キーワードを指定します。
def food_search_params
    params.fetch(:search, {}).permit(:food_country_id,
                                     :food_category_id,
                                     :price_from,
                                     :price_to,
                                     :keyword)
end

2. チェックした検索パラメータを保持する(インスタンス変数)

  • @search_paramsインスタンス変数
    • 検索条件を保持し、Viewで利用可能にします。

3. 検索パラメータを元に検索する(Scope, includes)

  • Food.search(@search_params)メソッド
    • 検索条件に基づいてFoodデータを取得します。
    • searchスコープを使用して条件に合致するFoodデータをフィルタリングします。
    • includes(:food_category, :food_country)
      • 関連するfood_categoryとfood_countryのデータを事前にロードし、N+1問題を防ぎます。

4. 検索結果を返す(インスタン変数)

  • @foodsインスタンス変数
    • 検索結果を保持し、Viewで表示します。
  # 食品検索結果一覧ページ
  def index
    @search_params = food_search_params
    @foods = Food.search(@search_params).includes(:food_category, :food_country)
  end

  # トップページ
  def search
    # 初期化処理
    @search_params = {}
  end

4. Modelの設定

外部キー制約

  • belongs_to :food_category
    • FoodモデルがFoodCategoryモデルに属していることを示します。

検索メソッド

  • scope :search
    • 検索条件を受け取り、結果をフィルタリングします。
    • return if search_params.blank?
      • 検索パラメータが空の場合は何もせずに終了します。

スコープ

  • food_country_id_is:
    • food_country_idが指定されている場合、そのIDでフィルタリングします。
  • food_category_id_is:
    • food_category_idが指定されている場合、そのIDでフィルタリングします。
  • price_from:
    • priceが指定されたfrom以上のものをフィルタリングします。
  • price_to:
    • priceが指定されたto以下かつ0より大きいものをフィルタリングします。
    • price_toが指定されていない場合は、priceが0以上のものを取得します。
  • keyword_like:
    • name, title, bodyのいずれかにキーワードが含まれるものをフィルタリングします。
class Food < ApplicationRecord
  # 外部キー制約
  belongs_to :food_category
  belongs_to :food_country

  # 検索メソッド
  scope :search, -> (search_params) do
    # search_paramsが空の場合以降の処理を行わない。
    return if search_params.blank?

  # パラメータを指定して検索する
  food_country_id_is(search_params[:food_country_id])
    .food_category_id_is(search_params[:food_category_id])
    .price_from(search_params[:price_from])
    .price_to(search_params[:price_to])
    .keyword_like(search_params[:keyword])
  end

  # スコープ定義
  scope :food_country_id_is, -> (food_country_id) { where(food_country_id: food_country_id) if food_country_id.present? }
  scope :food_category_id_is, -> (food_category_id) { where(food_category_id: food_category_id) if food_category_id.present? }
  scope :price_from, -> (from) { where('? <= price', from) if from.present? }
  scope :price_to, -> (to) {
    if to.present?
      where('price <= ? AND price > 0', to) # priceが0より大きく、指定したto以下
    else
      where('price >= 0') # price_to が指定されていない場合の条件
    end
  }
  scope :keyword_like, -> (keyword) {
    where('name LIKE :keyword OR
           title LIKE :keyword OR
           body LIKE :keyword', keyword: "%#{keyword}%") if keyword.present?
  }
end

最後に

以上が、RubyonRailsで検索機能を実装するまでのチュートリアルです。

Discussion