フォームオブジェクトとは
データベースとは無関係なフォームや複数のモデルを扱うフォームを作るとき
フォームオブジェクトというものを導入すると処理をすっきり書くことができます。
Railsのドキュメントで正式に使用されている言葉ではないですが、よく用いられる手法となります。
app/forms
フォルダ配下にフォームオブジェクトを置くことが多いです。
具体例
以下のようなケースを考えることにします。
- 名前、メールアドレス、郵便番号、住所でサインアップを行う
- 名前とメールアドレスはUserモデルに保存する
- 郵便番号と住所はAddressモデルに保存する
- UserモデルとAddressモデルは1:1のアソシエーションを持つ
ソースコード
モデル
UserモデルとAddressモデルのアソシエーションとバリデーションを適当に書いています。
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
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
フォームオブジェクト
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
<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"=>"千葉県"}
}
コントローラー
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
を使った方法もあると思います。
どれを使うかは一長一短かと思いますがフォームオブジェクトを使う場合
モデルのカラム、バリデーションを全くそのまま使う場合は今回のやり方が簡潔に書けるかなと思います。