🗑️
Railsでスマートな論理削除を実現する「discard」
はじめに
Webアプリケーションを開発していると、「データを削除したいけど、完全には消したくない」という要求に遭遇することがあります。例えば、ユーザーが投稿を削除しても、管理者は確認できるようにしたい、あるいは誤削除からの復旧を可能にしたいといったケースです。
このような要求を満たすのが「論理削除(ソフトデリート)」です。今回は、Railsで論理削除を実装するための優れたgem「discard」について、実践的な使い方を詳しく解説します。
なぜdiscardなのか?
論理削除を実装するgemとしては、長らく「paranoia」が使われてきました。しかし、paranoiaには以下のような問題があります:
paranoiaの問題点
-
デフォルトスコープの弊害
- すべてのクエリに自動的に
WHERE deleted_at IS NULLが追加される - 削除済みレコードを取得したい場合は明示的に
.with_deletedを指定する必要がある
- すべてのクエリに自動的に
-
ActiveRecordメソッドのオーバーライド
-
destroyとdeleteメソッドが上書きされる - 本当に削除したい場合は
really_destroyを使う必要がある
-
-
依存レコードの扱いが複雑
-
dependent: :destroyで関連レコードまで論理削除される - 復元時の処理が複雑になる
-
discardのアプローチ
discardは、これらの問題を解決するために「シンプルで明示的」なアプローチを採用しています:
- ActiveRecordのメソッドをオーバーライドしない
- デフォルトスコープを使わない
- 明示的なメソッド名(
discard、undiscard)を使用 - 依存レコードの扱いを柔軟に制御可能
インストール
Gemfileに追加:
gem 'discard', '~> 1.4'
その後、bundleを実行:
bundle install
基本的な使い方
1. マイグレーションの作成
論理削除用のカラムを追加します:
rails generate migration add_discarded_at_to_posts discarded_at:datetime:index
または手動でマイグレーションを作成:
class AddDiscardToPosts < ActiveRecord::Migration[7.0]
def change
add_column :posts, :discarded_at, :datetime
add_index :posts, :discarded_at
end
end
2. モデルの設定
モデルに Discard::Model をincludeします:
class Post < ApplicationRecord
include Discard::Model
has_many :comments
belongs_to :user
end
3. 基本的な操作
# レコードの論理削除
post = Post.find(1)
post.discard # => true
post.discarded? # => true
post.kept? # => false
post.discarded_at # => 2025-01-10 10:00:00 +0900
# レコードの復元
post.undiscard # => true
post.discarded? # => false
post.kept? # => true
post.discarded_at # => nil
# スコープの使用
Post.all # すべてのレコード(削除済みも含む)
Post.kept # 削除されていないレコードのみ
Post.discarded # 削除済みレコードのみ
実践的な使用例
1. コントローラーでの使用
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy, :restore]
def index
# 通常は削除されていないもののみ表示
@posts = Post.kept.includes(:user)
end
def destroy
@post.discard
redirect_to posts_path, notice: '投稿を削除しました'
end
def restore
@post.undiscard
redirect_to post_path(@post), notice: '投稿を復元しました'
end
def trash
# ゴミ箱ページ(削除済み一覧)
@posts = Post.discarded.includes(:user)
end
private
def set_post
# 管理者は削除済みも含めて検索
scope = current_user.admin? ? Post : Post.kept
@post = scope.find(params[:id])
end
end
2. 関連レコードの扱い
パターン1: 独立した論理削除
投稿とコメントをそれぞれ独立して論理削除する場合:
class Comment < ApplicationRecord
include Discard::Model
belongs_to :post
# それぞれ独立してkeptスコープを持つ
end
# 使用例
Comment.kept # 削除されていないコメント(削除済み投稿のコメントも含む)
Post.kept # 削除されていない投稿
パターン2: 親レコードに依存した論理削除
投稿が削除されたらコメントも非表示にする場合:
class Comment < ApplicationRecord
include Discard::Model
belongs_to :post
# 投稿が削除されていないコメントのみを取得
scope :kept, -> {
undiscarded.joins(:post).merge(Post.kept)
}
# コメントが表示可能かどうか
def kept?
undiscarded? && post.kept?
end
end
# 使用例
Comment.kept # 削除されていない かつ 投稿も削除されていないコメント
3. コールバックの活用
class Post < ApplicationRecord
include Discard::Model
has_many :comments
has_many :likes
# 削除時のコールバック
after_discard do
# 関連するコメントも論理削除
comments.discard_all
# いいねは物理削除
likes.destroy_all
# 通知を送信
NotificationMailer.post_deleted(self).deliver_later
end
# 復元時のコールバック
after_undiscard do
# コメントも復元
comments.undiscard_all
# 通知を送信
NotificationMailer.post_restored(self).deliver_later
end
end
4. Deviseとの統合
ユーザーアカウントの無効化に使用する場合:
class User < ApplicationRecord
devise :database_authenticatable, :registerable
include Discard::Model
# 論理削除されたユーザーはログインできないようにする
def active_for_authentication?
super && !discarded?
end
# エラーメッセージのカスタマイズ
def inactive_message
discarded? ? :discarded : super
end
end
localeファイル:
ja:
devise:
failure:
discarded: 'このアカウントは無効化されています。'
パフォーマンス考慮事項
1. バルク操作
# 推奨: パフォーマンスを重視する場合
Post.where(user_id: user.id).update_all(discarded_at: Time.current)
# 非推奨: コールバックが必要な場合のみ使用
Post.where(user_id: user.id).discard_all # 各レコードに対してコールバック実行
2. インデックスの活用
class AddIndexesToPosts < ActiveRecord::Migration[7.0]
def change
# 論理削除カラムのインデックス
add_index :posts, :discarded_at
# 複合インデックス(よく使うクエリパターンに応じて)
add_index :posts, [:user_id, :discarded_at]
add_index :posts, [:created_at, :discarded_at]
end
end
カスタマイズ
カラム名の変更
既存のシステムから移行する場合など、カラム名を変更したい場合:
class Post < ApplicationRecord
include Discard::Model
# deleted_at カラムを使用する場合
self.discard_column = :deleted_at
end
デフォルトスコープの追加(非推奨)
どうしても必要な場合のみ:
class Post < ApplicationRecord
include Discard::Model
# 注意: paranoiaと同じ問題が発生する可能性があります
default_scope -> { kept }
end
# 削除済みを含めて取得する場合
Post.with_discarded # または Post.unscoped
ベストプラクティス
1. 明示的なスコープ使用
# Good: 意図が明確
@posts = Post.kept.recent
# Bad: 削除済みが含まれるか不明確
@posts = Post.recent
2. 権限に応じた表示制御
class PostsController < ApplicationController
def index
@posts = posts_scope.includes(:user, :tags)
end
private
def posts_scope
case
when current_user&.admin?
# 管理者: すべて表示
Post.all
when current_user
# ログインユーザー: 自分の削除済みも表示
Post.kept.or(Post.discarded.where(user: current_user))
else
# ゲスト: 削除されていないもののみ
Post.kept.published
end
end
end
3. テストの書き方
RSpec.describe Post, type: :model do
describe '#discard' do
let(:post) { create(:post) }
it '論理削除される' do
expect {
post.discard
}.to change { post.discarded? }.from(false).to(true)
end
it 'discarded_atが設定される' do
freeze_time do
post.discard
expect(post.discarded_at).to eq(Time.current)
end
end
it 'keptスコープから除外される' do
post.discard
expect(Post.kept).not_to include(post)
expect(Post.discarded).to include(post)
end
end
end
まとめ
discardは、Railsで論理削除を実装するための優れた選択肢です。paranoiaの複雑さを避け、シンプルで明示的なAPIを提供することで、より保守性の高いコードを書くことができます。
主なポイント:
-
明示的なAPI:
discard/undiscardという分かりやすいメソッド名 - 柔軟な設計: デフォルトスコープを使わず、必要に応じてカスタマイズ可能
- パフォーマンス: SQLのJOINを活用した効率的なクエリ
- シンプル: ActiveRecordの標準的な動作を変更しない
論理削除の実装を検討している場合は、ぜひdiscardの採用を検討してみてください。
Discussion