🐙

railsでbuild_associationをするときは関連の種類によって挙動が違うので気をつけよう

2024/03/30に公開

先日業務で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!が実行されていると思うので、実は更新系のクエリを発行しようとしているのでしょうか...?

https://api.rubyonrails.org/classes/ActiveRecord/RecordNotSaved.html

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をすることはなさそうだから、あまり心配いらなさそう(アプリケーションによると思うが)

参考

https://qiita.com/Kyo18/items/96dd5d213ee57a42f482
https://blog.willnet.in/entry/2023/07/04/113321

Discussion