StartupTechnology開発部による技術ブログ

StartupTechnologyの開発部が
開発の裏側やノウハウをお届けします。

結局Railsで論理削除ってどうやればいいんだろうという話

投稿者
てつ

論理削除は最高のソリューションである

論理削除の実装について、Googleで「論理削除」を検索してみてください。

「論理削除は駄目だ」とか「アンチパターン」といったキーワードの記事がヒットすることかと思います。大抵のエンジニアは「やはり論理削除は設計がむずかしいのか」と論理削除から目をそらし、論理削除を伴わなくて済むような方法を検討し、よりスマートな設計に逃げ出すことでしょう。

しかしそれでも、『論理削除のデメリットなんて知ったことか。俺は論理削除がやりたいんだ。手段を目的にしたいんだ。』

そんなスキーマ設計の乱世に丸腰で走り込むような、大和魂を失わないエンジニアの方々のために本記事を贈りたいと思います。

前提

Ruby on Railsでポピュラーな論理削除の実装方法はgemのparanoiaを使う方法がありましたが、現在はparanoia公式にて非推奨となっています。大丈夫です。論理削除は非推奨とは書いていないです。 非推奨となった詳細な理由は公式のGitHubを参照してください。

代替に多く使用されているgemはdiscardのようです。あえて推奨されていないparanoiaに丸腰で挑みたい方がほとんどだと思いますが、今回はその気持を必死に抑え、discardを使用します。

discardを使ったときの課題

実装方針について

discardを使用した時の大まかな動作としては - カラムdiscarded_atを対象のテーブルに追加する - メソッドdiscardで論理削除(discarded_atTime.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

また、discarddiscarded_atTime.currentで更新しているのみなので、関連レコードが論理削除の対象の場合だったとしても、そのレコードは論理削除されません。

参考: Working with associations

解決方法

上記を解決するために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で論理削除を実装したいという方の役に立てれば嬉しいです。

一緒にスタートアップを盛り上げませんか?
Rails6.1から入ったActiveStorageのPublicAccessが良かった
2021/03/18