おはようございます。投稿する用にとっているメモが溜まってきて少し焦っています・・(笑)
今日は実務で使った論理削除を便利に導入する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を使うのがいいんじゃないかと思いました。では、今回はここまでにしたいと思います。読んでいただきありがとうございました。