【Rails 6】Formオブジェクトでシンプルな検索機能を作ってみる

## 概要

記事タイトル通り、Form オブジェクトを使用したシンプルな検索機能を Rails6 で作ってみます。

Form オブジェクトの概念については、以下の記事の表現が非常にわかりやすかったので参考にされてください。

> Formオブジェクトはフォームの責務をカプセル化し、コントローラやビューを疎結合に保つために必要なデザインパターンです。

> ユーザの入力の整形や永続化をコントローラだけで行うと、コントローラが肥大化してしまいます。 この原因はコントローラがモデル層の知識をもちすぎるためにあります。 このときビューもフォームを表示するための知識をもつことになるため、コントローラと同じような問題が起こってしまいます。 このことは単一責任の原則に反し、モデル層の変更がコントローラやビューに影響を及ぼすことになります。

> 逆にActiveRecordモデルにこういった責務をもたせると、今度はActiveRecordモデルがフォームの知識を持ちすぎてしまいます。 フォームという独立した責務があるのであれば、これをひとつのクラスにカプセル化する、というのがFormオブジェクトの役割です。

参照: [Railsのデザインパターン: Formオブジェクト](https://applis.io/posts/rails-design-pattern-form-objects)

## 完成イメージ

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/c052281e-7683-8843-4f98-9a0768b69b5b.gif)

名前、性別、年齢、都道府県といった複数の条件でユーザーを絞り込んでいく事が可能です。


## 仕様

- Ruby 3
- Rails 6
- MySQL 5.7
- Bootstrap 5
- Docker

※ Bootstrap はバージョン5から JQuery が不要になり導入方法が少し変わったのでご注意ください。

## 下準備

まず最初に下準備から始めていきます。

### 各種ディレクトリ & ファイルを作成

```
$ mkdir rails-search-form && cd rails-search-form
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
```

```dockerfile:./Dockerfile
FROM ruby:3.0

RUN curl https://deb.nodesource.com/setup_14.x | bash

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
```

```yml:./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 4306:3306
  web:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  mysql-data:
```


```sh:./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
```

```ruby:./Gemfile.lock
# 空欄でOK
```

### rails new

おなじみのコマンドでアプリの雛型を作成。

```
$ docker-compose run web rails new . --force --no-deps -d mysql --skip-test
```

### database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

```yml:./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  host: db

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>
```

### コンテナを起動 & データベースを作成

```
$ docker-compose build
$ docker-compose up -d
$ docker-compose run web bundle exec rails db:create
```

### 動作確認（localhost:3000 にアクセス）

![スクリーンショット 2022-01-03 21.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6d3c7749-4e15-01c9-046e-9bb02ac964a6.png)


