これを知らなくて小一時間ハマったので、メモ
環境
- 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 : UserProfile
が has_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#attributes
で idなし
の 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
のソースを見てみる- https://github.com/rails/rails/blob/5-2-1/activerecord/lib/active_record/nested_attributes.rb#L333-L354
-
generate_association_writer
でメソッドを定義してるっぽい - 定義されたメソッドの中で、今回のケースは
has_one
なのでassign_nested_attributes_for_one_to_one_association
が呼ばれる - この中で、今回のケースでは、
build_user_profile
が呼ばれていた
-
has_one
な関連のbuildメソッドはなにをしているのか?-
ActiveRecord::Associations::SingularAssociation#build
が呼ばれているはず - ここで、
set_new_record
している。(あやしい) - SingularAssociation#replaceは実装されていないので、HasOneの方をみる
-
remove_target!(options[:dependent])
してる -
今回のケースでは
dependent: :destoy
をUser#user_profileに設定して居たため、ここでdestroyされていた
-
すっきりした
対応策
さきに書いてしまったけど、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すれば元に戻る