解決したい問題
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, 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テーブルにはユニーク制約が付いています。
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に対して、著者の「かーにはん」と「りっちー」の順番を入れ替えたいと思って次のようにフォームを入力したとします。
しかし、この状態で「更新」ボタンをクリックすると、Railsのユニークバリデーションに引っかかってデータを保存できません。これは次のような動きになってしまうからです。
- 「かーにはん」を「りっちー」に変更したい。でも、その前にバリデーションの実行が必要
- 「プログラミング言語C」の中にすでに「りっちー」が存在しないか確認するため、DBを検索する
- 「りっちー」はすでに存在する(2行目のレコードが該当)
- 「りっちー」はすでに存在するため、「かーにはん」を「りっちー」に変更するのは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
例外が発生します。
このエラーは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
を使ったフォームで意図しないユニークバリデーションの発生を回避する方法を紹介しました。
この記事で使ったコードは以下のリポジトリに置いています。(ただし、テストコードはありますが、画面はありません)
他に何かもっといい方法をご存じの方がいたら、コメント欄等で教えてください!