後述する仕様を実現するためにactiverecordのdependent周りのソースを読んだのでまとめました。
前提
railsのアソシエーションにはdependentというオプションを設定することができます。
これによりレコードを削除した時に関連レコードに対して自動的に様々な動作をさせることができます。
例えばhas_manyの場合は下記を指定できます。
# レコード削除時に関連するbooksも削除する(callbackあり)
has_many :books, dependent: :destroy
# レコード削除時に関連するbooksも削除する(callbackなし)
has_many :books, dependent: :delete_all
# レコード削除時に関連するbooksの外部キーをnilに更新する
has_many :books, dependent: :nullify
# レコード削除時に関連するbooksが存在する場合例外を発生させる
has_many :books, dependent: :restrict_with_exception
# レコード削除時に関連するbooksが存在する場合errorsを追加して削除を失敗させる
has_many :books, dependent: :restrict_with_error
詳細はrailsドキュメント参照してください。
https://railsguides.jp/association_basics.html#dependent
やりたかったこと
(色々端折ってはいますが)下記の仕様を満たすモデルを作成しようとしていました。
- お店には本を追加・削除できる。ただし、必ず1冊以上は本がある状態をキープする。
- お店を削除した時は全ての本を削除する。
簡単に実装したものが下記。
ソースコメントに記載していますが、before_destoryの中で①「shopを削除してdependent: :destroyで削除されようとしている」のか、②「booksを単独で消そうとした」のかを判断する方法がぱっとわかりませんでした。
# お店
class Shop < ApplicationRecord
has_many :books, dependent: :destroy
end
# 本
class Books < ApplicationRecord
belongs_to :shop
before_destroy :before_destroy_action
def before_destroy_action
# 最後の1冊の場合は削除できない。
# ただし、お店ごと削除する場合はすべて削除する。 <- これどう判定するの????
raise unless Books.not.where(id: id).exists?
end
end
考えた解決案
dependent: :delete_allを使う
dependent: :destroyの部分をdependent: :delete_allを変更することでcallbackが呼ばれなくなるのでbefore_destroyはbooksを直接削除したときのことだけ考えれば良くなる。
これで動作は問題ないが、今後booksに他のcallbackが追加された時や子テーブルが追加されてdependent: :destoryとしたい時などに支障がでるためこの方法はパス。
# お店
class Shop < ApplicationRecord
has_many :books, dependent: :delete_all
end
# 本
class Books < ApplicationRecord
belongs_to :shop
before_destroy :before_destroy_action
def before_destroy_action
# 最後の1冊の場合は削除できない。
# shopを削除した場合はbefore_destroyは実行されないので考慮不要
raise unless Books.not.where(id: id).exists?
end
end
dependent: :destroyで削除する場合にshopレコードに判定するためのフラグを持たせる
下記のようにshopを削除する時にフラグを立ててbookのbefore_destroy内でそのフラグで判定する。
この方法でも動作は問題ないが、そもそもこの微妙なフラグをわざわざ自前で定義する必要があるのか?railsのことだから判定する方法があるのではないか?と考えて保留にしました。
# お店
class Shop < ApplicationRecord
before_destroy: :before_destroy_action
has_many :books, dependent: :destroy
def before_destroy_action
@destroying = true
end
def destroying?
@destroying
end
end
# 本
class Books < ApplicationRecord
belongs_to :shop
before_destroy :before_destroy_action
def before_destroy_action
# shopを削除した場合は何もしない
return if shop.destroying?
# 最後の1冊の場合は削除できない。
raise unless Books.not.where(id: id).exists?
end
end
railsに組み込まれている機能を使って判定する
ということでrailsのソースを読んで使えそうな機能を探すことにしました。
destroyについて
最初にdestroyのソースを見てみました。
@destroyedってフラグを立てているじゃないか。これ使えないかな?
https://github.com/rails/rails/blob/93a6500baa6bbb331bb93ccdc14fdda5769f5ef9/activerecord/lib/active_record/persistence.rb#L172
結論を先に書きますがdependent: :destroyによる削除が先に動いてしまい、関連データのbefore_destroyが呼ばれる時には@destroyedはtrueになっていません。
dependentについて
動作を確認してdestroyより先にdependent: :destroyが動いていることはわかりましたが、そもそもdependentって何をしているんだろうということでソースをみてみました。
https://github.com/rails/rails/blob/fc35da76e93f8a5d5ace595b4819e19cc0512edd/activerecord/lib/active_record/associations/builder/association.rb#L32
↓
https://github.com/rails/rails/blob/fc35da76e93f8a5d5ace595b4819e19cc0512edd/activerecord/lib/active_record/associations/builder/association.rb#L76
↓
https://github.com/rails/rails/blob/fc35da76e93f8a5d5ace595b4819e19cc0512edd/activerecord/lib/active_record/associations/builder/association.rb#L129
↓
https://github.com/rails/rails/blob/47e3bbeb9057b37c244330cc4e745c8a8090e8c5/activerecord/lib/active_record/associations/has_many_association.rb#L13
上記の順番に追っていけばわかりますが、dependentで指定したオプションを判定して削除処理のbefore_destroyを追加しています。
ということで、dependentはbefore_destroyを追加しているだけなのでdestroyより前に動いちゃうんですね。
じゃあどうする?
下記のソースを見てみるとdependent: :destroyの時にdestroyed_by_associationに値を入れていることがわかります。
https://github.com/rails/rails/blob/47e3bbeb9057b37c244330cc4e745c8a8090e8c5/activerecord/lib/active_record/associations/has_many_association.rb#L27
これを判定に使えそうなので検証したところ下記のようにチェック可能でした。
自前フラグを追加するよりこちらの方がスマートな気がします。
# お店
class Shop < ApplicationRecord
has_many :books, dependent: :destroy
end
# 本
class Books < ApplicationRecord
belongs_to :shop
before_destroy :before_destroy_action
def before_destroy_action
# shopを削除した場合は何もしない
return if destroyed_by_association.present?
# 最後の1冊の場合は削除できない。
raise unless Books.not.where(id: id).exists?
end
end
あとがき
railsのようなオープンソースのフレームワークはソースを簡単にみることができるので仕様を細かく知りたくなったら積極的に読んでみることをお勧めします。
自分でごちゃごちゃやらなくても大抵のことは他の人も困っていて、すでに実装されていたりします。
また、コードリーディングはコーディング力向上に繋がると思いますし、理解が深まればその言語を使うことがより楽しくなると思います。
あとがきのあとがき
ソースを読んでdestroyed_by_associationが使えそうとわかったところで、あらためてググってみたら似たような記事をいくつか見つけました。下記は一例(qiitaにもあった)
https://qiita.com/mishiwata1015/items/ac7c33b5f116111d8568
私はdestroyed_by_associationというキーワードを見つけるまでは関連記事には辿り着けなかったけど、ググり力が高い人はさらっと見つけて時短できるんだろうな。ググり力もエンジニアには重要だなと感じました。