LoginSignup
34
21

More than 5 years have passed since last update.

has_oneな関連をaccepts_nested_attributes_forしている場合にattributesで代入した場合idが無いとdestroyされる

Posted at

これを知らなくて小一時間ハマったので、メモ

環境

  • Rails 5.2.1

結論から言うと

  • has_oneなfieldをaccepts_nested_attributes_forする場合は、 update_only: true にしておいた方が安全かもしれない

ActiveRecord::NestedAttributes::ClassMethods accepts_nested_attributes_for(*attr_names)

現象

以下のようなテーブルとモデルがあったとする。

class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :name

      t.timestamps
    end
  end
end

class CreateUserProfiles < ActiveRecord::Migration[5.2]
  def change
    create_table :user_profiles do |t|
      t.string :avatar
      t.belongs_to :user

      t.timestamps
    end
  end
end
class User < ApplicationRecord
  has_one :user_profile, dependent: :destroy
  accepts_nested_attributes_for :user_profile
end

class UserProfile < ApplicationRecord
  belongs_to :user
end

User : UserProfilehas_one つまり 1 : 1 な関係で存在している。

とりあえず、Userのデータを作成する

[13] pry(main)> User.create(name: "test", user_profile_attributes: { avatar: "http://hoge.com" })
   (0.1ms)  begin transaction
  User Create (9.7ms)  INSERT INTO "users" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "test"], ["created_at", "2018-09-19 02:31:31.405308"], ["updated_at", "2018-09-19 02:31:31.405308"]]
  UserProfile Create (1.6ms)  INSERT INTO "user_profiles" ("avatar", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["avatar", "http://hoge.com"], ["user_id", 3], ["created_at", "2018-09-19 02:31:31.416808"], ["updated_at", "2018-09-19 02:31:31.416808"]]
   (8.3ms)  commit transaction
=> #<User:0x0000560a59d8a630 id: 3, name: "test", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00>

