accepts_nested_attributes_for で更新するモデルに対するバリデーションエラー（ActiveModel::NestedError）を外部からセットする方法

## はじめに

Railsでは `accepts_nested_attributes_for` を使って、`has_many`関連する子レコードを1つのフォームで更新することができます。
イメージとしてはこんな感じです。

![sample.002.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/fbd1d0e0-3c36-dcaf-715c-a787fbce39b4.jpeg)

上の画面では以下のようなBookモデルと、それに関連するAuthorモデルを更新するフォームです。
（本と著者はmany-to-many関連だろう、というツッコミが来そうですが、説明のために単純化してるので気にしないでください）

```ruby 
class Book < ApplicationRecord
  has_many :authors, dependent: :destroy
  accepts_nested_attributes_for :authors, allow_destroy: true

  validates :title, presence: true
end
```

```ruby 
class Author < ApplicationRecord
  belongs_to :book

  validates :name, presence: true, uniqueness: { scope: :book_id }
end
```

また、翻訳情報は以下のように設定されているとします。

```config/locales/ja.yml 
ja:
  activerecord:
    attributes:
      book/authors:
        name: 著者名
    errors:
      messages:
        blank: を入力してください
```

このフォームで著者名を空白にして更新すると、必須バリデーションに引っかかってエラーになります。

たとえばrails console上でこの動きをシミュレートすると以下のようなコードになります。

```ruby
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`の中身は以下のようになっています。

```ruby 
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`をセットしなければならない場合はどうしたらいいのでしょうか？

```ruby 
book = Book.find_by(title: 'プログラミング言語C')

# 何らかの方法で book に authors.name の ActiveModel::NestedError をセットする

book.errors.full_messages #=> ["著者名 を入力してください"]
```

ちなみに以下のようなコードではダメでした。

```ruby
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` をセットできます。

```ruby 
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`を追加しているのがわかりますね。

```ruby
# 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` に対するエラーになってしまうので、微妙に動きが異なります。 

```ruby 
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 を入力してください"]
```

https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-merge-21

## まとめ

単純なバリデーションであればRailsの仕組みをそのまま使えばOKですが、ちょっと変わった要件でモデルの外部から `ActiveModel::NestedError` をセットしたいという場合はこの方法が使えるかもしれません。

もし他に何かもっといい方法をご存じの方がいたら、コメント欄等で教えてください！