Railsのbuild_associationの挙動について


登録したデータが削除される?!

build_associationで新規登録しようとすると、 既存のデータが削除されてしまい、混乱したのでメモとして残します。

実行環境

ruby 2.6.5
Rails 6.0.2.2

かんたんなケースで動作確認してみる

UserとUserProfileというモデルを作ります。
1対1の関係であるとき、以下のようにmodelで設定されるかと思います。
なおUserProfileテーブル自体にuniqueインデックスが追加されているとします。

class User < ApplicationRecord
  has_one :user_profile
end
class UserProfile < ApplicationRecord
  belongs_to :user
end

UserProfileの登録処理を実装します。
createアクションの中で、
・既存のUserに紐づくUserProfileが存在する場合は、例外を発生させる。存在しない場合は登録成功とする。
のような実装をしたいとします。
最初に思いついたのは以下の実装です。build_associationメソッドを使用しています。 Railsガイド belongs-toで追加されるメソッド

def create
  user = find_user!
  user.build_user_profile(hoge: params[:hoge]).save!

  render_some_page
end

しかし、上記のコードでは、例外は発生しません。
既存のデータは削除され、新規で登録されてしまいまいます。

# 既存のuserに紐づくuser_profileあり
[1] pry(main)> user.user_profile
=> #<UserProfile:0x0000560151435258 id: 4, user_id: 1, hoge: 2, created_at: Sat, 02 Dec 2023 15:50:13 JST +09:00, updated_at: Sat, 02 Dec 2023 15:50:13 JST +09:00>

# id=4がdestroyされています...!
[2] pry(main)> user.build_user_profile(hoge: 2).save!
   (0.8ms)  BEGIN
  UserProfile Destroy (1.0ms)  DELETE FROM `user_profiles` WHERE `user_profiles`.`id` = 4
   (71.2ms)  COMMIT
   (0.4ms)  BEGIN
  UserProfile Create (0.9ms)  INSERT INTO `user_profiles` (`user_id`, `hoge`, `created_at`, `updated_at`) VALUES (1, 2, '2023-12-02 07:19:07', '2023-12-02 07:19:07')
   (44.2ms)  COMMIT
=> true

# id=5で新しくレコードが作成されてしまいます。
[3] pry(main)> user.user_profile
=> #<UserProfile:0x000056015145fc88 id: 5, user_id: 1, hoge: 2, created_at: Sat, 02 Dec 2023 16:19:07 JST +09:00, updated_at: Sat, 02 Dec 2023 16:19:07 JST +09:00>

意図して削除して新規作成したい場合は、上記で良いと思いますが、知らずに使うと期待しない結果になってしまうので、注意が必要だと思いました。

さて、今回のように、
・既存のUserに紐づくUserProfileが存在する場合は、例外を発生させる。存在しない場合は登録成功とする。
を実装したい場合はどうしたら良いのか。

パターン1

newしてsaveをする

[4] pry(main)> user.user_profile
=> #<UserProfile:0x000056015145fc88 id: 5, user_id: 1, hoge: 2, created_at: Sat, 02 Dec 2023 16:19:07 JST +09:00, updated_at: Sat, 02 Dec 2023 16:19:07 JST +09:00>
[5] pry(main)> user_profile =  UserProfile.new(user_id: 1, hoge: 2)
=> #<UserProfile:0x00005601512faf78 id: nil, user_id: 1, hoge: 2, created_at: nil, updated_at: nil>

# 例外が発生する
[6] pry(main)> user_profile.save!
   (0.9ms)  BEGIN
  User Load (1.6ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  UserProfile Create (2.1ms)  INSERT INTO `user_profiles` (`user_id`, `hoge`, `created_at`, `updated_at`) VALUES (1, 2, '2023-12-02 07:28:56', '2023-12-02 07:28:56')
   (5.6ms)  ROLLBACK
ActiveRecord::RecordNotUnique: Mysql2::Error: Duplicate entry '1' for key 'user_profiles.index_user_profiles_on_user_id'
from /app/.bundle/ruby/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'
Caused by Mysql2::Error: Duplicate entry '1' for key 'user_profiles.index_user_profiles_on_user_id'
from /app/.bundle/ruby/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'

パターン2

create_user_profile(create_user_profile!)を使う

[7] pry(main)> user.user_profile
=> #<UserProfile:0x000056015145fc88 id: 5, user_id: 1, hoge: 2, created_at: Sat, 02 Dec 2023 16:19:07 JST +09:00, updated_at: Sat, 02 Dec 2023 16:19:07 JST +09:00>

# 例外が発生する
[8] pry(main)> user.create_user_profile!(hoge: 2)
   (0.8ms)  BEGIN
  UserProfile Create (2.2ms)  INSERT INTO `user_profiles` (`user_id`, `hoge`, `created_at`, `updated_at`) VALUES (1, 2, '2023-12-02 07:38:18', '2023-12-02 07:38:18')
   (5.6ms)  ROLLBACK
ActiveRecord::RecordNotUnique: Mysql2::Error: Duplicate entry '1' for key 'user_profiles.index_user_profiles_on_user_id'
from /app/.bundle/ruby/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'
Caused by Mysql2::Error: Duplicate entry '1' for key 'user_profiles.index_user_profiles_on_user_id'
from /app/.bundle/ruby/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'

※ なおRails7以上だとcreate_associateの挙動が変わっているので注意(検証せずに削除→新規作成される)

github.com