😇

Discard gem入門 - 論理削除を正しく理解しよう!

に公開

はじめに

Railsを使用してWebアプリケーションを開発していると、「データを削除したいが、完全に削除するのは怖い」というケースはありません?ユーザーが誤って削除ボタンを押してしまった場合や、後で削除したデータを復元する必要が生じた場合など、物理的にデータベースからレコードを削除してしまうと取り返しがつきません。

そこで登場するのが「論理削除」という概念です。本記事では、論理削除の基本概念から、Railsで論理削除を簡単に実装できるDiscard gemの使い方まで、初学者の方にもわかりやすく解説していきます。

論理削除とは何か

物理削除と論理削除の違い

まず、データの削除方法には大きく分けて2つの方法があります。

物理削除(Physical Delete)

# データベースからレコードを完全に削除
user.destroy
# DELETE FROM users WHERE id = 1

論理削除(Logical Delete)

# レコードは残したまま、削除フラグを立てる
user.update(deleted_at: Time.current)
# UPDATE users SET deleted_at = '2025-01-15 10:30:00' WHERE id = 1

なぜ論理削除が必要なのか

論理削除が必要な理由は以下の通りです:

  1. データの復元が可能: 誤って削除されたデータを復元できる
  2. 監査証跡の保持: いつ、誰がデータを削除したかの記録を残せる
  3. 関連データの整合性: 他のテーブルから参照されているデータを安全に「削除」できる
  4. 法的要件への対応: 一定期間データを保持する必要がある場合

論理削除の基本的な実装方法

従来の手動実装

Discard gemを使わない場合、以下のような実装が一般的でした:

class User < ApplicationRecord
  # deleted_flag カラム(boolean)を使用
  scope :active, -> { where(deleted_flag: false) }
  scope :deleted, -> { where(deleted_flag: true) }
  
  def soft_delete
    update(deleted_flag: true)
  end
  
  def restore
    update(deleted_flag: false)
  end
end

# 使用例
User.active.all  # 削除されていないユーザーのみ取得
user.soft_delete # ユーザーを論理削除

従来実装の問題点

  1. 実装の複雑さ: 毎回スコープを意識してクエリを書く必要がある
  2. 忘れやすさ: 削除チェックを忘れて削除済みデータを取得してしまう
  3. 一貫性の欠如: プロジェクト内で実装方法がバラバラになりがち
  4. 削除日時の管理: boolean フラグでは「いつ削除されたか」がわからない

Discard gemの紹介

Discard gemとは

Discard gemは、Railsアプリケーションで論理削除を簡単に実装できるgemです。Active Recordモデルに論理削除機能を追加し、一貫性のある方法で削除されたレコードを管理できます。

インストールと基本設定

Gemfileに追加

gem 'discard', '~> 1.2'

マイグレーションファイルの作成

class AddDeletedAtToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :deleted_at, :datetime
    add_index :users, :deleted_at
  end
end

モデルでの設定

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
end

主要な機能の概要

Discard gemは以下の機能を提供します:

  • 自動的なスコープ生成: keptdiscardedwith_discardedスコープが自動生成される
  • 簡単な削除・復元: discardundiscardメソッドでレコードの削除・復元が可能
  • 柔軟なカラム設定: デフォルトではdeleted_atだが、任意のカラム名を指定可能

Discard gemの基本的な使い方

モデルの設定方法

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  # その他のモデル定義
  validates :name, presence: true
  has_many :posts, dependent: :destroy
end

レコードの削除(discard)

user = User.find(1)

# 論理削除の実行
user.discard
# => UPDATE users SET deleted_at = '2025-01-15 10:30:00' WHERE id = 1

# 削除状態の確認
user.discarded?  # => true
user.kept?       # => false

削除の取り消し(undiscard)

# 論理削除されたユーザーを復元
user.undiscard
# => UPDATE users SET deleted_at = NULL WHERE id = 1

# 復元状態の確認
user.discarded?  # => false
user.kept?       # => true

基本的なスコープの使い方

Discard gemは以下のスコープを自動的に提供します:

# 削除されていないレコードのみ取得
User.kept.all
# => SELECT * FROM users WHERE deleted_at IS NULL

# 削除されたレコードのみ取得
User.discarded.all
# => SELECT * FROM users WHERE deleted_at IS NOT NULL

# 削除状態に関係なく全レコードを取得
User.with_discarded.all
# => SELECT * FROM users

# 削除されたレコードも含めて特定のレコードを検索
User.with_discarded.find(1)

kept スコープとdefault_scopeの理解

kept スコープの役割

keptスコープは、論理削除されていないレコード(deleted_atNULLのレコード)のみを取得するためのスコープです。

# 以下は同じ意味
User.kept.all
User.where(deleted_at: nil).all

default_scope -> { kept } の意味と効果

