今回はRailsで論理削除機能を簡単に実装できるGem、discrardの使い方について簡単にまとめてみました。論理削除はDBから実際に削除するわけではないため、ユーザーの退会処理などデータの復元の可能性がある場合に有効です。
論理削除とは
論理削除とは、削除されたというフラグを対象データに付与し、あたかも削除されたかのような振る舞いをする削除方法です。論理削除は実際にはDBから削除されるわけではないため、データを保管したり、誤って削除してしまったデータの復元が可能になります。
論理削除と対照的な削除として、物理削除があります。物理削除はDBからそのままデータを削除することになります。destoryやdeleteメソッドが物理削除に該当します。
Gem「discard」とは
discardは論理削除を簡単に実装することができるGemです。
Soft deletes for ActiveRecord done right. A simple ActiveRecord mixin to add conventions for flagging records as discarded.
参照: discard
Gem「discard」の使い方
簡易的なアプリケーションを作成しながらdiscardの使い方について見ていきます。
アプリケーションの作成
% rails new discard-app -d mysql
% rails db:create
今回のアプリケーションの概要は以下になります。
- アプリケーション名: discard-app
- Ruby: 2.6.5
- Rails: 6.0.4.1
- DB: MySQL
discardの導入
gem 'discard', '~> 1.2'
% bundle install
Postモデルの作成
% rails g model post title:string
% rails db:migrate
Postモデルにdiscarded_atカラムを追加
Postモデルに論理削除された日時が保存される、discarded_atカラムをdatetime型で追加します。その後、マイグレートを実行しましょう。
% rails generate migration add_discarded_at_to_posts discarded_at:datetime:index
% rails db:migrate
作成されたマイグレーションファイル
class AddDiscardedAtToPosts < ActiveRecord::Migration[6.0]
def change
add_column :posts, :discarded_at, :datetime
add_index :posts, :discarded_at
end
end
マイグレート後のスキーマファイル
ActiveRecord::Schema.define(version: 2021_10_03_103659) do
create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", force: :cascade do |t|
t.string "title"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.datetime "discarded_at"
t.index ["discarded_at"], name: "index_posts_on_discarded_at"
end
end
precisionオプションの追加
先ほど作成したdiscarded_atカラムにprecisionオプションを付与します。
class AddDiscardedAtToPosts < ActiveRecord::Migration[6.0]
def change
add_column :posts, :discarded_at, :datetime, precision: 6
add_index :posts, :discarded_at
end
end
% rails db:migrate:reset

マイグレート後のスキーマファイル
ActiveRecord::Schema.define(version: 2021_10_03_104454) do
create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", force: :cascade do |t|
t.string "title"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.datetime "discarded_at", precision: 6
t.index ["discarded_at"], name: "index_posts_on_discarded_at"
end
end
seed_fuを用いたデータ投入
続いてseed_fuを用いてPostモデルにデータを投入します。
gem 'seed-fu'
% bundle install
% mkdir db/fixtures
% mkdir db/fixtures/development
% touch db/fixtures/development/01_post.rb
# db/fixtures/development/01_post.rb
Post.seed do |s|
s.id = 1
s.title = 'おはよう'
end
Post.seed do |s|
s.id = 2
s.title = 'こんにちは'
end
Post.seed do |s|
s.id = 3
s.title = 'こんばんは'
end
Post.seed do |s|
s.id = 4
s.title = 'おやすみなさい'
end
% rails db:seed_fu

