2017-10-04

FormクラスでラップしたActiveRecordのエラー内容をマージする

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にも入っていること自体がおかしいので、ここらへんの重複問題は起こり得ないとは思われる。

このエントリーをはてなブックマークに追加