2021-02-07

ActiveRecordのautosaveの設定で子モデル含めた新規作成・更新時の振る舞いはどのように変わるか

ActiveRecordが提供する has_manyhas_onebelongs_to といったアソシエーションの設定を行うメソッドには autosave というオプションがある。

APIドキュメント を見るとオプションの効果がおおよそわかるのだけど、いまいち理解しきれた感は得られなかったので、手元で検証してみることにした。

特に、autosaveオプションの設定によって関連する子モデルごとまとめて新規作成・更新しようとしたときにどのように振る舞いが変わるかについて調べている。

検証時の環境は次の通り。

❯ ruby -v
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin18]

❯ bundle exec rails -v
Rails 6.1.1

最初に結論

以下が検証結果の簡単なまとめになる。 未指定の場合、子モデル側にバリデーションエラーがあった場合の挙動が結構やばいと思ったので、通常 autosave: true をつけるのが良さそうという見解を得られた。

  • autosave 未指定時
    • 新規作成は子モデル含めて操作することができる
    • 更新時は子モデル側の変更は無視される
    • 新規作成時、子モデルにバリデーションエラーがあった場合、子モデルだけエラーで保存されず親モデルは保存される
  • autosave: true の時
    • 新規作成も更新も子モデル含めて操作する
    • 子モデルにバリデーションエラーがあった場合、新規作成時も更新時も全体がロールバックされる。
  • autosave: false の時
    • 新規作成も更新時も子モデルの変更は無視される

検証に利用するModelと関連設定

Company モデルが Website モデルを1つ持つ、というhas_oneの関係のモデルを検証に使う。

# app/models/company.rb
class Company < ApplicationRecord
  has_one :website # ★ ここに autosave オプションを付けたりはずしたり未指定にしたりする

  validates :name, presence: true
end
# app/models/website.rb
class Website < ApplicationRecord
  belongs_to :company

  validates :url, presence: true
end

親モデル側を主体として子モデルをまとめて操作する、という使い方をすることが多いと思うので、autosave はCompany側で設定する。

autosave オプション未指定時

まずは、autosaveオプションを明示的に指定しない場合。

子モデルごと新規作成

子モデルごとまとめて作成される。

company = Company.new(name: 'foo')
company.build_website(url: 'https://example.com')
company.save!
#  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
#  Company Create (1.3ms)  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  ...
#  Website Create (0.4ms)  INSERT INTO "websites" ("url", "company_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) ...
#  TRANSACTION (0.2ms)  RELEASE SAVEPOINT active_record_1
# => true

子モデルごと更新

子モデルの更新は無視され、親モデル側だけ更新される。

company = Company.last
company.name = 'bar'
company.website.url = 'https://www.mogulla3.tech'
company.save!
#  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
#  Company Update (0.5ms)  UPDATE "companies" SET "name" = ?, "updated_at" = ? WHERE "companies"."id" = ?  ...
#  TRANSACTION (0.2ms)  RELEASE SAVEPOINT active_record_1
# => true

p company.reload.website.url 
# => "https://example.com"

子モデルにバリデーションエラーが発生する場合

更新時は子モデルはそもそも無視されるので、新規作成時に子モデル側でバリデーションエラーが起こった場合にどうなるか?という話。

子モデル側でバリデーションエラーになる場合、親モデルの新規作成処理だけ行われる。 ここの振る舞いは、知らないと結構やばいなと思った。

company = Company.new(name: 'foo')
company.build_website(url: nil) # urlは必須なのでバリデーションエラーに引っかかる
company.save!
#  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
#  Company Create (1.0ms)  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?) ...
#  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
# => true

p company.reload.website 
# => nil

p company.website.errors
# => #<ActiveModel::Errors:0x00007fcf19d4be40 @base=#<Website:0x00007fcf19440c10 id: nil, url: nil, company_id: 1, created_at: nil, updated_at: nil>, @errors=[#<ActiveModel::Error attribute=url, type=blank, options={}>]>

エラーは子モデル側に格納されている。

autosave: true の時

次は autosave: true を指定した場合

 class Company < ApplicationRecord
-  has_one :website
+  has_one :website, autosave: true

   validates :name, presence: true
 end

子モデルごと新規作成

子モデルごとまとめて作成される。autosave未指定時と同じ挙動。

company = Company.new(name: 'foo')
company.build_website(url: 'https://example.com')
company.save!
#  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
#  Company Create (1.1ms)  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?) ... 
#  Website Create (0.9ms)  INSERT INTO "websites" ("url", "company_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) ...
#  TRANSACTION (0.3ms)  RELEASE SAVEPOINT active_record_1
# => true

子モデルごと更新

未指定時と違い、子モデルごとまとめて更新される。

company = Company.last
company.name = 'bar'
company.website.url = 'https://www.mogulla3.tech'
company.save!
#  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
#  Company Update (0.4ms)  UPDATE "companies" SET "name" = ?, "updated_at" = ? WHERE "companies"."id" = ?  ...
#  Website Update (0.1ms)  UPDATE "websites" SET "url" = ?, "updated_at" = ? WHERE "websites"."id" = ?  ...
#  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
# => true

p company.reload.website.url
# => "https://www.mogulla3.tech"

子モデルにバリデーションエラーが発生する場合

未指定時と違い、子モデルにバリデーションエラーが発生するような場合、新規作成処理も更新処理もロールバックされるため、親モデル側のみ中途半端に作られるようなことは起きない。

新規作成時

company = Company.new(name: 'foo')
company.build_website(url: nil)  # urlは必須なのでバリデーションエラーに引っかかる
company.save!

ActiveRecord::RecordInvalid: Validation failed: Website url can't be blank

更新時

company = Company.last
company.name = 'bar'
company.website.url = nil
company.save!

ActiveRecord::RecordInvalid: Validation failed: Website url can't be blank

autosave: false の時

最後に autosave: false を指定した場合。検証せずとも子モデル側は無視されることが予想がつくが、念のためやっておく。

 class Company < ApplicationRecord
-  has_one :website
+  has_one :website, autosave: false

   validates :name, presence: true
 end

子モデルごと新規作成

子モデルの作成処理は無視される。

company = Company.new(name: 'foo')
company.build_website(url: 'https://example.com')
company.save!
#  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
#  Company Create (0.8ms)  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  ...
#  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
# => true

子モデルごと更新

子モデルの更新処理は無視される。

company = Company.last
company.name = 'bar'
company.website.url = 'https://www.mogulla3.tech'
company.save!
#  TRANSACTION (0.4ms)  SAVEPOINT active_record_1
#  Company Update (0.5ms)  UPDATE "companies" SET "name" = ?, "updated_at" = ? WHERE "companies"."id" = ?  ...
#  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
# => true

p company.reload.website.url 
# => "https://example.com"

参考リンク