Formオブジェクトを使って、ActiveRecord経由でDBにレコードを保存する場合、エラー内容を適切にマージする必要があるよーということの備忘録。
例えば、以下のようなActiveRecordのUserクラスとFormオブジェクトであるUserRegisterFormクラスがあるとする。
class UserRegisterForm
include ::ActiveModel::Model
attr_accessor :email
def save
return false if invalid?
# snip...
user = User.new(email: email)
return false if user.invalid?
# snip...
end
end
class User < ActiveRecord::Base
# snip...
validates :email, presence: true
# snip...
end
この場合、コントローラ側ではこんな感じで呼び出して、内部のロジックは全てUserRegisterFormやUserの中に入れる感じになる。いわゆる Skinny Controller。
form = UserRegisterForm.new(register_params)
if form.save
render :complete, info: '登録が完了しました'
else
render :new
end
View側はこんな感じになる。Formクラスを使えばControllerもViewも同じように書けるので可読性あって良い、というのもメリット。
<% if @form.errors.any? %>
<ul class="register__errors">
<% @form.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
<% end %>
<%= form_with model: @form, url: users_url, local: true do |f| %>
<!-- snip -->
ここで問題なのが@form.errorsの部分で、このままだとUserRegisterForm#saveで内部的に呼び出されるUser#saveでエラーになった時のエラー内容は返さず、Formクラス内のエラーしか返してくれない。
Userのエラー内容をそのままerrorsにマージしても良いのであれば、以下のようなコードでUserクラスのerrorsをごっそりForm側に持っていくことができる。
class UserRegisterForm
# snip...
validate :validate_user
# snip...
private
def validate_user
user = User.new(email: email)
return if user.valid?
user.errors.each do |attr, error|
errors.add(attr, error)
end
end
end
が、この方法にも問題があって、Formとモデルに同じバリデーションがあった場合にはバリデーションメッセージが重複する。
class UserRegisterForm
# snip...
validates :email, presence: true
# snip...
end
例えば、上のようなバリデーションをFormに追加して、email未入力のエラーが発生するとエラー内容が重複する。
{:email=>["を入力してください", "を入力してください"]}
なのでerrorsをFormクラスにマージするときはここらへんも考慮してマージする必要がある。
とはいえ、Formに切り出す目的としては “コンテキスト特有のバリデーション、コールバックや外部サービスへの連携(例えばメール通知)をモデルから切り離してメンテナビリティ上げる” ということだと思うので、モデルにも入っているコンテキスト特有ではない共通のバリデーションがFormにも入っていること自体がおかしいので、ここらへんの重複問題は起こり得ないとは思われる。