[localhost:3000](http://localhost:3000/) にアクセスしてウェルカムページが表示されればOKです。

### slim を導入

個人的にビューのテンプレートエンジンは erb よりも slim の方が好みなので変更します。

```rb:./Gemfile
gem "slim-rails"
gem "html2slim"
```

```
$ docker-compose build
```

既存のビューファイルを slim に書き換え。

```
$ docker-compose run web bundle exec erb2slim app/views app/views
$ docker-compose run web bundle exec erb2slim app/views app/views -d
```


### Bootstrap を導入

見た目を整えるために Bootstrap を使用します。

※ 下記の手順はバージョン5を想定したものになるので、別のバージョンを使いたい場合は他の記事を参考に導入してください。

#### yarn install

必要なライブラリをインストール。

```
$ docker-compose run web yarn add bootstrap @popperjs/core
```

#### app/views/layouts/application.html

```diff_ruby:./app/views/layouts/application.html
- = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
+ = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
```

「app/views/layouts/application.html」内の9行目あたりに記述されている「stylesheet_link_tag」を「stylesheet_pack_tag」に書き換えます。

#### app/javascript/stylesheets/application.scss

```
$ mkdir app/javascript/stylesheets
$ touch app/javascript/stylesheets/application.scss
```

「app/javascript/stylesheets/」以下に「application.scss」を作成し、次の１行を追記してください。

```rb:./app/javascript/stylesheets/application.scss
@import "~bootstrap/scss/bootstrap";
```

#### app/javascript/packs/application.js

「app/javascript/packs/application.js」内に次の2行を追記。

```javascript:./app/javascript/packs/application.js
import "bootstrap";
import "../stylesheets/application";
```

これで Bootstrap の設定は完了です。

## 本実装

準備ができたので、本格的な実装に入りましょう。

### User モデルを作成

![Untitled Diagram.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/b7dbbdf2-5943-a1c8-0a70-544e9d8dd140.png)

今回は簡単な User モデルを例に検索機能を実装していきたいと思います。

- 名前で検索（姓・名・フルネーム）
- 性別で検索
- 年齢で検索
- 都道府県で検索


```
$ docker-compose run web rails g model User first_name:string last_name:string gender:integer birthdate:date prefecture_id:integer
$ docker-compose run web rails db:migrate
```

```rb:./app/models/user.rb
class User < ApplicationRecord
  extend ActiveHash::Associations::ActiveRecordExtensions
  belongs_to_active_hash :prefecture

  validates :first_name, presence: true, length: { maximum: 10 }
  validates :last_name, presence: true, length: { maximum: 10 }
  validates :gender, presence: true
  validates :birthdate, presence: true
  validates :prefecture_id, presence: true

  enum gender: { male: 0, female: 1 }

  # 新しい順
  scope :order_by_latest, -> { order(id: :desc) }
  # 古い順
  scope :order_by_oldest, -> { order(id: :asc) }

  # フルネーム（姓 + 名）
  def full_name
    "#{last_name} #{first_name}"
  end

  # 年齢（生年月日から計算）
  def age
    return if birthdate.blank?

    date_format = "%Y%m%d"
    (Date.today.strftime(date_format).to_i - birthdate.strftime(date_format).to_i) / 10_000
  end
end
```

### サンプルデータを作成

```rb:./Gemfile
gem "active_hash"
gem "faker"
```

```
$ docker-compose build
```

- [active_hash](https://github.com/zilkey/active_hash)
    - ハッシュ形式のデータをActiveRecordと同じ感覚で使用できるようになるgem。基本的に変更される事の無い「都道府県」のような情報を取り扱う時などに便利。
- [faker](https://github.com/faker-ruby/faker)
    - ランダムな値のダミーデータを作成する際に定番のgem。一部で日本語対応も。



```
$ docker-compose run web rails g model Prefecture --skip-migration
```

```rb:./app/models/prefecture.rb
class Prefecture < ActiveHash::Base
  self.data = [
      {id: 1, name: "北海道"}, {id: 2, name: "青森県"}, {id: 3, name: "岩手県"},
      {id: 4, name: "宮城県"}, {id: 5, name: "秋田県"}, {id: 6, name: "山形県"},
      {id: 7, name: "福島県"}, {id: 8, name: "茨城県"}, {id: 9, name: "栃木県"},
      {id: 10, name: "群馬県"}, {id: 11, name: "埼玉県"}, {id: 12, name: "千葉県"},
      {id: 13, name: "東京都"}, {id: 14, name: "神奈川県"}, {id: 15, name: "新潟県"},
      {id: 16, name: "富山県"}, {id: 17, name: "石川県"}, {id: 18, name: "福井県"},
      {id: 19, name: "山梨県"}, {id: 20, name: "長野県"}, {id: 21, name: "岐阜県"},
      {id: 22, name: "静岡県"}, {id: 23, name: "愛知県"}, {id: 24, name: "三重県"},
      {id: 25, name: "滋賀県"}, {id: 26, name: "京都府"}, {id: 27, name: "大阪府"},
      {id: 28, name: "兵庫県"}, {id: 29, name: "奈良県"}, {id: 30, name: "和歌山県"},
      {id: 31, name: "鳥取県"}, {id: 32, name: "島根県"}, {id: 33, name: "岡山県"},
      {id: 34, name: "広島県"}, {id: 35, name: "山口県"}, {id: 36, name: "徳島県"},
      {id: 37, name: "香川県"}, {id: 38, name: "愛媛県"}, {id: 39, name: "高知県"},
      {id: 40, name: "福岡県"}, {id: 41, name: "佐賀県"}, {id: 42, name: "長崎県"},
      {id: 43, name: "熊本県"}, {id: 44, name: "大分県"}, {id: 45, name: "宮崎県"},
      {id: 46, name: "鹿児島県"}, {id: 47, name: "沖縄県"}
  ]
end
```

「db/seeds.rb」内を以下のように編集し、サンプルデータを挿入してください。

```rb:./db/seeds.rb
puts "Creating users..."

Faker::Config.locale = "ja"

500.times do
  gender = User.genders.keys.sample

  User.create!(
    first_name: gender == "male" ? Faker::Name.male_first_name : Faker::Name.female_first_name,
    last_name: Faker::Name.last_name,
    gender: gender,
    birthdate:  Faker::Date.birthday(min_age: 18, max_age: 65),
    prefecture_id: Prefecture.all.sample.id
  )
end

puts "Finished"
```

```
$ docker-compose run web rails db:seed
```

![スクリーンショット 2022-01-04 9.52.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/d963341a-2284-9fa2-f49a-6a9e384ec104.png)

良い感じに入ってますね。

### 日本語化対応

「config/application.rb」内に以下を内容を記述し、デフォルトのタイムゾーンや言語を日本に変更してください。


```rb:./config/application.rb
module Myapp
  class Application < Rails::Application
    # ...
    
    # 追記
    config.time_zone = "Tokyo"
    config.active_record.default_timezone = :local
    config.i18n.default_locale = :ja
    config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}").to_s]
    
    # ...
  end
end
```

また、「config/locals/」以下にja.ymlを作成します。

```
$ touch config/locales/ja.yml
```

```yml:./config/locales/ja.yml
ja:
  activerecord:
    models:
      user: ユーザー
    attributes:
      id: ID
      created_at: 作成日時
      updated_at: 更新日時
      user:
        first_name: 名
        last_name: 姓
        full_name: 名前
        gender: 性別
        birthdate: 生年月日
        age: 年齢
        prefecture_id: 都道府県
  enums:
    user:
      gender:
        male: 男性
        female: 女性
```

### Form オブジェクト（UserSerachForm） を作成

ここからが本記事の目玉。
検索を行うためのロジックを Form オブジェクトで実装していきます。

```
$ mkdir app/forms
$ touch app/forms/user_search_form.rb
```

```rb:./app/forms/user_search_form.rb
class UserSearchForm
  include ActiveModel::Model

  attr_accessor :first_name, :last_name, :gender, :min_age, :max_age, :prefecture_id

  def initialize(params = {})
    @first_name = params[:first_name]
    @last_name = params[:last_name]
    @gender = params[:gender]
    @min_age = params[:min_age]
    @max_age = params[:max_age]
    @prefecture_id = params[:prefecture_id]
  end

  # クエリを実行
  def query
    base_relation
      .then(&method(:search_by_name))
      .then(&method(:search_by_gender))
      .then(&method(:search_by_ages))
      .then(&method(:search_by_prefecture_id))
  end

  private

    # 基本条件
    def base_relation
      User.all
    end

    # 名前で検索（必ずしも姓・名の両方が入力されるとは限らないため、入力された値によって検索条件を変える）
    def search_by_name(relation)
      if first_name.present? && last_name.present?  # 姓・名の両方が入力された場合
        search_by_full_name(relation)
      elsif first_name.present? && last_name.blank? # 名のみ入力された場合
        search_by_first_name(relation)
      elsif first_name.blank? && last_name.present? # 姓のみ入力された場合
        search_by_last_name(relation)
      else
        relation
      end
    end

    # フルネーム（姓 + 名）で検索
    def search_by_full_name(relation)
      relation
        .where("last_name LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(last_name)}%")
        .where("first_name LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(first_name)}%")
    end

    # 名で検索
    def search_by_first_name(relation)
      relation.where("first_name LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(first_name)}%")
    end

    # 姓で検索
    def search_by_last_name(relation)
      relation.where("last_name LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(last_name)}%")
    end
    
    # 性別で検索
    def search_by_gender(relation)
      return relation if gender.blank?

      relation.where(gender: gender)
    end

    # 年齢で検索（Userモデルに年齢の値そのものが保持されているわけではないので誕生日から計算）
    def search_by_ages(relation)
      return relation if min_age.blank? || max_age.blank?

      start_date = Date.today - max_age.to_i.years
      end_date = Date.today - min_age.to_i.years

      relation.where(birthdate: start_date..end_date)
    end

    # 都道府県で検索
    def search_by_prefecture_id(relation)
      return relation if prefecture_id.blank?

      relation.where(prefecture_id: prefecture_id)
    end
end
```

基本的にはコードを読んでいただければ何をしているのかわかると思いますが、あまり見慣れない部分としては

```rb
def query
  base_relation
    .then(&method(:search_by_name))
    .then(&method(:search_by_gender))
    .then(&method(:search_by_ages))
    .then(&method(:search_by_prefecture_id))
end
```

この辺かなと思います。

この　`then` は「レシーバを引数としてブロックを呼び出し、そこで評価された結果を返す」というメソッドで、　`tap` メソッドと似ている事からしばしば比較されていたりします。

参照: [Ruby: Object#tap、Object#then を使ってみよう](https://techracho.bpsinc.jp/kazz/2019_12_23/85305)

その性質からメソッドチェーンにおいて便利と言われており、今回もそんな感じの用途で使用しました。

「一つ前の処理で返ってきた結果に対し再度処理を施してその結果を返す...」 というプロセスを繰り返し、各条件に合わせた絞り込みを行なっていくイメージですね。

今回は &記法にしているので若干わかりにくいかもしれませんが、もう少し素直に書くなら

```rb
def query
  base_relation
    .then { |relation| search_by_name(relation)}
    .then { |relation| search_by_gender(relation)}
    .then { |relation| search_by_ages(relation)}
    .then { |relation| search_by_prefecture_id(relation)}
end
```

こんな感じになると思います。どちらでも正常に動くのでお好きな方を採用してください。


ちなみに、Rubyには `yiled_self` という同じ挙動を持つ別のメソッドがあったりしますが、 「then」 はそのエイリアスになります。（「yiled_self」という名前がやや不評で後ほど追加されたとの事。）

### コントローラーを作成

コントローラーを作成し、先ほど定義した Form オブジェクトを呼び出します。

```
$ docker-compose run web rails g controller users
```

```rb:./app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.order_by_latest
  end

  def search
    @users = UserSearchForm.new(user_search_params).query.order_by_latest

    render :index
  end

  private
  
    # 検索用のストロングパラメータ
    def user_search_params
      params.fetch(:q, {}).permit(
        :first_name,
        :last_name,
        :gender,
        :min_age,
        :max_age,
        :prefecture_id
      )
    end
end
```

### ビューを作成

見た目の部分を作り込んでいきます。

```
$ touch app/views/users/index.html.slim app/views/users/_search_form.html.slim
```

```slim:./app/views/users/index.html.slim
.container.p-3
  .row.mb-4
    .col-12
      .d-flex.align-items-center.justify-content-center
        = render "search_form"

  .row
    .col-12
      .mt-1.mb-2
        = "#{@users.count} 件"
      
      .card.card-outline
        .card-body
          table.table
            thead
              tr
                th scope="col"
                  = t("activerecord.attributes.id")
                th scope="col"
                  = t("activerecord.attributes.user.full_name")
                th scope="col"
                  = t("activerecord.attributes.user.gender")
                th scope="col"
                  = t("activerecord.attributes.user.age")
                th scope="col"
                  = t("activerecord.attributes.user.prefecture_id")

            - if @users.present?
              - @users.each do |user|
                tr
                  td = user.id
                  td = user.full_name
                  td = user.gender_i18n
                  td = user.age
                  td = user.prefecture.name
                  
            - else
              tr
                td 該当するデータがありません
                td
                td
                td
                td
```

```slim:./app/views/users/_search_form.html.slim
= form_with url: search_users_path, scope: :q, method: :get, local: true do |f|
  .row
    .col-4
      .form-group
        = f.label t("activerecord.attributes.user.last_name"), class: "control-label"
        = f.text_field :last_name, value: params.dig(:q, :last_name), class: "form-control"
    
    .col-4
      .form-group
        = f.label t("activerecord.attributes.user.first_name"), class: "control-label"
        = f.text_field :first_name, value: params.dig(:q, :first_name), class: "form-control"
    
    .col-4
      .form-group
        = f.label t("activerecord.attributes.user.gender"), class: "control-label"
        = f.select :gender, User.genders_i18n.invert, { include_blank: true, value: nil, selected: params.dig(:q, :gender) }, class: "form-select"
    
    .col-4.mt-2
      .form-group
        = f.label "#{t('activerecord.attributes.user.age')}（from）", class: "control-label"
        = f.select :min_age, [*(18..65)], { include_blank: true, value: nil, selected: params.dig(:q, :min_age) }, class: "form-select"
    
    .col-4.mt-2
      .form-group
        = f.label "#{t('activerecord.attributes.user.age')}（to）", class: "control-label"
        = f.select :max_age, [*(18..65)].reverse, { include_blank: true, value: nil, selected: params.dig(:q, :max_age) }, class: "form-select"
    
    .col-4.mt-2
      .form-group
        = f.label t("activerecord.attributes.user.prefecture_id")
        = f.collection_select :prefecture_id, Prefecture.all, :id, :name, { include_blank: true, value: nil, selected: params.dig(:q, :prefecture_id) }, class: "form-select"

  .d-flex.align-items-center.justify-content-center.mt-4
    = f.submit "検索", class: "btn btn-primary me-1"
    = link_to "リセット", users_path, class: "btn btn-secondary ms-1"
```

`scope: :q` という部分で検索用のパラメータにまとめている部分だけご注意ください。

参照: [form_withの:scopeオプション](https://endoakak.hatenablog.com/entry/2020/08/29/200000)

### ルーティングを設定

最後にルーティングを設定しましょう。

```rb:./config/routes.rb
Rails.application.routes.draw do
  resources :users, only: :index do
    collection do
      get :search
    end
  end
end
```

## 動作確認

![スクリーンショット 2022-01-04 22.47.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/53f0e306-8fd7-6dd7-7ada-a55eb12c32f1.png)


[localhost:3000/users](http://localhost:3000/users) にアクセスしてこんな感じになっていれば完成です。

試しに色々な条件で検索してみてください。

## あとがき

以上、Form オブジェクトを使ってシンプルな検索機能を作ってみました。

ロジック部分の大半を Form オブジェクトに切り出した事でモデル・コントローラー・ビューがすっきり保てているのが大きなメリットですね。

少しでも参考になれば幸いです。

https://github.com/kazama1209/rails-search-form