Posted at

dependentはどうやって動いているのか?

後述する仕様を実現するために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_destroybooksを直接削除したときのことだけ考えれば良くなる。

これで動作は問題ないが、今後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を削除する時にフラグを立ててbookbefore_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というキーワードを見つけるまでは関連記事には辿り着けなかったけど、ググり力が高い人はさらっと見つけて時短できるんだろうな。ググり力もエンジニアには重要だなと感じました。