カレーの恩返し

おいしいのでオススメ。

Rails の has many through 経由でモデルを削除すると destroy callback が呼び出されない

has many through の挙動についてずっと勘違いしていたところがあったので忘れないように書き留めておきます。

3行まとめ

  • has many through のデフォルトの挙動では削除時に delete_all を実行したのと同じ挙動になり、callback が発火しない
  • has many through のオプションに dependent: :destroy をつけると destroy callback が呼び出され一般的に期待する挙動になる
  • has many through を使う際は常に dependent: :destroy をつけるようにした方が良いのでは?

起きていた問題

例えば、図書館のような誰がどの本を借りているかを管理するアプリケーションがあったとすると、以下のような model が存在しているはず。

# == Schema Information
#
# Table name: users
#
#  id          :bigint           not null, primary key
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
# 
class User < ApplicationRecord
  has_many :bookings
  has_many :books, through: :bookings
end

# == Schema Information
#
# Table name: books
#
#  id          :bigint           not null, primary key
#  content     :text
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
# 
class Book < ApplicationRecord
end

# == Schema Information
#
# Table name: bookings
#
#  id          :bigint           not null, primary key
#  user_id     :bigint           not null
#  book_id     :bigint           not null
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
# 
class Booking < ApplicationRecord
  belongs_to :user
  belongs_to :book
end

user_id: 1 のユーザーが借りている本を取得したい場合は以下のように記述できる。

User.find(1).books
# => [<Book>, <Book>, ...]

また、user_id: 1 のユーザーが新たに book_id: 100 の本を借りる場合は以下のように記述できる。
このとき、Booking model に after_create callback が定義されていれば該当 callback が発火し、期待する処理を行うことができる。

User.find(1).books << Books.find(100)
# Booking Create (xxx ms) INSERT INTO `bookings` (`user_id`, `book_id`) VALUES (1, 100)

また、user_id: 1 のユーザーが借りている本を全て返したい場合は以下のように記述できる。
このとき、Booking model に after_destroyafter_commit on: :destroy callback が定義されていても該当 callback は発火せず、期待する処理は行われない。 ログには Delete All と表示されているのでどうやら delete_all を実行したときと同じような挙動になってそう。困った。

User.find(1).books
# => [<Book id:100>]
User.find(1).books << []
# Booking Delete All (xxx ms) DELETE FROM `bookings` WHERE `bookings`.`user_id` = 1 AND `bookings`.`book_id` = 100

この挙動については、rails/rails でも過去に議論になったもののバグではなく仕様であると判断された様子。

github.com

解決方法

has many through association に dependent: :destroy オプションを渡せば delete_all ではなく destroy 相当の処理が行われ、after_destroyafter_commit on: :destroy を発火させることができる。

# == Schema Information
#
# Table name: users
#
#  id          :bigint           not null, primary key
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
# 
class User < ApplicationRecord
  has_many :bookings
  # ここに `dependent: :destroy` を追加
  has_many :books, through: :bookings, dependent: :destroy
end
User.find(1).books
# => [<Book id:100>]
User.find(1).books << []
# Booking Destroy (xxx ms) DELETE FROM `bookings` WHERE `bookings`.`id` = 1

ちなみに has many through association に dependent: :destroy オプションを渡しても多対多の関連先である Book が削除されないことは確認済み。*1

感想

https://github.com/rails/rails/issues/7618 にも書いてあったように、追加時は callback が走って削除時は callback が走らないのは直感的ではないように感じた。且つ、has many through に dependent: :destroy オプションを渡せば destroy callback が発火するのもピンも来ていない。
destroy callback を発火させたくないケースが正直思いつかないので、 has many through を使う時には常にdestroy: :dependent オプションをつけておいてもいいのでは?とさえ思った。

とはいえ、ここを Rails 側が変更するとなるとどうしても破壊的な変更になってしまうので Rails 側の仕様が変わることはなさそう。であれば Cop を作って未然に防ぐなどで自衛するしかないんだろうか...

*1:直感的にはこっちが削除されそうな気がしたので念の為確認した