Postモデルにdiscardできることを宣言
次にPostモデルにdiscardできることを宣言します。
class Post < ApplicationRecord
include Discard::Model
end
コンソール上で論理削除とその復元
ここまででdiscardを用いた論理削除が可能になりました。
試しにコンソール上で論理削除を実行してみます。
% rails c
seed_fuで作成したデータが4件あることを確認
> Post.all.count
# => 4
論理削除実行
> Post.first.discard
# => true
discarded_atに論理削除された日時が保存される
> Post.first
# => #<Post id: 1, title: "おはよう", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 06:31:54", discarded_at: "2021-10-05 06:31:54">
論理削除された日時
> Post.first.discarded_at
# => Tue, 05 Oct 2021 06:31:54 UTC +00:00
論理削除が実行されたか確認
> Post.first.discarded?
# => true
論理削除されたレコード一覧を返す
> Post.discarded
# => #<ActiveRecord::Relation [#<Post id: 1, title: "おはよう", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 06:31:54", discarded_at: "2021-10-05 06:31:54">]>
実際にDBから削除されたわけではないので4が返る
> Post.all.count
# => 4
既に論理削除済みのためfalseが返る
> Post.first.discard
# => false
論理削除失敗時に例外を発生
Post.first.discard!
# => Discard::RecordNotDiscarded (Failed to discard the record)
上記で実行した論理削除に関するメソッド
- 論理削除を実行: discard
- 論理削除が実行されたかをtrueもしくはfalseで返す: discarded?
- 論理削除されたレコード一覧を返す: discarded
- 論理削除の失敗時に例外を発生: discard!
続いて先ほど論理削除したレコード(Post.first)を復元してみます。
論理削除されたレコードを復元
> Post.first.undiscard
# => true
論理削除されていないかチェック
> Post.first.undiscarded?
# => true
復元済みのためnilが返る
> Post.first.undiscard
# => nil
復元失敗時に例外を発生
> Post.first.undiscard!
# => Discard::RecordNotUndiscarded (Failed to undiscard the record)
論理削除されていないレコードを一覧で返す
> Post.undiscarded
# => #<ActiveRecord::Relation [#<Post id: 1, title: "おはよう", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 07:46:23", discarded_at: nil>, #<Post id: 2, title: "こんにちは", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 06:09:48", discarded_at: nil>, #<Post id: 3, title: "こんばんは", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 06:09:48", discarded_at: nil>, #<Post id: 4, title: "おやすみなさい", created_at: "2021-10-05 06:09:48", updated_at: "2021-10-05 06:09:48", discarded_at: nil>]>
上記で実行した論理削除に関するメソッド
- 論理削除されたレコードを復元: undiscard
- 復元されたかをtrueもしくはfalseで返す: undiscarded?
- 復元失敗時に例外を発生: undiscard!
- 論理削除されていないレコード一覧を返す: undiscarded
コントローラー上で論理削除とその復元
先ほどはコンソール上で論理削除とその復元方法を試しました。
コントローラー上で論理削除とその復元を実行する際は以下のように記述します。
class PostsController < ApplicationController
def update
@post.undiscard
redirect_to users_url, notice: "Post undiscarded"
end
def destroy
@post.discard
redirect_to users_url, notice: "Post removed"
end
end
ここまでがGem「discard」の基本的な使い方になります。
論理削除されたデータを返さない
Post.allでは論理削除されたレコードも返すことを前項で確認しました。
> Post.all.count
# => 4
> Post.first.discard
# => true
> Post.all.count
# => 4
しかし実際には論理削除されたものは返したくないことが大半です。その場合、モデルにdefault_scopeを追加することで解決できます。
class Post < ApplicationRecord
include Discard::Model
default_scope -> { kept }
end
> Post.all.count
# => 4
> Post.first.discard
# => true
論理削除されたものを返さない
> Post.all.count
# => 3
default_scopeを定義し、論理削除されたものも全て取得したい場合、以下のようにwith_discardedをチェーンさせることで取得が可能になります。
> Post.with_discarded.count
# => 4
default_scopeを不用意に使うとメンテ性が悪くなってしまう恐れがあります。使用する際は、十分に検討してから導入しましょう。
まとめ
- 論理削除とは、削除されたというフラグを対象データに付与し、あたかも削除されたかのような振る舞いをする削除方法のこと
- 物理削除とは、DBからそのままデータを削除すること
- discardは論理削除を簡単に実装することができるGem
- 論理削除を実行するモデルには、discarded_atカラムをdatetime型で追加する
- 論理削除されたものを返さない場合、モデルにdefault_scopeを追加する
参考
今回はRailsで論理削除機能を簡単に実装できるGem、discrardの使い方について簡単にまとめてみました。ユーザーの退会処理など物理処理ではなく論理削除がふさわしい場合があるため、そういった場合は導入を検討してみてください。