10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

form_objectで親子関係のあるフォームを作成する(テストも書いてます)

Last updated at Posted at 2020-09-28

背景

@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

/forms/shop_entry_form.rb
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

次は、コントローラーの記述です。コントローラーはこのような形になりました。

app/controllers/shops_controller.rb
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はこのようになっています。

app/views/shops/new.html.haml
= 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を使った実装と変わらないのですね^^

テスト

テストも至ってシンプルでした!

spec/forms/shop_entry_form_spec.rb
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 ...の部分, テスト用のインスタンス生成時の記述に注意すれば良いだけでした^^

これは、少し古いのですがこちらの記事を参考に作成しました。

フォームオブジェクトのテストをRSpecで書く

感想・参考資料など

さて、、、、本当に長い時間が実装にかかりました。実際のフォームはネストした子要素が3種類もあったり、形もかなり複雑だったのもあるのですが、何よりも素のRubyの書き方に慣れていなかったのが大きかったと思います。。。落ち着いたら、またRubyを復習したいです。

今回、参考にした記事や資料まとめです。

▼全体的な書き方
accepts_nested_attributes_forを使わず、複数の子レコードを保存する

▼paramsへのアクセス方法
フォームクラスを使う
『プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで』(p.215)

▼Concerningについて
Bite-sized separation of concerns

▼テストの書き方
フォームオブジェクトのテストをRSpecで書く

この後、editとupdateのフォームも残っているので、次はそちらを取り組みたいです^^

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?