🗑️

Railsでスマートな論理削除を実現する「discard」

に公開

はじめに

Webアプリケーションを開発していると、「データを削除したいけど、完全には消したくない」という要求に遭遇することがあります。例えば、ユーザーが投稿を削除しても、管理者は確認できるようにしたい、あるいは誤削除からの復旧を可能にしたいといったケースです。

このような要求を満たすのが「論理削除(ソフトデリート)」です。今回は、Railsで論理削除を実装するための優れたgem「discard」について、実践的な使い方を詳しく解説します。

なぜdiscardなのか?

論理削除を実装するgemとしては、長らく「paranoia」が使われてきました。しかし、paranoiaには以下のような問題があります:

paranoiaの問題点

  1. デフォルトスコープの弊害

    • すべてのクエリに自動的に WHERE deleted_at IS NULL が追加される
    • 削除済みレコードを取得したい場合は明示的に .with_deleted を指定する必要がある
  2. ActiveRecordメソッドのオーバーライド

    • destroydelete メソッドが上書きされる
    • 本当に削除したい場合は really_destroy を使う必要がある
  3. 依存レコードの扱いが複雑

    • dependent: :destroy で関連レコードまで論理削除される
    • 復元時の処理が複雑になる

discardのアプローチ

discardは、これらの問題を解決するために「シンプルで明示的」なアプローチを採用しています:

  • ActiveRecordのメソッドをオーバーライドしない
  • デフォルトスコープを使わない
  • 明示的なメソッド名(discardundiscard)を使用
  • 依存レコードの扱いを柔軟に制御可能

インストール

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