後述する仕様を実現するために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
というキーワードを見つけるまでは関連記事には辿り着けなかったけど、ググり力が高い人はさらっと見つけて時短できるんだろうな。ググり力もエンジニアには重要だなと感じました。