概要
記事タイトル通り、Form オブジェクトを使用したシンプルな検索機能を Rails6 で作ってみます。
Form オブジェクトの概念については、以下の記事の表現が非常にわかりやすかったので参考にされてください。
Formオブジェクトはフォームの責務をカプセル化し、コントローラやビューを疎結合に保つために必要なデザインパターンです。
ユーザの入力の整形や永続化をコントローラだけで行うと、コントローラが肥大化してしまいます。 この原因はコントローラがモデル層の知識をもちすぎるためにあります。 このときビューもフォームを表示するための知識をもつことになるため、コントローラと同じような問題が起こってしまいます。 このことは単一責任の原則に反し、モデル層の変更がコントローラやビューに影響を及ぼすことになります。
逆にActiveRecordモデルにこういった責務をもたせると、今度はActiveRecordモデルがフォームの知識を持ちすぎてしまいます。 フォームという独立した責務があるのであれば、これをひとつのクラスにカプセル化する、というのがFormオブジェクトの役割です。
参照: Railsのデザインパターン: Formオブジェクト
完成イメージ
名前、性別、年齢、都道府県といった複数の条件でユーザーを絞り込んでいく事が可能です。
仕様
- 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
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"]
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:
# !/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 "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄でOK
rails new
おなじみのコマンドでアプリの雛型を作成。
$ docker-compose run web rails new . --force --no-deps -d mysql --skip-test
database.ymlを編集
デフォルトの状態だとデータベースとの接続ができないので「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 にアクセス)
localhost:3000 にアクセスしてウェルカムページが表示されればOKです。
slim を導入
個人的にビューのテンプレートエンジンは erb よりも slim の方が好みなので変更します。
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
- = 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」を作成し、次の1行を追記してください。
@import "~bootstrap/scss/bootstrap";
app/javascript/packs/application.js
「app/javascript/packs/application.js」内に次の2行を追記。
import "bootstrap";
import "../stylesheets/application";
これで Bootstrap の設定は完了です。
本実装
準備ができたので、本格的な実装に入りましょう。
User モデルを作成
今回は簡単な 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
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
サンプルデータを作成
gem "active_hash"
gem "faker"
$ docker-compose build
-
active_hash
- ハッシュ形式のデータをActiveRecordと同じ感覚で使用できるようになるgem。基本的に変更される事の無い「都道府県」のような情報を取り扱う時などに便利。
-
faker
- ランダムな値のダミーデータを作成する際に定番のgem。一部で日本語対応も。
$ docker-compose run web rails g model Prefecture --skip-migration
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」内を以下のように編集し、サンプルデータを挿入してください。
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
良い感じに入ってますね。
日本語化対応
「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
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
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
基本的にはコードを読んでいただければ何をしているのかわかると思いますが、あまり見慣れない部分としては
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 を使ってみよう
その性質からメソッドチェーンにおいて便利と言われており、今回もそんな感じの用途で使用しました。
「一つ前の処理で返ってきた結果に対し再度処理を施してその結果を返す...」 というプロセスを繰り返し、各条件に合わせた絞り込みを行なっていくイメージですね。
今回は &記法にしているので若干わかりにくいかもしれませんが、もう少し素直に書くなら
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
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
.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
= 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
という部分で検索用のパラメータにまとめている部分だけご注意ください。
ルーティングを設定
最後にルーティングを設定しましょう。
Rails.application.routes.draw do
resources :users, only: :index do
collection do
get :search
end
end
end
動作確認
localhost:3000/users にアクセスしてこんな感じになっていれば完成です。
試しに色々な条件で検索してみてください。
あとがき
以上、Form オブジェクトを使ってシンプルな検索機能を作ってみました。
ロジック部分の大半を Form オブジェクトに切り出した事でモデル・コントローラー・ビューがすっきり保てているのが大きなメリットですね。
少しでも参考になれば幸いです。