😵‍💫

ActiveRecordでdependent: :destroy_asyncを指定した際のdelete_allの挙動に惑わされない

2024/06/19に公開

はじめに

こんにちは、kmkntです。

今回はタイトルにある通り、開発中にActiveRecordでdependent: :destroy_asyncを指定した際のdelete_allメソッドの挙動に惑わされたので、そのお話ができればと思います。

まずは、説明のために用意した以下のシンプルなソースコードをご覧ください。
なお、検証に利用したRailsのバージョンは7.1.3です。

ActiveRecord::Schema[7.1].define(version: 0) do
  create_table "articles", force: :cascade do |t|
  end

  create_table "comments", force: :cascade do |t|
    t.integer "article_id", null: false
    t.index ["article_id"], name: "index_comments_on_article_id"
  end

  add_foreign_key "comments", "articles"
end

class Article < ActiveRecord::Base
  has_many :comments, dependent: :destroy_async
end

class Comment < ActiveRecord::Base
  belongs_to :article
end

articlesテーブルとcommentsテーブルが関連付けされており、commentsテーブルには外部キーが張られています。

ここで、

article = Article.find(1)
article.comments.delete_all

というようなコードを書いて実行すると、NOT NULL制約違反のエラーが出て削除に失敗します。

一見すると、commentsテーブルに対してDELETE文が実行されるだけのように思えませんか?
実際には、DELETE文は実行されず、以下のようなcommentsテーブルのarticle_idカラムをNULL化するためのUPDATE文が実行されます。

UPDATE "comments" SET "article_id" = NULL WHERE "comments"."article_id" = 1

しかし、article_idカラムはNOT NULLかつarticlesテーブルへの外部キーが張られているので、UPDATE文の実行に失敗してしまい、上述のエラーが出てしまう・・という流れです。

なぜ、delete_allメソッドはこのような挙動をするのでしょうか?

delete_allの仕様を調べてみる

delete_allメソッドの仕様を調べてみましょう。ソースコードのコメントに、仕様に関して詳しく記載されています。

https://github.com/rails/rails/blob/7b6a01b437f46817aea5ad0e9cea4789e39e41df/activerecord/lib/active_record/associations/collection_proxy.rb#L395-L476

コメントを読んでみる

コメントを上から順に見ていきたいと思います。

      # Deletes all the records from the collection according to the strategy
      # specified by the +:dependent+ option. If no +:dependent+ option is given,
      # then it will follow the default strategy.
      #
      # For <tt>has_many :through</tt> associations, the default deletion strategy is
      # +:delete_all+.
      #
      # For +has_many+ associations, the default deletion strategy is +:nullify+.
      # This sets the foreign keys to +NULL+.
      #
      #   class Person < ActiveRecord::Base
      #     has_many :pets # dependent: :nullify option by default
      #   end
      #
      #   person.pets.size # => 3
      #   person.pets
      #   # => [
      #   #       #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
      #   #       #<Pet id: 2, name: "Spook", person_id: 1>,
      #   #       #<Pet id: 3, name: "Choo-Choo", person_id: 1>
      #   #    ]
      #
      #   person.pets.delete_all
      #   # => [
      #   #       #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
      #   #       #<Pet id: 2, name: "Spook", person_id: 1>,
      #   #       #<Pet id: 3, name: "Choo-Choo", person_id: 1>
      #   #    ]
      #
      #   person.pets.size # => 0
      #   person.pets      # => []
      #
      #   Pet.find(1, 2, 3)
      #   # => [
      #   #       #<Pet id: 1, name: "Fancy-Fancy", person_id: nil>,
      #   #       #<Pet id: 2, name: "Spook", person_id: nil>,
      #   #       #<Pet id: 3, name: "Choo-Choo", person_id: nil>
      #   #    ]

