Ruby on Rails

【Rails】Discardを使った論理削除の実装

おはようございます。投稿する用にとっているメモが溜まってきて少し焦っています・・(笑)
今日は実務で使った論理削除を便利に導入するDiscardというGemについて紹介していこうと思います。

論理削除とは

論理削除に対する用語として物理削除というものがあり、分かりやすいためまずこちらを解説します。物理削除とはデータベースから該当するデータを削除することです。そのため、一度削除したデータを復元することは基本的にできません。一方、論理削除とは文字通り論理的にデータを削除することで、データベースのカラムに削除フラグを立てることでそれを実現します。削除フラグは書き換えることができるので、一度削除をしても元に戻すことができるというメリットがあります。しかし、バグを生みやすかったり、データが膨大になる(論理削除されたデータが多い場合、物理削除に比べてその分のデータを読み込む必要がある)といったデメリットがあります。

物理削除はデータベースからデータを削除

論理削除は削除フラグを立てることにより、論理的にデータを削除(データは削除されない

論理削除の実装

Railsで論理削除を実装しようとすると、論理削除用のメソッドを自作する必要がありけっこう大変です。そのため、今回は論理削除用によく使われるGemであるDiscardというGemを紹介していこうと思います。

いつものようにデモアプリをつくってその挙動を確認していこうと思います。今回はメモを投稿することができて、それに対してコメントすることができるようなアプリをデモとして作ります。

$ rails new discard-demo --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable
$ rails g scaffold post content
$ rails g scaffold comment comment post_id:integer:index
$ rails db:create
$ rails db:migrate

それではGemfileにdiscardを入れてbundle installをしましょう。

gem 'discard', '~> 1.2'

論理削除を実装したいモデルで(今回の例ではPostモデル)以下のように書き、モジュールを使えるようにします。

class Post < ApplicationRecord
  include Discard::Model # 追記
  
  has_many :comments
end

次に削除フラグをつけるためのカラムを追加します。

$ rails g migration add_discarded_at_to_posts discarded_at:datetime:index

すると、下記マイグレーションファイルが追加されるので、rails db:migrateを実行します。

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

discardのGemのデフォルトでは削除フラグにdiscard_atという名前のカラムを使います。論理削除された際にはこのカラムに削除を実行した日付が入ります。このカラムに値が含まれていると、railsアプリでは削除されたとします。

では、実際にデータを投入していきます。コンソールでデータを作ると時間がかかるので、seedファイルで一気につくってしまいます。

10.times do |n|
  Post.create!(content: "記事")
end

では、コンソールで確認をしてみます。

Post.all.count
=> 10
Post.all
=> [#<Post:0x00007fca0d8ab108
  id: 1,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
  discarded_at: nil>,
 #<Post:0x00007fca0dcce708
  id: 2,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  discarded_at: nil>,
# 略

記事が10件取得できています。Discard::Modelをインクルードしているので、keptメソッドが使うことができます。このメソッドでは、削除されていない記事の一覧を取得することができます。またその逆のメソッドがdiscardedで、削除された記事の一覧を取得することができます。

Post.kept
  Post Load (1.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."discarded_at" IS NULL
=> [#<Post:0x00007fca0ded0b00
  id: 1,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
  discarded_at: nil>,
 #<Post:0x00007fca0ded0880
  id: 2,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  discarded_at: nil>,
# 略

Post.discarded
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."discarded_at" IS NOT NULL
=> []

その他にもさまざまなメソッドが提供されています。

post = Post.first
=> #<Post:0x00007fca13011708
 id: 1,
 content: "記事",
 created_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
 updated_at: Thu, 08 Apr 2021 02:09:58.030777000 UTC +00:00,
 discarded_at: nil>

# discardメソッド => 投稿を論理削除する
post.discard
  TRANSACTION (0.4ms)  begin transaction
  Post Update (0.9ms)  UPDATE "posts" SET "updated_at" = ?, "discarded_at" = ? WHERE "posts"."id" = ?  [["updated_at", "2021-04-08 02:22:59.875487"], ["discarded_at", "2021-04-08 02:22:59.874025"], ["id", 1]]
  TRANSACTION (1.3ms)  commit transaction
=> true

# discarded?メソッド => 論理削除されたか検証
post.discarded?
=> true

# undiscardedメソッド => 論理削除されたデータを復元
post.undiscard
  TRANSACTION (1.0ms)  begin transaction
  Post Update (2.0ms)  UPDATE "posts" SET "updated_at" = ?, "discarded_at" = ? WHERE "posts"."id" = ?  [["updated_at", "2021-04-08 02:37:49.228705"], ["discarded_at", nil], ["id", 1]]
  TRANSACTION (1.7ms)  commit transaction
=> true

post.discarded_at
=> nil

post.discarded?
=> false

一覧表示させる場合、デフォルトで論理削除したデータを表示させなくするのには、default scopeを用います。ただ、アプリケーションが大きくなってくると予期せぬバグを生むのであまり推奨はされてません。

class Post < ApplicationRecord
  include Discard::Model
  default_scope -> { kept } # 追記

  has_many :comments
end

コンソールでの確認結果です。

post = Post.first
post.discard
Post.all.count
=> 9
Post.all
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."discarded_at" IS NULL
=> [#<Post:0x00007fca0dd78820
  id: 2,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.039055000 UTC +00:00,
  discarded_at: nil>,
 #<Post:0x00007fca0dd78578
  id: 3,
  content: "記事",
  created_at: Thu, 08 Apr 2021 02:09:58.049069000 UTC +00:00,
  updated_at: Thu, 08 Apr 2021 02:09:58.049069000 UTC +00:00,
  discarded_at: nil>,
# 略

default scopeを設定している状態ですべての投稿を取得するには、以下のメソッドを使います。

# with_discardedメソッド => 論理削除された投稿も含めて全投稿を取得する
Post.with_discarded.count
=> 10

# with_discarded.discarded => 論理削除された投稿のみを取得する
Post.with_discarded.discarded.count
=> 0

論理削除のアソシエーション

次に投稿と紐付いたコメントの取得についても行っていきます。コメントを取得する際、コメント自体は論理削除されていないが、親である投稿が論理削除されていた場合取得しないようにしたいことがあるかと思います。それもDiscardでは簡単に実装できるのでそれを書いていきます。

まずはcommentsテーブルにdiscarded_atカラムを追加します。

$ rails g migration add_discarded_at_to_comments discarded_at:datetime:index

次に以下の一行を追加することで、論理削除された投稿に紐づくコメントを取得しないようにできます。

class Comment < ApplicationRecord
  belongs_to :post

  include Discard::Model
  scope :kept, -> { undiscarded.joins(:post).merge(Post.kept) }
end

それぞれで発行されるSQLを見てみます。

# 単純なINNER JOIN
Comment.joins(:post).to_sql
=> "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"posts\" ON \"posts\".\"id\" = \"comments\".\"post_id\""

# 論理削除された投稿を親に持つコメントも取得する
Comment.joins(:post).undiscarded.to_sql
"SELECT \"comments\".* FROM \"comments\" INNER JOIN \"posts\" ON \"posts\".\"id\" = \"comments\".\"post_id\" WHERE \"comments\".\"discarded_at\" IS NULL"

# 論理削除された投稿を親に持つコメントは取得しない
Comment.joins(:post).merge(Post.kept).undiscarded.to_sql
=> "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"posts\" ON \"posts\".\"id\" = \"comments\".\"post_id\" WHERE \"posts\".\"discarded_at\" IS NULL AND \"comments\".\"discarded_at\" IS NULL"

# undiscardedメソッドをどこで使っても発行されるSQLは同じ
Comment.undiscarded.joins(:post).merge(Post.kept).to_sql
=> "SELECT \"comments\".* FROM \"comments\" INNER JOIN \"posts\" ON \"posts\".\"id\" = \"comments\".\"post_id\" WHERE \"comments\".\"discarded_at\" IS NULL AND \"posts\".\"discarded_at\" IS NULL"

merge(Post.kept)でWHERE文にposts.discarded_at IS NULLの条件を足している挙動のようです。よって、Commentモデルにkeptを使うと以下のようなSQLが発行されます。

Comment.kept
# SELECT * FROM comments
#    INNER JOIN posts ON comments.post_id = posts.id
# WHERE
#    comments.discarded_at IS NULL AND
#       posts.discarded_at IS NULL

GemのReadMeでは、コメントが論理削除されているか確かめる(親の投稿が論理削除されていた場合も削除とみなす)メソッドも紹介されています。

def kept?
  undiscarded? && post.kept?
end

コンソールで確かめてみます。

comment = Comment.first
comment.kept?
=> true

さいごに

以上、論理削除についてでした。
自前で論理削除を実装するのは大変なので、色々な便利なメソッドが揃っているこのGemを使うのがいいんじゃないかと思いました。では、今回はここまでにしたいと思います。読んでいただきありがとうございました。

ABOUT ME
sakai
東京在住の30歳。元々は車部品メーカーで働いていてましたが、プログラミングに興味を持ちスクールに通ってエンジニアになりました。 そこからベンチャー → メガベンチャー → 個人事業主になりました。 最近は生成 AI 関連の業務を中心にやっています。 ヒカルチャンネル(Youtube)とワンピースが大好きです!