結局Railsで論理削除ってどうやればいいんだろうという話
- 投稿者
- てつ
論理削除は最高のソリューションである
論理削除の実装について、Googleで「論理削除」を検索してみてください。
「論理削除は駄目だ」とか「アンチパターン」といったキーワードの記事がヒットすることかと思います。大抵のエンジニアは「やはり論理削除は設計がむずかしいのか」と論理削除から目をそらし、論理削除を伴わなくて済むような方法を検討し、よりスマートな設計に逃げ出すことでしょう。
しかしそれでも、『論理削除のデメリットなんて知ったことか。俺は論理削除がやりたいんだ。手段を目的にしたいんだ。』
そんなスキーマ設計の乱世に丸腰で走り込むような、大和魂を失わないエンジニアの方々のために本記事を贈りたいと思います。
前提
Ruby on Railsでポピュラーな論理削除の実装方法はgemのparanoiaを使う方法がありましたが、現在はparanoia公式にて非推奨となっています。大丈夫です。論理削除は非推奨とは書いていないです。 非推奨となった詳細な理由は公式のGitHubを参照してください。
代替に多く使用されているgemはdiscardのようです。あえて推奨されていないparanoiaに丸腰で挑みたい方がほとんどだと思いますが、今回はその気持を必死に抑え、discardを使用します。
discardを使ったときの課題
実装方針について
discardを使用した時の大まかな動作としては
- カラムdiscarded_at
を対象のテーブルに追加する
- メソッドdiscard
で論理削除(discarded_at
をTime.current
で更新)
# ソースコードの該当箇所抜粋
def discard
return false if discarded?
run_callbacks(:discard) do
update_attribute(self.class.discard_column, Time.current)
end
end
対象モデルでデフォルトスコープに
kept
を追加(default_scope -> { kept }
)# ソースコードの該当箇所抜粋 scope :kept, ->{ undiscarded } scope :undiscarded, ->{ where(discard_column => nil) }
といった仕様です。
単にレコードを論理削除する場合にはこれで十分なのですが、複数のレコードを削除する場合や、関連レコードの削除の際に不都合がでてきます。
複数のレコード・関連レコードの削除
複数レコード削除する場合にはメソッドdiscard_all
が用意されていますが、内容としてはそれぞれのレコードで上記のdiscard
を実行しているので、複数削除する場合はN+1が発生します。
def discard_all
kept.each(&:discard)
end
また、discard
はdiscarded_at
をTime.current
で更新しているのみなので、関連レコードが論理削除の対象の場合だったとしても、そのレコードは論理削除されません。
解決方法
上記を解決するためにApplicationRecord app/models/application_record.rb
に以下を追記しました。
詳細に処理速度を測定しておらず、「もっとベターな形があるよ」という想いを持つ方がいらっしゃるかもしれませんが、マサカリを投げたりせずに黙って胸の内に秘めておいてください。
# includesするための関連レコード群をハッシュで返す
def self.included_tables
result = %i[has_many has_one].map { |a| self.class.reflect_on_all_associations(a) }.flatten.map do |i|
next unless %i[destroy delete delete_all].include?(i.options[:dependent]) && i.options.keys.exclude?(:through)
self_class = i.options[:class_name]&.underscore&.pluralize&.to_sym || i.name
sub_table = self_class.to_s.singularize.camelize.constantize.included_tables
sub_table.blank? ? self_class : { self_class => sub_table }
end.compact
result
end
# 関連レコードも含めて論理削除する
def discard_with_relations
@root_class = self.class
ActiveRecord::Base.transaction do
update_records = discard_relation_records
update_records[self.class.name] ||= []
update_records[self.class.name] << id
update_records.each do |key, value|
key.constantize.where(id: value).update_all(discarded_at: DateTime.current)
end
end
end
# 関連レコードのidをクラスごとに再帰的に収集する
def discard_relation_records(update_records = {})
relation_tables.each do |relation_table|
send(relation_table).each do |record|
update_records[record.class.name] ||= []
update_records[record.class.name] << record.id
update_records = record.discard_relation_records(update_records)
end
end
update_records
end
# has_manyで関連付けられているテーブル群を返す
def relation_tables
%i[has_many has_one].map { |a| self.class.reflect_on_all_associations(a) }.flatten.map do |i|
next unless %i[destroy delete delete_all].include?(i.options[:dependent]) && i.options.keys.exclude?(:through)
# 再帰処理の最初のみincludes(:hogehoge)する
klass = i.options[:class_name]&.constantize || i.name.to_s.singularize.camelize.constantize
self.class == @root_class ? send(i.name).includes(klass.included_tables) : send(i.name)
end.compact
end
結論
上記を実装すると、discard_with_relations
を使っておけば、後々関連レコードが追加されたときも追加対応することが不要となり、discard
ではなくdiscard_with_relations
を使うこととなります。
結果として、gemで用意されている機能をほぼ使わなくなくなるので、Railsにおいて論理削除を実装する場合はgemを使わなくても問題なく実装できるかな、という感触でした。
最後まで読んでいただきありがとうございました。Railsで論理削除を実装したいという方の役に立てれば嬉しいです。