4
0

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 1 year has passed since last update.

【Rails】複数のモデルをフォームオブジェクトを使って保存する

Last updated at Posted at 2023-02-26

フォームオブジェクトとは

データベースとは無関係なフォームや複数のモデルを扱うフォームを作るとき
フォームオブジェクトというものを導入すると処理をすっきり書くことができます。
Railsのドキュメントで正式に使用されている言葉ではないですが、よく用いられる手法となります。
app/formsフォルダ配下にフォームオブジェクトを置くことが多いです。

具体例

以下のようなケースを考えることにします。

  • 名前、メールアドレス、郵便番号、住所でサインアップを行う
  • 名前とメールアドレスはUserモデルに保存する
  • 郵便番号と住所はAddressモデルに保存する
  • UserモデルとAddressモデルは1:1のアソシエーションを持つ

erd.png

ソースコード

モデル

UserモデルとAddressモデルのアソシエーションとバリデーションを適当に書いています。

app/models/user.rb
class User < ApplicationRecord
  has_one :address, dependent: :destroy

  validates :name, presence: true, length: { maximum: 14 }
  validates :email, presence: true, uniqueness: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
end
app/models/address.rb
class Address < ApplicationRecord
  belongs_to :user

  POSTAL_CODE_REGEXP = /\A\d{3}-?\d{4}\z/

  validates :address, presence: true, length: { maximum: 50 }
  validates :postal_code, presence: true
  validates :postal_code, format: { with: POSTAL_CODE_REGEXP }, allow_blank: true
end

フォームオブジェクト

app/forms/registration_form
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attr_accessor :user_attributes, :address_attributes

  validate :user_validate
  validate :address_validate

  def save
    return false if invalid?

    ActiveRecord::Base.transaction do
      save!
    end
    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  def save!
    user.save!
  end

  def user
    @user ||= User.new(user_attributes)
  end

  def address
    @address ||= user.build_address(address_attributes)
  end

  private

  def user_validate
    return unless user.invalid?

    user.errors.full_messages.each do |message|
      errors.add(:base, message)
    end
  end

  def address_validate
    return unless address.invalid?

    address.errors.full_messages.each do |message|
      errors.add(:base, message)
    end
  end
end

フォームオブジェクトにname, email, postal_code, addressそれぞれのバリデーションを書いてもいいのですが
モデルに書いたバリデーションと全く同じことを書くのも嫌なので今回はuser_validate, address_validateでUserモデル, Addressモデルのバリデーションでチェックを行いエラーメッセージはerrors.add(:base, message)でそのまま代入しています。

user.save!で関連づけられているaddressも同時に保存されます。

※ エラーメッセージを代入する処理は

user.errors.full_messages.each do |message|
    errors.add(:base, message)
end

の代わりに

errors.merge!(user.errors)

でも代入できますが、マージする方法だとフォームオブジェクトのエラーメッセージの日本語化ができていないので
下記のようなlocaleファイルを追加してあげてください。

ja:
  activemodel:
    attributes:
      registration_form:
        name: 名前
        email: メールアドレス
        postal_code: 郵便番号
        address: 住所

views

app/views/registrations/new/html.erb
<div class="container">
  <h1>新規登録</h1>
  <%= form_with model: @registration_form, url: registrations_path do |f| %>
    <%= render 'shared/error_messages', object: f.object %>

    <%= fields_for f.object.user do |ff| %>
      <div>
        <%= ff.label :name, class: 'form-label' %>
        <%= ff.text_field :name, class: "form-control" %>
      </div>

      <div>
        <%= ff.label :email, class: 'form-label' %>
        <%= ff.text_field :email, class: "form-control" %>
      </div>
    <% end %>

    <%= fields_for f.object.address do |ff| %>
      <div>
        <%= ff.label :postal_code, class: 'form-label' %>
        <%= ff.text_field :postal_code, class: "form-control" %>
      </div>

      <div>
        <%= ff.label :address, class: 'form-label' %>
        <%= ff.text_field :address, class: "form-control" %>
      </div>
    <% end %>

    <%= f.submit '新規登録', class: "btn btn-primary" %>
  <% end %>
</div>

fields_forでモデル毎に分割してあげるることでパラメーターは下記のような形になります

{
    "user"=>{"name"=>"yamada", "email"=>"test@example.com"}, 
    "address"=>{"postal_code"=>"100-0000", "address"=>"千葉県"}
}

コントローラー

app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def new
    @registration_form = RegistrationForm.new
  end

  def create
    @registration_form = RegistrationForm.new(user_attributes: user_params,
                                         address_attributes: address_params)
    if @registration_form.save
      redirect_to root_path
    else
      render 'new', status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email)
  end

  def address_params
    params.require(:address).permit(:postal_code, :address)
  end
end

paramsの内Userモデルに関するパラメーターはuser_attributesとして渡し、Addressモデルに関するパラメーターはaddress_attributesとしてフォームオブジェクトに渡してあげます。
attributesはフォームオブジェクト内でそれぞれのモデルのnewするときのパラメーターとして使用します。

まとめ

フォームオブジェクトを使った実装方法について解説しました。
今回はバリデーション処理は既にあるモデルのバリデーションに任せることにしましたが、

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :postal_code, :string
  attribute :address, :string

  POSTAL_CODE_REGEXP = /\A\d{3}-?\d{4}\z/

  validates :name, presence: true, length: { maximum: 14 }
  validates :email, presence: true, uniqueness: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
  validates :address, presence: true, length: { maximum: 50 }
  validates :postal_code, presence: true
  validates :postal_code, format: { with: POSTAL_CODE_REGEXP }, allow_blank: true

# 以下略

みたいにフォームの項目それぞれ書くこともできると思います。
この場合viewsファイルでfields_forを使う必要もなくなると思います。
(↓こんな感じのパラメーターを渡すイメージです。)

{
    "registration_form"=>{
        "name"=>"yamada",
        "email"=>"test@example.com",
        "postal_code"=>"100-0000", 
        "address"=>"千葉県"
    }
}

また、accepts_nested_attributes_forを使った方法もあると思います。

どれを使うかは一長一短かと思いますがフォームオブジェクトを使う場合
モデルのカラム、バリデーションを全くそのまま使う場合は今回のやり方が簡潔に書けるかなと思います。

4
0
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
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?