テックキャンプ卒の弱々エンジニア日記

エンジニアとして働く中での学びをちょっとでも記録していきます。

【Rails】論理削除の歴史を読み解きながらDiscardの内部実装まで見てみた件

こんにちは、Tochiです。 Railsの論理削除といえば、1社目ではdeletedカラムを追加してupdate処理で管理していました。 しかし世の中にはDiscardなる論理削除を簡単に管理できるgemがあると知りました。その中で、Discardができた背景がとても面白かったので歴史を紐解きながら内部実装を見ていきたいなと思います。

そもそも論理削除って??

「論理削除」とは、あるアイテムを即座に完全に削除する物理削除と異なり、 アイテムを保留場所に移動し削除されているように見せるアプローチを取るものです。 保留場所に移動したアイテムは、物理削除まえに一定期間保持され、手動または自動で削除されるものです。 (もちろん、永続的に保持しておくことも可能です)

論理削除であれば実際にはデータベースから削除されることはなく、復元も可能です。 メールのゴミ箱なども、いわゆる論理削除にあたります

論理削除はどのように実装される?

一般的なアプローチとしては、deleted_at/discarded_atのタイムスタンプ・カラムを個別に追跡します。論理削除されたレコードはそうでないレコードと同じテーブルに保持し、スコープクエリで区別することができます。

user = User.where(deleted_at: nil)

Discardより少し前に登場したParanoia

Discardより少し前の時代には、 Paranoiaというgemが流行していました。 これは、Rails3 ~ 5用のact_as_paranoidを再実装したもので、内部実装が整備されたものらしいです。

github.com

しかし、このParanoiaには問題がありました。 それは、既存メソッド(deleteメソッド / destroyメソッド)を上書きしてしまうのです。

実際に見てみます。

Githubの該当箇所

  def paranoia_delete
    raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
    if persisted?
      # if a transaction exists, add the record so that after_commit
      # callbacks can be run
      add_to_transaction
      update_columns(paranoia_destroy_attributes)
    elsif !frozen?
      assign_attributes(paranoia_destroy_attributes)
    end
    self
  end
  alias_method :delete, :paranoia_delete
  1. レコードが読み取り専用であれば、ActiveRecord::ReadOnlyRecord 例外が発生します。
  2. レコードがデータベースに保存されているものであれば、削除用の属性を使って update_columns メソッドが呼ばれます。(論理削除フラグとなるカラムを更新)
  3. レコードがデータベースに保存されていない場合かつ凍結されていない場合、assign_attributes メソッドが呼ばれ、論理削除用の属性がレコードに割り当てられます。
  4. 最後に self を返してメソッドが終了します。
  5. alias_method :delete, :paranoia_delete の一行でエイリアスをつけています。

5の処理で、deleteメソッドにエイリアスが付けられています。

またデフォルトスコープが変わってきます。 User.allとしたときに、発行されるSQLは以下の通りになります。

  SELECT `users`.* FROM `users` WHERE (`users`.`deleted_at` IS NULL)

論理削除されたものは暗黙的に取得しなくなります。 このような実装について、Discardの作者はというと、、、、

・ I've worked with and have helped maintain paranoia for a while. I'm convinced it does the wrong thing for most cases.

・These overrides of the default ActiveRecord behaviour, while nice and magical on the surface, can and do lead to some rather confusing debugging sessions.

【翻訳】

Paranoiaおよびacts_as_paranoidは、ほとんどのケースで誤った結果を生むと確信しています。

・デフォルトの ActiveRecord 動作のこれらのオーバーライドは、表面的には素晴らしく魔法のように見えますが、かなり混乱するデバッグ セッションを引き起こす可能性があり、実際に発生します。

Discardはどうなん?

Discardは、レコードに破棄済みのフラグをつける規則を追加する単純なAtiveRecordミックスインです。 公式ドキュメントはこちらです。 github.com

どんなふうに使うのか
事前準備

使い方はとても簡単です。 論理削除を施したい対象のモデルにDiscard::Modelをインクルードします。

class Post < ActiveRecord::Base
  include Discard::Model
end

Postモデルに対応するPostsテーブルには以下のようにインデックスとカラムを追加します。

class AddDiscardToPosts < ActiveRecord::Migration[5.0]
  def change
    add_column :posts, :discarded_at, :datetime
    add_index :posts, :discarded_at
  end
end

これで準備は完了です。

論理削除の実施方法

公式ドキュメントにあるこちらのコードを見るとよくわかります。

Post.all             # => [#<Post id: 1, ...>]
Post.kept            # => [#<Post id: 1, ...>]
Post.discarded       # => []

post = Post.first   # => #<Post id: 1, ...>
post.discard        # => true
post.discard!       # => Discard::RecordNotDiscarded: Failed to discard the record
post.discarded?     # => true
post.undiscarded?   # => false
post.kept?          # => false
post.discarded_at   # => 2017-04-18 18:49:49 -0700

Post.all             # => [#<Post id: 1, ...>]
Post.kept            # => []
Post.discarded       # => [#<Post id: 1, ...>]

これだけみてもデフォルトスコープが変わってないことがわかります。 実際に内部実装を見ていきます。

クラスメソッド(kept・undiscarded・discarded・with_discarded...)

Githubの該当箇所

module Discard
  module Model
    extend ActiveSupport::Concern

    included do
      class_attribute :discard_column
      self.discard_column = :discarded_at

      scope :kept, ->{ undiscarded }
      scope :undiscarded, ->{ where(discard_column => nil) }
      scope :discarded, ->{ where.not(discard_column => nil) }
      scope :with_discarded, ->{ unscope(where: discard_column) }

      define_model_callbacks :discard
      define_model_callbacks :undiscard
    end
end

かなりわかりやすいですね。。 シンプルにそれぞれのスコープで、discard_columnをどう扱うかを定義しているだけです。

インスタンスメソッド(discarded・undiscarded)

Githubの該当箇所

def discard
   return false if discarded?
   run_callbacks(:discard) do
     update_attribute(self.class.discard_column, Time.current)
   end
end

Githubの該当箇所

def undiscard
    return false unless discarded?
    run_callbacks(:undiscard) do
     update_attribute(self.class.discard_column, nil)
   end
end

説明不要なくらいシンプルです。

気づき

ParanoiaとDiscardの内部実装を見ていて、先日読んだ「UNIXという考え方」という本に書いてあったことを思い出しました。

www.ohmsha.co.jp

この本はUNIXがなぜ他のOSより優れているかを設計思想の観点から述べた本です。その中にこんなことが書いてありました。

  • 小さなプログラムはわかりやすい
  • 小さなプログラムは保守しやすい
  • 小さなプログラムは組み合わせしやすい

今回、ParanoiaとDiscardを比較していたときにDiscardのシンプルさには少し衝撃を覚えました。同じようなことをしようとしても、ここまで内部実装の美しさやシンプルさが変わるのかと、、、

本で読んでも「まあ、小さい方がいいわな」と思っていましたが 実際に当事者になると、この設計思想がいかに重要でありがたいことかを見にしみて感じました。。。

まとめ

内部実装見るのは結構勉強なりました!!!