背景
@shop
に紐づく@comment
、@employee
など、様々な子要素があるテーブルがあります。初回情報登録時には、@shop
、@comment
、@employee
など、親要素と一緒に全ての子要素も一緒に保存できるようにしていたのですが、
下記の記事のような形で、accepts_nested_attributes_for
を用いてこれらを実現していたものの、
▼こんなふうに実現していました
fields_forで子テーブルのデータを一気に作成する(テストも書いてます)[Rails][Rspec]
そのうち、@shop
だけの編集フォームや、@comment
、@employee
などの投稿・編集フォームも必要になってきたため、だんだんmodel
が様々な記述で肥大化してきました。
form_objectとは?
↑上記のような状態の時に、特定のフォームに関するバリデーションやデフォルト値の設定などを一箇所に集め、モデルの記述を簡素化できるのが、form_object
です。個人的には、導入にかなりつまづいてしまったので、記事を書いて記録を残しておこうと思います。
なお、実行環境は下記の通りです。
- Rails 5.2.4.2
- rspec-rails 4.0.1
導入方法
form_object
, controller
, view
の基本の書き方は下記の通りです。なお、今回は@shop
の初回登録時に@comment
も1件登録できるようなフォームを例にしたいと思います。
実装にあたって、一番参考にさせていただいたのは、こちらの記事です。
accepts_nested_attributes_forを使わず、複数の子レコードを保存する
DB構造
shops | |
---|---|
name | string |
category | integer |
↑ categoryはショップ種別。enum
のカラム。
comments | |
---|---|
content | text |
shop_id | integer |
作成したファイル
form_object
class ShopEntryForm
include ActiveModel::Model
# @shopに関する記述 -----------------------------
concerning :ShopBuilder do
def initialize(params = {})
super(params)
@category = params[:category]
end
def shop
@shop ||= Shop.new
end
end
attr_accessor :name, :category
validates :name, presence: true
validates :category, presence: true
# @commentに関する記述 -----------------------------
concerning :CommentBuilder do
attr_reader :comments_attributes
def comments
@comments_attributes ||= Comment.new
end
def comments_attributes=(attributes)
@comments_attributes = Comment.new(attributes)
end
end
attr_accessor :content
# 実装のロジック ------------------------------------
def save
# バリデーションエラーならfalseを返して以下の処理は行わない
return false if invalid?
shop.assign_attributes(shop_params)
build_asscociation
shop.save ? true : false
end
private
def shop_params
{
name: name,
category: @category,
}
end
def build_asscociations
# shopの子要素にcommentを追加する。ただし、中身が空なら追加しない。
shop.comments << comments if comments[:content].present?
end
end
これだけでつまづきどころがかなりありました。。。。
まず、concerning :ShopBuilder do ... end
の部分ですが、以下のような意味を持ちます。
# この記述は...
concern :ShopBuilder do
...
end
# 下記と同じ
module ShopBuilder
extend ActiveSupport::Concern
...
end
詳しくは、実装にあたって参考にした、こちらの記事をご覧ください。
次に、initialize(params = {}) ... end
の部分なのですが、以下のような意味を持ちます。
def initialize(params = {})
# @shopのparamsにアクセスできるようにする
super(params)
# DBでデフォルト値が設定されているカラム用の記述
@category = params[:category]
end
まず、super(params)
については、こちらも実装にあたって大変参考にさせていただいた記事である以下の記事によると
super(params)
でパラメーターを格納する記述で、以下の記述と同じ意味を持ちます。
@attributes = self.class._default_attributes.deep_dup
assign_attributes(params)
また、db側でデフォルト値が設定されているカラムは、以下のように明示的にparamsにアクセスすることを書かないとparamsにアクセスできず、値を入力してもDBのデフォルト値になってしまいました...。
@category = params[:category]
この謎は解けず。今後の課題としたいです。。。
enumを使ったカラムにdb側でデフォルト値が必要な理由は、こちらの記事をご覧ください。
そして def comments_attributes=(attributes) ... end
の部分なのですが、
def comments_attributes=(attributes)
@comments_attributes = Comment.new(attributes)
end
こちらはRailsばかりやっているとなかなか目にしない、セッターメソッド
という書き方で、=でおわるメソッド(引数)
の形で、引数によって@のつく要素を変更することができます。
個人的には、こんなことをやっているイメージに近いのではないかなと思いました。
def comments_attributes=(attributes) # ... 以下略
# こんなイメージ
comments_attributes = attributes
# なので、こんな感じに呼び出せる
self.comments_attributes
# => attributesの中身
Rubyのゲッターとセッターを正しく理解していなかったせいですね。。。。トホホ。。。頑張ります。。。
なお、=でおわるメソッド
については、『プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで』のp215
を15回ぐらい読み直しました。
controller
次は、コントローラーの記述です。コントローラーはこのような形になりました。
class ShopsController < ApplicationController
def new
@shop = ShopEntryForm.new
end
def create
@shop = ShopEntryForm.new(shop_entry_params)
if @shop.save
# 成功したときの処理
else
# 失敗したときの処理
end
end
private
def shop_entry_params
params.require(:shop_entry_form).permit(:caregory,
:name,
comments_attributes: [:content])
end
end
こちらは、意外に記述が減らなかった印象があります。当初shop_entry_params
がcontrollerから減ってくれればいいなーと期待したものの、結局controllerからは消せず。アソシエーションを作るメソッドだけはcontrollerから削除することができました。
なお、Modelに関しては、**バリデーションとデフォルト値設定のメソッド、アソシエーションなども全て消すことができました!**増えた記述は、なし!!やはり、form_object
はモデルをスリム化するために便利な書き方なのですね!!
View
最後に、Viewはこのようになっています。
= form_with model: @shop, url: shops_path, local: true do |f|
= f.text_field :name
= f.fields_for :shop_comments, local: true do |comment_form|
= comment_form.text_field
= f.submit "送信"
fields_for
を使うあたりは、accept_nested_attributes_for
を使った実装と変わらないのですね^^
テスト
テストも至ってシンプルでした!
require 'rails_helper'
RSpec.describe ShopEntryForm, type: :model do
before do
@shop_form = ShopEntryForm.new(category: "category1", name: "テストのお店")
end
describe "バリデーションのテスト" do
it "名前とカテゴリーがあればバリデーションを通過すること" do
@shop_form.valid?
expect(@shop_form).to be_valid
end
# 以下略
end
end
ファイルの置き場所と、RSpec.describe ShopEntryForm ...
の部分, テスト用のインスタンス生成時の記述に注意すれば良いだけでした^^
これは、少し古いのですがこちらの記事を参考に作成しました。
感想・参考資料など
さて、、、、本当に長い時間が実装にかかりました。実際のフォームはネストした子要素が3種類もあったり、形もかなり複雑だったのもあるのですが、何よりも素のRubyの書き方に慣れていなかったのが大きかったと思います。。。落ち着いたら、またRubyを復習したいです。
今回、参考にした記事や資料まとめです。
▼全体的な書き方
accepts_nested_attributes_forを使わず、複数の子レコードを保存する
▼paramsへのアクセス方法
フォームクラスを使う
『プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで』(p.215)
▼Concerningについて
Bite-sized separation of concerns
▼テストの書き方
フォームオブジェクトのテストをRSpecで書く
この後、editとupdateのフォームも残っているので、次はそちらを取り組みたいです^^