🕸️

[Rails] has_one に対する `#assign_attributes` & `#save` の罠

2024/06/25に公開

ドーモ、株式会社ソーシャルPLUS CTO の サトウリョウスケ (@ryosuke_sato) です ✌︎('ω')✌︎

前回に引き続き、今回も簡単な備忘録です ✍️
別の大きなネタもあるんですが、時間が取れなかったのでまたの機会に。

https://zenn.dev/socialplus/articles/81b8e34fc99603


以下のような User モデルに対してメモを記録するモデルとの関連があるとします。
has_oneaccepts_nested_attributes_for を使っている点がポイントです。

class User < ApplicationRecord
  has_one :memo
  accepts_nested_attributes_for :memo
end

class Memo < ApplicationRecord
  belongs_to :user
end

このモデルを使って更新処理を書いてみます。

class SomeController < ApplicationController
  def update
    user = User.find(params[:id])
    user.assign_atteibutess(user_params)
    if user.save
      render status: :ok
    else
      # render an error response
    end
  end

  private

  def user_params
    params.require(:user).permit!
    # => {
    #   memo_attributes: {
    #     content: 'modified'
    #   }
    # }
  end
end

一見うまく動きそうなんですが、上記のコードだとバリデーションエラーの際に memo レコードが削除されてしまいます。

TRANSACTION (0.3ms)  SAVEPOINT active_record_1
Memo Destroy (0.4ms)  DELETE FROM `memos` WHERE `memos`.`id` = 1
TRANSACTION (0.2ms)  RELEASE SAVEPOINT active_record_1
TRANSACTION (0.2ms)  SAVEPOINT active_record_1
TRANSACTION (0.2ms)  ROLLBACK TO SAVEPOINT active_record_1

これは #assign_attributes を実行した際に Memo#destroy が実行され、新しい Memo インスタンスに置き換えられてしまうため。なお、 #save に成功すれば新しい Memo インスタンスの保存に成功するので、正常系では問題ないように見えます。

これを解決するには、 #assign_attributes を使わず #update を使えば OK です。

class SomeController < ApplicationController
  def update
    user = User.find(params[:id])
    if user.update(user_params) # ← ここを変更
      render status: :ok
    else
      # render an error response
    end
  end
end

発行される SQL にも変化があることがわかります。

TRANSACTION (0.2ms)  SAVEPOINT active_record_1
Memo Destroy (0.3ms)  DELETE FROM `memos` WHERE `memos`.`id` = 1
TRANSACTION (0.8ms)  ROLLBACK TO SAVEPOINT active_record_1

複雑な代入が必要な際に #assign_attributes を使いたいシーンがありますが、 has_one を使っている場合はご注意ください。

SocialPLUS Tech Blog

Discussion