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_destroy
や after_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 でも過去に議論になったもののバグではなく仕様であると判断された様子。
解決方法
has many through association に dependent: :destroy
オプションを渡せば delete_all
ではなく destroy
相当の処理が行われ、after_destroy
や after_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:直感的にはこっちが削除されそうな気がしたので念の為確認した