ActiveRecordでdependent: :destroy_asyncを指定した際のdelete_allの挙動に惑わされない
はじめに
こんにちは、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
メソッドの仕様を調べてみましょう。ソースコードのコメントに、仕様に関して詳しく記載されています。
コメントを読んでみる
コメントを上から順に見ていきたいと思います。
# 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 :through
もhas_many
もdelete_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
メソッドの実装です。
destroy_async
の場合はoptions[:dependent]
がそのままdelete_or_nullify_all_records
メソッドに渡されています。なお、引数を渡すことでnullify
かdelete_all
を切り替えることもできるようです(知りませんでした)。
次はdelete_or_nullify_all_records
メソッドを見てみます。
delete_count
メソッドで処理をしているようなので、次はそちらを見てみます。
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されていました。
最後に
ActiveRecordでdependent: :destroy_async
を指定した際のdelete_all
メソッドの挙動に関して、社内で助言をもらったことを整理して記事にしてみました。私自身、delete_all
メソッドの仕様を正しく理解するきっかけになったので、皆様のお役に立てば幸いです。
Discussion