7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

accepts_nested_attributes_for を使ったフォームで意図しないユニークバリデーションの発生を回避する方法

Last updated at Posted at 2022-05-14

解決したい問題

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

sample.002.jpeg

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

class Book < ApplicationRecord
  has_many :authors, dependent: :destroy
  accepts_nested_attributes_for :authors, reject_if: :all_blank, allow_destroy: true

  validates :title, presence: true
end
class Author < ApplicationRecord
  belongs_to :book

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

Authorモデルのバリデーションを見ればわかるとおり、ここでは「ある本に紐付く著者名は重複してはいけない」というバリデーションが付いています。DB側でもこのルールを担保できるように、authorsテーブルにはユニーク制約が付いています。

db/schema.rb
create_table "authors", force: :cascade do |t|
  t.integer "book_id", null: false
  t.string "name"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["book_id", "name"], name: "index_authors_on_book_id_and_name", unique: true
  t.index ["book_id"], name: "index_authors_on_book_id"
end

このとき、すでに保存済みのBookとAuthorに対して、著者の「かーにはん」と「りっちー」の順番を入れ替えたいと思って次のようにフォームを入力したとします。

BEFORE
sample.002.jpeg

AFTER
sample.003.jpeg

しかし、この状態で「更新」ボタンをクリックすると、Railsのユニークバリデーションに引っかかってデータを保存できません。これは次のような動きになってしまうからです。

  1. 「かーにはん」を「りっちー」に変更したい。でも、その前にバリデーションの実行が必要
  2. 「プログラミング言語C」の中にすでに「りっちー」が存在しないか確認するため、DBを検索する
  3. 「りっちー」はすでに存在する(2行目のレコードが該当)
  4. 「りっちー」はすでに存在するため、「かーにはん」を「りっちー」に変更するのはNG

2行目の「りっちー」を「かーにはん」に変更する場合でも同じ理屈でユニークバリデーションに引っかかります。

とはいえ、フォームの内容を見れば著者名が重複しないのは見て明らかです。開発者からすれば、この状態をバリデーションエラーと見なすRailsの方がおかしい、と言いたくなりますね。
どうすればバリデーションエラーを発生させずにデータを更新することができるでしょうか?

解決策

今回は以下のようにBookモデルにupdate_with_avoiding_uniqueness_errorというメソッドを追加してこの問題を回避することにします。

class Book < ApplicationRecord
  has_many :authors, dependent: :destroy
  accepts_nested_attributes_for :authors, reject_if: :all_blank, allow_destroy: true

  validates :title, presence: true

  def update_with_avoiding_uniqueness_error(book_params)
    success = false
    transaction do
      # HACK: 著者名をスワップするような更新が走ると、どうしてもユニークバリデーションに引っかかってしまう
      # そこで事前に著者名をデタラメに更新しておき、ユニークバリデーションエラーの発生を回避する、というハック
      authors.each do |author|
        author.update_columns(name: SecureRandom.uuid)
      end
      success = update(book_params)

      raise ActiveRecord::Rollback unless success
    end
    success
  end
end

ポイントは

author.update_columns(name: SecureRandom.uuid)

の部分です。
通常のupdateを実行する前に、対象の本に関連する著者名をすべてランダムな名前に更新しています。
こうすることで、既存の著者名に「りっちー(または、かーにはん)」がないか?と検索しに行っても見つからないため、バリデーションエラーが発生しなくなります。

もし、それ以外のバリデーションエラーが存在していた場合(たとえば本のタイトルが未入力だった場合など)は、

success = update(book_params)

success の値が false になり、

raise ActiveRecord::Rollback unless success

ですべての更新がロールバックされるので、デタラメに更新していた著者名の変更も元に戻ります。

コントローラー側では通常のupdateメソッドではなく、上で定義したメソッドを使うように書き替えます。

 class BooksController < ApplicationController
   # ...
 
 
   def update
-    if @book.update(book_params)
+    if @book.update_with_avoiding_uniqueness_error(book_params)
       redirect_to @book, notice: 'Book was successfully updated.'
     else
       render :edit
     end
   end
 
   # ...
 end

本当に重複していた場合にも対処する

ただし、上のコードだと次のように本当に著者名が重複していたときに ActiveRecord::RecordNotUnique 例外が発生します。

sample.004.jpeg

このエラーはRailsのバリデーションでは検知されず、データベース上でUPDATE文が実行されたタイミングでDBのユニーク制約違反が発生してエラーになります。

ユニーク制約違反は前述の通り ActiveRecord::RecordNotUnique という例外が発生するので、先ほどのメソッドに例外処理も追加しておきます。

 def update_with_avoiding_uniqueness_error(book_params)
   success = false
   transaction do
     # HACK: 著者名をスワップするような更新が走ると、どうしてもユニークバリデーションに引っかかってしまう
     # そこで事前に著者名をデタラメに更新しておき、ユニークバリデーションエラーの発生を回避する、というハック
     authors.each do |author|
       author.update_columns(name: SecureRandom.uuid)
     end
     success = update(book_params)

     raise ActiveRecord::Rollback unless success
   end
   success
+rescue ActiveRecord::RecordNotUnique
+  # NOTE: ここでは著者名の重複と決めうちしているが、他にもユニーク制約があるようならもう少し厳密な判定が必要
+  errors.add(:base, '著者名が重複しています。')
+  false
 end

本来であればBookモデルの:baseではなく、実際に重複しているAuthorモデルのerrorsにエラーメッセージを追加すべきですが、コードが複雑になるのでここでは雑にエラーメッセージを追加しています。
また、コードコメントにもあるとおり、本の著者名以外にもユニーク制約違反が発生しそうなカラムがあれば、例外メッセージをパースして、どのモデルのどのカラムで制約違反が発生したのかを解析するようなコードも必要になるかもしれません。

ですが、ここではコードの簡潔さを重視して「 ActiveRecord::RecordNotUnique 例外が発生したら、十中八九著者名の重複だろう」と決めつけて、雑な例外処理を追加しています。

FAQ 「そもそも accepts_nested_attributes_for って非推奨じゃないんですか?」

「そもそもaccepts_nested_attributes_forは非推奨だから使わない方がいいんじゃないか?」という見方をする人もいるかもしれません。

accepts_nested_attributes_forが非推奨」というのは以下のDHH氏の発言が震源地になっています。

I'd actually like to kill accepts_nested_attributes_for in due time.

(筆者訳)
accepts_nested_attributes_forはそのうち無くしたいんだよね〜。

https://github.com/rails/rails/pull/26976#discussion_r87855694

ただ、結局これはDHH氏のお気持ち表明止まりであり、Railsチームから公式に「非推奨だよ、もうすぐ無くなるよ」という発表がなされたわけではありません。
このDHH氏の発言は2016年のものですが、5年以上経っても(僕が知る限りでは)特に動きは見られないため、このまま何も起きないか、起きるとしてももうしばらく先になるだろうと考えています。

もちろん、将来非推奨となるリスクがあるから使用を避ける、という考え方もありだと思います。とはいえ、僕個人としては「もしかしたらいつか非推奨になるかも」というリスクを頭の片隅に置いた上で、accepts_nested_attributes_forを使うのは別に構わないんじゃないか、と考えています。

まとめ

というわけで、この記事では accepts_nested_attributes_for を使ったフォームで意図しないユニークバリデーションの発生を回避する方法を紹介しました。

この記事で使ったコードは以下のリポジトリに置いています。(ただし、テストコードはありますが、画面はありません)

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

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?