railsでbuild_associationをするときは関連の種類によって挙動が違うので気をつけよう
先日業務でrailsの関連を定義したときに使えるメソッドのbuild_associationを使ったらちょっと不思議な挙動になったので、改めて関連とオプションによってどんな挙動をするのか調べてみました。
環境
rails -v
Rails 7.1.3.2
TL;DR
- has_oneで関連先が永続化された状態で関連先を再度ビルドすると
ActiveRecord::RecordNotSaved
の例外が起こる - has_oneでdependent:destroyを定義して関連先が永続化された状態で関連先を再度ビルドすると、deleteのクエリが発行される
build_associationを試してみる
ここからはコードとコンソールから実行した例を長々と書いて、挙動を確かめていきます。
has_manyの場合
まずはhas_manyのときの挙動を確認します。適当に以下のようなモデルを定義した状態で試してみます。
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
end
# authorと紐づくbooksをビルドして永続化します
irb(main):001> author = Author.new(name: 'hoge')
=> #<Author:0x0000000101b29088 id: nil, name: "hoge", age: nil, created_at: nil, updated_at: nil>
irb(main):002* books = author.books.build([{title: 'hoge'}, {title: 'fuga'}])
=> [#<Book:0x0000000106d1ed20 id: nil, title: "hoge", author_id: nil, created_at: nil, updated_at: nil>, #<Book:0x0000000107178e98 id: nil, title: "fuga", author_id: nil, created_at: nil, updated_at: nil>]
irb(main):004> author.save
TRANSACTION (0.2ms) begin transaction
Author Create (1.4ms) INSERT INTO "authors" ("name", "age", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "hoge"], ["age", nil], ["created_at", "2024-03-30 07:44:54.480518"], ["updated_at", "2024-03-30 07:44:54.480518"]]
Book Create (0.3ms) INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["title", "hoge"], ["author_id", 3], ["created_at", "2024-03-30 07:44:54.487120"], ["updated_at", "2024-03-30 07:44:54.487120"]]
Book Create (0.4ms) INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["title", "fuga"], ["author_id", 3], ["created_at", "2024-03-30 07:44:54.490454"], ["updated_at", "2024-03-30 07:44:54.490454"]]
TRANSACTION (0.3ms) commit transaction
=> true
# DBの確認
irb(main):005> Author.all
Author Load (0.3ms) SELECT "authors".* FROM "authors" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Author:0x000000010715b7d0 id: 3, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:44:54.480518000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:44:54.480518000 UTC +00:00>]
irb(main):006> Book.all
Book Load (0.3ms) SELECT "books".* FROM "books" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=>
[#<Book:0x000000010715c810 id: 3, title: "hoge", author_id: 3, created_at: Sat, 30 Mar 2024 07:44:54.487120000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:44:54.487120000 UTC +00:00>,
#<Book:0x000000010715c6d0 id: 4, title: "fuga", author_id: 3, created_at: Sat, 30 Mar 2024 07:44:54.490454000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:44:54.490454000 UTC +00:00>]
# 最初のauthorを取得して、またbooksをビルドします
irb(main):007> first_author = Author.first
Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Author:0x00000001071399c8 id: 3, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:44:54.480518000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:44:54.480518000 UTC +00:00>
irb(main):008> first_author_books = first_author.books.build
=> #<Book:0x0000000107137088 id: nil, title: nil, author_id: 3, created_at: nil, updated_at: nil>
このログを見ると、model.association.build
は一貫して関連先のオブジェクトの初期化?のようなことをしているだけのように見えます。
has_oneで関連先が永続化された状態の場合
次はhas_oneのときの挙動を確認します。適当に以下のようなモデルを定義した状態で試してみます。
class Author < ApplicationRecord
has_one :book
end
class Book < ApplicationRecord
belongs_to :author
end
# authorと紐づくbooksをビルドして永続化します
irb(main):001> author = Author.new(name: 'hoge')
=> #<Author:0x00000001045e0060 id: nil, name: "hoge", age: nil, created_at: nil, updated_at: nil>
irb(main):002> book = author.build_book(title: 'fuga')
=> #<Book:0x00000001083c9c00 id: nil, title: "fuga", author_id: nil, created_at: nil, updated_at: nil>
irb(main):003> author.save
TRANSACTION (0.2ms) begin transaction
Author Create (1.4ms) INSERT INTO "authors" ("name", "age", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "hoge"], ["age", nil], ["created_at", "2024-03-30 07:52:29.891604"], ["updated_at", "2024-03-30 07:52:29.891604"]]
Book Create (0.3ms) INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["title", "fuga"], ["author_id", 4], ["created_at", "2024-03-30 07:52:29.898393"], ["updated_at", "2024-03-30 07:52:29.898393"]]
TRANSACTION (0.5ms) commit transaction
=> true
# DBの確認
irb(main):005> Author.all
Author Load (0.4ms) SELECT "authors".* FROM "authors" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Author:0x000000010838b180 id: 4, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:52:29.891604000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:52:29.891604000 UTC +00:00>]
irb(main):006> Book.all
Book Load (0.3ms) SELECT "books".* FROM "books" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Book:0x000000010995cbd0 id: 5, title: "fuga", author_id: 4, created_at: Sat, 30 Mar 2024 07:52:29.898393000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:52:29.898393000 UTC +00:00>]
# 最初のauthorを取得して、またbookをビルドします
irb(main):007> first_author = Author.first
Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Author:0x0000000109959ed0 id: 4, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:52:29.891604000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:52:29.891604000 UTC +00:00>
irb(main):008> first_author_book = first_author.build_book(title: 'piyo')
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 4], ["LIMIT", 1]]
gems/activerecord-7.1.3.2/lib/active_record/associations/has_one_association.rb:110:in `remove_target!': Failed to remove the existing associated book. The record failed to save after its foreign key was set to nil. (ActiveRecord::RecordNotSaved)
bookがすでに紐づいた状態かつDBに永続化された状態で、再度ビルドするとActiveRecord::RecordNotSaved
となりました。
ActiveRecord::RecordNotSaved
ということはsave!
やcreate!
が実行されていると思うので、実は更新系のクエリを発行しようとしているのでしょうか...?
has_oneで関連先が永続化されていない状態の場合
次はhas_oneの亜種で関連先(book)がDBに保存されていないケースの挙動を見ていきます。
これはauthorとbookが1:0または1
の関係なら起こり得るケースだと思います。
# authorを初期化して永続化します
irb(main):001> author = Author.new(name: 'hoge')
=> #<Author:0x0000000106ff95b8 id: nil, name: "hoge", age: nil, created_at: nil, updated_at: nil>
irb(main):002> author.save
TRANSACTION (0.1ms) begin transaction
Author Create (1.4ms) INSERT INTO "authors" ("name", "age", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "hoge"], ["age", nil], ["created_at", "2024-03-30 07:57:33.098168"], ["updated_at", "2024-03-30 07:57:33.098168"]]
TRANSACTION (0.2ms) commit transaction
=> true
# DBの確認
irb(main):003> Author.all
Author Load (0.2ms) SELECT "authors".* FROM "authors" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Author:0x00000001076dca88 id: 5, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:57:33.098168000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:57:33.098168000 UTC +00:00>]
irb(main):004> Book.all
Book Load (0.3ms) SELECT "books".* FROM "books" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> []
# 最初のauthorを取得して、bookをビルドします
irb(main):005> first_author = Author.first
Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Author:0x00000001076d9b08 id: 5, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 07:57:33.098168000 UTC +00:00, updated_at: Sat, 30 Mar 2024 07:57:33.098168000 UTC +00:00>
irb(main):006> first_author.build_book(title: 'fuga')
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 5], ["LIMIT", 1]]
=> #<Book:0x0000000106df2d28 id: nil, title: "fuga", author_id: 5, created_at: nil, updated_at: nil>
関連先のbookがDBに存在しない状態だと、bookのビルドは通るみたいです。
has_oneでdependent:destroyを定義した場合
class Author < ApplicationRecord
has_one :book, dependent: :destroy
end
class Book < ApplicationRecord
belongs_to :author
end
# authorと紐づくbooksをビルドして永続化します
irb(main):001> author = Author.new(name: 'hoge')
=> #<Author:0x0000000108229a30 id: nil, name: "hoge", age: nil, created_at: nil, updated_at: nil>
irb(main):002> author.build_book(title: 'fuga')
=> #<Book:0x000000010ccd5ca0 id: nil, title: "fuga", author_id: nil, created_at: nil, updated_at: nil>
irb(main):003> author.save
TRANSACTION (0.1ms) begin transaction
Author Create (1.3ms) INSERT INTO "authors" ("name", "age", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "hoge"], ["age", nil], ["created_at", "2024-03-30 08:06:11.275015"], ["updated_at", "2024-03-30 08:06:11.275015"]]
Book Create (0.4ms) INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["title", "fuga"], ["author_id", 6], ["created_at", "2024-03-30 08:06:11.281536"], ["updated_at", "2024-03-30 08:06:11.281536"]]
TRANSACTION (0.4ms) commit transaction
=> true
# DBの確認
irb(main):004> Author.all
Author Load (0.3ms) SELECT "authors".* FROM "authors" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Author:0x000000010cbb8e80 id: 6, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 08:06:11.275015000 UTC +00:00, updated_at: Sat, 30 Mar 2024 08:06:11.275015000 UTC +00:00>]
irb(main):005> Book.all
Book Load (0.3ms) SELECT "books".* FROM "books" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> [#<Book:0x000000010cbba500 id: 7, title: "fuga", author_id: 6, created_at: Sat, 30 Mar 2024 08:06:11.281536000 UTC +00:00, updated_at: Sat, 30 Mar 2024 08:06:11.281536000 UTC +00:00>]
# 最初のauthorを取得して、bookを再度ビルドします
irb(main):006> first_author = Author.first
Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Author:0x000000010cbbadc0 id: 6, name: "hoge", age: nil, created_at: Sat, 30 Mar 2024 08:06:11.275015000 UTC +00:00, updated_at: Sat, 30 Mar 2024 08:06:11.275015000 UTC +00:00>
irb(main):007> first_author.build_book(title: 'piyo')
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 6], ["LIMIT", 1]]
TRANSACTION (0.1ms) begin transaction
Book Destroy (0.7ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 7]]
TRANSACTION (0.2ms) commit transaction
=> #<Book:0x000000010d11bcd8 id: nil, title: "piyo", author_id: 6, created_at: nil, updated_at: nil>
# もう一度現状のbooksテーブルの状態を確認する
irb(main):008> Book.all
Book Load (0.2ms) SELECT "books".* FROM "books" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=> []
最後の方を見ると分かる通り、dependent:destroy
を関連のオプションで使っているときは、ビルドしたときにdeleteのクエリが発行されるようです。
これは個人的には落とし穴かなと思っており、build_associationを実行しただけで更新系のクエリが発行されるのは想像しにくいかなと思います。
まとめ
- has_manyのときは、
model.association.build
で更新されることはない - has_oneで関連先とのデータ構造が
1:0または1
のような関係で、DBに永続化したあとにmodel.build_association
をするとレコードが更新される可能性がある - has_oneで関連先とのデータ構造が
1:1
のような関係で、DBに永続化したあとにmodel.build_association
をすることはなさそうだから、あまり心配いらなさそう(アプリケーションによると思うが)
参考
Discussion