[14] pry(main)> u = User.last
  User Load (2.7ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User:0x0000560a598f3c70 id: 3, name: "test", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00>

[15] pry(main)> u.user_profile
  UserProfile Load (2.5ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a59843500 id: 2, avatar: "http://hoge.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00>

User#attributesidなしuser_profile_attributes をセットする

[16] pry(main)> u.attributes = { user_profile_attributes: { avatar: "http://xxxxxxxx.com" } }
   (0.1ms)  begin transaction
  UserProfile Destroy (9.2ms)  DELETE FROM "user_profiles" WHERE "user_profiles"."id" = ?  [["id", 2]]
   (5.7ms)  commit transaction
=> {:user_profile_attributes=>{:avatar=>"http://xxxxxxxx.com"}}

attributesに代入しただけで、saveしてないのに、UserProfileはdestroyされている

saveしてないので、あたらしいUserProfileのidはnilになっている

[17] pry(main)> u.user_profile
=> #<UserProfile:0x0000560a597113a8 id: nil, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: nil, updated_at: nil>

当然、reloadすると、user_profileはnilになる

[18] pry(main)> u.reload.user_profile
  User Load (2.5ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  UserProfile Load (0.6ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> nil

[21] pry(main)> UserProfile.count
   (2.5ms)  SELECT COUNT(*) FROM "user_profiles"
=> 0

不用意にrails consoleでattributesの代入をして、データを失うという悲劇。

どういう時に困るのか?

User側にvalidationエラーがあったりして、saveできない時。

通常、createやupdateメソッドを使っていれば、困らないかもしれない。

class User < ApplicationRecord
  has_one :user_profile, dependent: :destroy
  accepts_nested_attributes_for :user_profile

  validates :name, length: { minimum: 10 }
end

User#name に10文字以上のvalidationを追加した。

attributesでセットした場合

[29] pry(main)> u = User.last
  User Load (0.8ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User:0x0000560a5ab7b2a8 id: 3, name: "test", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00>

[30] pry(main)> u.user_profile
  UserProfile Load (1.1ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x00007f698c867638 id: 4, avatar: "http://xxxxxxxx.co", user_id: 3, created_at: Wed, 19 Sep 2018 02:41:30 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:41:30 UTC +00:00>

[31] pry(main)> u.attributes = { name: "hoge" , user_profile_attributes: { avatar: "http://hoge.com" } }
   (0.1ms)  begin transaction
  UserProfile Destroy (7.7ms)  DELETE FROM "user_profiles" WHERE "user_profiles"."id" = ?  [["id", 4]]
   (6.7ms)  commit transaction
=> {:name=>"hoge", :user_profile_attributes=>{:avatar=>"http://hoge.com"}}

[32] pry(main)> u.save
   (0.1ms)  begin transaction
   (0.0ms)  rollback transaction
=> false

[34] pry(main)> u.errors
=> #<ActiveModel::Errors:0x00007f698c7e5c50
 @base=#<User:0x0000560a5ab7b2a8 id: 3, name: "hoge", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00>,
 @details={:name=>[{:error=>:too_short, :count=>10}]},
 @messages={:name=>["is too short (minimum is 10 characters)"]}>

[35] pry(main)> u.reload.user_profile
  User Load (3.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  UserProfile Load (0.6ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> nil

こうなるので、Userがsave出来ない状態で、UserProfileが削除されてしまう。

if user.save
  
else
  render :edit
end

のように、例外が発生しない状態だとrollbackも出来ず、データが消えてしまう。

updateで更新する場合

[40] pry(main)> u = User.last
  User Load (2.3ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User:0x00007f698c012348 id: 3, name: "1234567890", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>

[41] pry(main)> u.user_profile
  UserProfile Load (2.5ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a5aa0b670 id: 5, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>

[42] pry(main)> u.update name: "1234", user_profile_attributes: { avatar: "http://xxxxxxxx.com" }
   (0.1ms)  begin transaction
  UserProfile Destroy (7.7ms)  DELETE FROM "user_profiles" WHERE "user_profiles"."id" = ?  [["id", 5]]
   (3.2ms)  rollback transaction
=> false

[43] pry(main)> u.user_profile
=> #<UserProfile:0x0000560a5a9ace68 id: nil, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: nil, updated_at: nil>

[44] pry(main)> u.reload.user_profile
  User Load (1.9ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  UserProfile Load (0.5ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a5a733510 id: 5, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>

updateメソッド実行時に、transactionがrollbackされているのがわかる。
u.user_profileには新しいレコードのデータがセットされているが、
destroyがロールバックされているので、reloadすると、元に戻る

ただし、ここでこんなことをすると、ゴミデータが出来てしまう。

[45] pry(main)> u.update name: "1234", user_profile_attributes: { avatar: "http://x.com" }
   (0.1ms)  begin transaction
  UserProfile Destroy (8.2ms)  DELETE FROM "user_profiles" WHERE "user_profiles"."id" = ?  [["id", 5]]
   (3.7ms)  rollback transaction
=> false

[46] pry(main)> u.user_profile
=> #<UserProfile:0x0000560a5a669bc0 id: nil, avatar: "http://x.com", user_id: 3, created_at: nil, updated_at: nil>

[47] pry(main)> u.user_profile.save
   (0.1ms)  begin transaction
  UserProfile Create (10.5ms)  INSERT INTO "user_profiles" ("avatar", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["avatar", "http://x.com"], ["user_id", 3], ["created_at", "2018-09-19 02:58:50.666477"], ["updated_at", "2018-09-19 02:58:50.666477"]]
   (7.3ms)  commit transaction
=> true

[48] pry(main)> u.reload.user_profile
  User Load (2.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  UserProfile Load (0.6ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a5a394d00 id: 5, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>

[49] pry(main)> UserProfile.all
  UserProfile Load (2.4ms)  SELECT "user_profiles".* FROM "user_profiles"
=> [#<UserProfile:0x0000560a59feac68 id: 5, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>,
 #<UserProfile:0x0000560a59feab00 id: 6, avatar: "http://x.com", user_id: 3, created_at: Wed, 19 Sep 2018 02:58:50 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:58:50 UTC +00:00>]

ただ、これはUnique制約をちゃんと入れておけば回避できるかと。

原因調査してみる

すっきりした

対応策

さきに書いてしまったけど、accepts_nested_attributes_for のオプションに update_only: true を入れることで、既存のレコードを更新する事ができる

class User < ApplicationRecord
  has_one :user_profile, dependent: :destroy
  accepts_nested_attributes_for :user_profile, update_only: true

  validates :name, length: { minimum: 10 }
end
[53] pry(main)> u = User.last
  User Load (1.1ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User:0x0000560a5950e740 id: 3, name: "1234567890", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>

[54] pry(main)> u.user_profile
  UserProfile Load (0.7ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a5942b1c0 id: 7, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00, updated_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00>

[55] pry(main)> UserProfile.all
  UserProfile Load (2.9ms)  SELECT "user_profiles".* FROM "user_profiles"
=> [#<UserProfile:0x0000560a593b1ac8 id: 7, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00, updated_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00>]

[56] pry(main)> u.attributes =  {name: "1234", user_profile_attributes: { avatar: "http://x.com" }}
=> {:name=>"1234", :user_profile_attributes=>{:avatar=>"http://x.com"}}

[57] pry(main)> u.user_profile
=> #<UserProfile:0x0000560a5942b1c0 id: 7, avatar: "http://x.com", user_id: 3, created_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00, updated_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00>

[59] pry(main)> u.user_profile.changed?
=> true

[60] pry(main)> u.save
   (0.1ms)  begin transaction
   (0.1ms)  rollback transaction
=> false

[61] pry(main)> u.errors
=> #<ActiveModel::Errors:0x00007f698c8a6bd0
 @base=#<User:0x0000560a5950e740 id: 3, name: "1234", created_at: Wed, 19 Sep 2018 02:31:31 UTC +00:00, updated_at: Wed, 19 Sep 2018 02:53:35 UTC +00:00>,
 @details={:name=>[{:error=>:too_short, :count=>10}]},
 @messages={:name=>["is too short (minimum is 10 characters)"]}>

[62] pry(main)> u.user_profile
=> #<UserProfile:0x0000560a5942b1c0 id: 7, avatar: "http://x.com", user_id: 3, created_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00, updated_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00>

[63] pry(main)> u.reload.user_profile
  User Load (1.8ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  UserProfile Load (0.6ms)  SELECT  "user_profiles".* FROM "user_profiles" WHERE "user_profiles"."user_id" = ? LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
=> #<UserProfile:0x0000560a5abe6be8 id: 7, avatar: "http://xxxxxxxx.com", user_id: 3, created_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00, updated_at: Wed, 19 Sep 2018 03:34:16 UTC +00:00>
  • attributesに代入したタイミングで、destroyされなくなった
  • 既存のUserProfileが更新されている
    • changed?がtrue
  • validationエラーでsaveされない
    • reloadすれば元に戻る
34
21
1

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
34
21