はじめに
Railsでは accepts_nested_attributes_for
を使って、has_many
関連する子レコードを1つのフォームで更新することができます。
イメージとしてはこんな感じです。
上の画面では以下のようなBookモデルと、それに関連するAuthorモデルを更新するフォームです。
(本と著者はmany-to-many関連だろう、というツッコミが来そうですが、説明のために単純化してるので気にしないでください)
class Book < ApplicationRecord
has_many :authors, dependent: :destroy
accepts_nested_attributes_for :authors, allow_destroy: true
validates :title, presence: true
end
class Author < ApplicationRecord
belongs_to :book
validates :name, presence: true, uniqueness: { scope: :book_id }
end
また、翻訳情報は以下のように設定されているとします。
ja:
activerecord:
attributes:
book/authors:
name: 著者名
errors:
messages:
blank: を入力してください
このフォームで著者名を空白にして更新すると、必須バリデーションに引っかかってエラーになります。
たとえばrails console上でこの動きをシミュレートすると以下のようなコードになります。
book = Book.find_by(title: 'プログラミング言語C')
author = book.authors[0]
params = {
title: 'プログラミング言語C',
authors_attributes: {
author.id => {
id: author.id,
# 著者名をあえて空白にする
name: '',
_destroy: "0"
}
}
}
book.update(params) #=> false
book.errors.full_messages #=> ["著者名 を入力してください"]
このときbook.errors
の中身は以下のようになっています。
p book.errors
#=> #<ActiveModel::Errors [#<ActiveModel::NestedError attribute=authors.name, type=blank, options={}>]>
ご覧のとおり、ActiveModel::NestedError
というエラーが入っていて、ここに accepts_nested_attributes_for
で更新されるレコードのエラー情報(attribute=authors.name, type=blank
)が格納されています。
解決したい問題
ブラウザのフォームからparams
を送信する場合、このエラーはRailsが自動的に設定してくれますが、もし何らかの理由でparams
を使わずにbook
のauthors.name
に対して開発者が外部から自前でActiveModel::NestedError
をセットしなければならない場合はどうしたらいいのでしょうか?
book = Book.find_by(title: 'プログラミング言語C')
# 何らかの方法で book に authors.name の ActiveModel::NestedError をセットする
book.errors.full_messages #=> ["著者名 を入力してください"]
ちなみに以下のようなコードではダメでした。
book = Book.find_by(title: 'プログラミング言語C')
# この書き方だとエラーになる
book.errors.add(:"authors.name", :blank)
#=> NoMethodError: undefined method `authors.name' for #<Book id: 975313824, title: "プログラミング言語C", created_at: "2022-06-02 00:57:31.275997000 +0000", updated_at: "2022-06-02 00:57:31.275997000 +0000">
解決策
以下のように errors.import
メソッドを使えば params
を使わずに自前で ActiveModel::NestedError
をセットできます。
book = Book.find_by(title: 'プログラミング言語C')
author = book.authors[0]
# authors.name の必須エラーを自前でセットする(内部的に ActiveModel::NestedError がセットされる)
book.errors.import(
ActiveModel::Error.new(author, :name, :blank),
attribute: :"authors.name"
)
book.errors.full_messages #=> ["著者名 を入力してください"]
errors.import
メソッドの実装は以下のようになっています。最後の行でNestedError
を追加しているのがわかりますね。
# https://github.com/rails/rails/blob/v7.0.3/activemodel/lib/active_model/errors.rb#L129-L136
def import(error, override_options = {})
[:attribute, :type].each do |key|
if override_options.key?(key)
override_options[key] = override_options[key].to_sym
end
end
@errors.append(NestedError.new(@base, error, override_options))
end
(参考)errors.merge! はなんか違う
同じようなメソッドにerrors.merge!
があるんですが、これを使うと authors.name
ではなく book.name
に対するエラーになってしまうので、微妙に動きが異なります。
book = books(:programming_c)
author = book.authors[0]
author.errors.add(:name, :blank)
book.errors.merge!(author)
# book.name の翻訳を取ってこようとして Name になってしまっている
book.errors.full_messages #=> ["Name を入力してください"]
まとめ
単純なバリデーションであればRailsの仕組みをそのまま使えばOKですが、ちょっと変わった要件でモデルの外部から ActiveModel::NestedError
をセットしたいという場合はこの方法が使えるかもしれません。
もし他に何かもっといい方法をご存じの方がいたら、コメント欄等で教えてください!