default_scopeを使用することで、そのモデルに対するすべてのクエリに自動的に条件を適用できます:

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  # すべてのクエリで削除されていないレコードのみを対象とする
  default_scope -> { kept }
end

これにより、以下のような効果が得られます:

# 通常のクエリでも、自動的にdeleted_atがNULLの条件が追加される
User.all
# => SELECT * FROM users WHERE deleted_at IS NULL

User.where(active: true)
# => SELECT * FROM users WHERE deleted_at IS NULL AND active = true

User.find(1)
# => SELECT * FROM users WHERE deleted_at IS NULL AND id = 1

実際のSQLクエリでの動作確認

実際にRailsコンソールで動作を確認してみましょう:

# モデルの定義
class Product < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  default_scope -> { kept }
end

# 商品を作成
product1 = Product.create(name: "商品A", price: 1000)
product2 = Product.create(name: "商品B", price: 2000)

# 1つの商品を論理削除
product1.discard

# 通常のクエリ(削除されていない商品のみ取得)
Product.all.to_sql
# => "SELECT \"products\".* FROM \"products\" WHERE \"products\".\"deleted_at\" IS NULL"

# 削除された商品も含めて取得
Product.with_discarded.all.to_sql
# => "SELECT \"products\".* FROM \"products\""

実践的な使い方とベストプラクティス

関連モデルとの連携

論理削除を使用する際は、関連モデルとの整合性も考慮する必要があります:

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  has_many :posts, dependent: :destroy
  has_many :comments, dependent: :destroy
end

class Post < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  belongs_to :user
  has_many :comments, dependent: :destroy
  
  # ユーザーが削除されていない投稿のみを取得するスコープ
  scope :from_active_users, -> { joins(:user).merge(User.kept) }
end

バリデーションとの組み合わせ

論理削除されたレコードに対してもバリデーションが実行されることがあります:

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  validates :email, presence: true, uniqueness: true
  
  # 削除されたユーザーのemailは一意性制約から除外
  validates :email, uniqueness: { conditions: -> { kept } }
end

パフォーマンスの考慮事項

論理削除を使用する際のパフォーマンス最適化:

# deleted_atカラムにインデックスを追加
class AddIndexToDeletedAt < ActiveRecord::Migration[7.0]
  def change
    add_index :users, :deleted_at
    # 複合インデックスも考慮
    add_index :users, [:deleted_at, :created_at]
  end
end

# 大量のデータがある場合は、削除されたレコードを定期的にアーカイブ
class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  
  # 一定期間経過した削除レコードを物理削除
  scope :old_discarded, -> { discarded.where('deleted_at < ?', 1.year.ago) }
end

注意点とよくあるトラブル

default_scopeの副作用

default_scopeは便利ですが、予期しない動作を引き起こすことがあります:

class User < ApplicationRecord
  include Discard::Model
  self.discard_column = :deleted_at
  default_scope -> { kept }
end

# 以下のクエリでも、削除されていないユーザーのみがカウントされる
User.count  # 削除されたユーザーは含まれない

# 全ユーザー数を取得したい場合
User.with_discarded.count  # 削除されたユーザーも含む

削除されたレコードが必要な場合の対処法

削除されたレコードにアクセスする必要がある場合:

# 削除されたユーザーを含めて検索
user = User.with_discarded.find(params[:id])

# 削除されたユーザーのデータを表示する場合の条件分岐
if user.discarded?
  render :show_deleted_user
else
  render :show_user
end

テストでの注意点

テストコードでも論理削除を考慮する必要があります:

RSpec.describe User, type: :model do
  describe '#discard' do
    let(:user) { create(:user) }
    
    it 'ユーザーが論理削除されること' do
      expect { user.discard }.to change { user.reload.discarded? }.from(false).to(true)
    end
    
    it '論理削除されたユーザーは通常のクエリで取得されないこと' do
      user.discard
      expect(User.all).not_to include(user)
      expect(User.with_discarded.all).to include(user)
    end
  end
end

まとめ

Discard gemを使うメリット

  1. 簡単な実装: 数行のコードで論理削除機能を追加できる
  2. 一貫性: プロジェクト全体で統一された論理削除の実装が可能
  3. 豊富な機能: スコープ、メソッドが自動的に提供される
  4. 保守性: 標準的な実装により、コードの保守が容易

導入時の検討事項

  1. パフォーマンス: 大量のデータがある場合はインデックス設計を検討
  2. データ設計: 削除されたデータの保持期間やアーカイブ戦略を決定
  3. 既存システムとの互換性: 既存の削除ロジックとの整合性を確認
  4. チーム内での共有: 論理削除の概念と実装をチーム全体で理解

Discard gemを使用することで、安全で保守しやすい論理削除機能をRailsアプリケーションに簡単に導入できます。適切に活用して、より堅牢なアプリケーションを構築しましょう。

Discussion