dependentオプションが指定されている場合はそちらに従い、dependentオプションが指定されていない場合はhas_many :throughだとデフォルトがdelete_allとなり、has_manyだとデフォルトがnullifyになると書かれていますね。

      # Both +has_many+ and <tt>has_many :through</tt> dependencies default to the
      # +:delete_all+ strategy if the +:dependent+ option is set to +:destroy+.
      # Records are not instantiated and callbacks will not be fired.
      #
      #   class Person < ActiveRecord::Base
      #     has_many :pets, dependent: :destroy
      #   end
      #
      #   person.pets.size # => 3
      #   person.pets
      #   # => [
      #   #       #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
      #   #       #<Pet id: 2, name: "Spook", person_id: 1>,
      #   #       #<Pet id: 3, name: "Choo-Choo", person_id: 1>
      #   #    ]
      #
      #   person.pets.delete_all
      #
      #   Pet.find(1, 2, 3)
      #   # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3)

dependent: :destroyが指定されている場合は、has_many :throughhas_manydelete_allとなると書かれています。

      # If it is set to <tt>:delete_all</tt>, all the objects are deleted
      # *without* calling their +destroy+ method.
      #
      #   class Person < ActiveRecord::Base
      #     has_many :pets, dependent: :delete_all
      #   end
      #
      #   person.pets.size # => 3
      #   person.pets
      #   # => [
      #   #       #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>,
      #   #       #<Pet id: 2, name: "Spook", person_id: 1>,
      #   #       #<Pet id: 3, name: "Choo-Choo", person_id: 1>
      #   #    ]
      #
      #   person.pets.delete_all
      #
      #   Pet.find(1, 2, 3)
      #   # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3)

dependent: :delete_allが指定されている場合は、destroyメソッドが呼ばれずに削除されると書かれています。

今回はdependent: :destroy_asyncが指定されているので、destroy_asyncに従うことになります。コメントには明記されていないので、ソースコードを追ってみることにします。

ソースコードを読んでみる

まずはdelete_allメソッドの実装です。

https://github.com/rails/rails/blob/7b6a01b437f46817aea5ad0e9cea4789e39e41df/activerecord/lib/active_record/associations/collection_association.rb#L135-L165

destroy_asyncの場合はoptions[:dependent]がそのままdelete_or_nullify_all_recordsメソッドに渡されています。なお、引数を渡すことでnullifydelete_allを切り替えることもできるようです(知りませんでした)。

次はdelete_or_nullify_all_recordsメソッドを見てみます。

https://github.com/rails/rails/blob/2ebb508cd8ee16c0bb280f91b93c01d939fcbf12/activerecord/lib/active_record/associations/has_many_association.rb#L120-L124

delete_countメソッドで処理をしているようなので、次はそちらを見てみます。

https://github.com/rails/rails/blob/2ebb508cd8ee16c0bb280f91b93c01d939fcbf12/activerecord/lib/active_record/associations/has_many_association.rb#L112-L118

delete_all以外はscope.update_all(nullified_owner_attributes)が実行されていることがわかります。よって、destroy_asyncの場合はnullifyするのがデフォルトの動作になるようです。

delete_allはなぜこのような仕様になっているのか

なぜ、delete_allメソッドはこのような仕様になっているのでしょうか?

dependent: :destroy_asyncが指定されていたとしても、delete_allメソッドを実行した場合はデフォルトの動作として、UPDATE文によるNULL化ではなく、DELETE文が実行されるのが仕様だとしても不自然ではないように思います。

今回の件を社内の朝会で共有したところ、ソースコードを読み進めて仕様を確認した上で、「delete_allメソッドで削除できるのであれば、そもそもdestroy_asyncを指定して非同期に削除する必要がないため、Railsとしてもサポートしていないのではないか?」という話になりました。今回のケースでは、destroy_asyncを指定したのがそもそもの間違いではないかと。nullifyがデフォルトの動作になっているのはバグではないかという話も一瞬出たのですが、destroy_asyncのユースケースを考えれば、仕様であると捉えたほうがよさそうという結論になりました。

実際、過去にこの仕様に関するissueが切られていたのですが、そのように判断できる回答とともにcloseされていました。

https://github.com/rails/rails/issues/50421

最後に

ActiveRecordでdependent: :destroy_asyncを指定した際のdelete_allメソッドの挙動に関して、社内で助言をもらったことを整理して記事にしてみました。私自身、delete_allメソッドの仕様を正しく理解するきっかけになったので、皆様のお役に立てば幸いです。

SocialPLUS Tech Blog

Discussion