T-CREATOR

Prisma で行うソフトデリート実装パターン

Prisma で行うソフトデリート実装パターン

開発現場で「削除したデータを復元してほしい」というリクエストを受けたことはありませんか?システム運用において、データの誤削除は避けて通れない問題の一つです。今回は、Prismaを使ったソフトデリート(論理削除)の実装パターンについて、基本から応用まで段階的にご紹介いたします。

実際のプロジェクトで遭遇するであろう様々なシナリオを想定し、すぐに活用できる実装例をお伝えしますので、ぜひ最後までお読みください。

背景

データベースでの削除方式の違い

データベースにおける削除方式には、大きく分けて2つのアプローチがあります。

#削除方式概要データの状態
1物理削除(Hard Delete)レコードを実際に削除するデータベースから完全に消去
2論理削除(Soft Delete)削除フラグを立てて見た目上削除データは残存、削除マークのみ付与

物理削除は、データを完全にデータベースから取り除く方式です。一度削除されたデータは二度と復元できません。一方、論理削除はデータ自体は残しておき、削除されたことを示すフラグやタイムスタンプを付与する方式になります。

Prismaでのソフトデリートの重要性

現代のWebアプリケーション開発において、Prismaは多くの開発者に愛用されているORMです。その理由は、型安全性とシンプルな構文にありますが、標準でソフトデリート機能は提供されていません。

しかし、実際のビジネス要件では以下のようなケースが頻繁に発生します:

  • ユーザーが誤操作でデータを削除してしまった場合の復旧対応
  • 法的要件によるデータ保持義務
  • 監査ログとしてのデータ履歴保持
  • データ分析における過去データの参照

課題

削除されたデータの復元要求への対応

システム運用において、最も心臓に悪い瞬間の一つが「削除したデータを復元してください」という依頼を受けた時ではないでしょうか。物理削除を採用している場合、データベースのバックアップから復元する以外に選択肢がありません。

この作業には以下のようなリスクと工数が伴います:

#課題影響度対応難易度
1サービス停止時間の発生困難
2他のデータへの影響リスク困難
3復元作業の技術的複雑さ普通
4作業コストと時間普通

データ整合性を保ちながらの削除フラグ管理

ソフトデリートを実装する際に直面する技術的な課題があります。単純にdeletedフラグを追加するだけでは、以下のような問題が発生する可能性があります:

javascript// 問題のあるクエリ例
const activeUsers = await prisma.user.findMany({
  where: {
    deleted: false  // この条件を忘れがち
  }
})

上記のように、すべてのクエリで削除条件を明示的に指定する必要があり、開発者が条件を書き忘れるリスクが常に存在します。

パフォーマンスへの影響を最小限に抑える方法

ソフトデリートを導入すると、テーブル内のレコード数が物理削除と比較して増加し続けます。削除されたデータも含めて検索対象となるため、以下のパフォーマンス課題が発生しがちです:

  • インデックス効率の低下
  • クエリ実行時間の増加
  • ストレージ使用量の増大

特に大規模なサービスでは、これらの課題が深刻な問題となることもあるでしょう。

解決策

Prismaでのソフトデリート実装アプローチ

これらの課題を解決するため、Prismaでは複数のソフトデリート実装パターンが提案されています。各アプローチにはそれぞれメリット・デメリットがあるため、プロジェクトの要件に応じて適切な手法を選択することが重要です。

deletedAtフィールドを使った基本実装

最も一般的なアプローチは、削除日時を記録するdeletedAtフィールドを使用する方法です。このフィールドがnullの場合は有効なレコード、日時が入っている場合は削除済みレコードとして扱います。

typescriptmodel User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  deletedAt DateTime? // null = 有効, 日時あり = 削除済み
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

このアプローチの利点は、削除日時の記録により詳細な監査ログが残せることです。

isDeletedフラグを使った実装

シンプルなブール値フラグを使用するアプローチもあります。この方法は理解しやすく、クエリも直感的に書けるメリットがあります。

typescriptmodel Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String
  isDeleted Boolean @default(false) // true = 削除済み
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

複合的なアプローチ

より高度な要件に対応するため、削除フラグと削除日時の両方を組み合わせるアプローチもあります。これにより、パフォーマンスと詳細ログの両方を実現できます。

typescriptmodel Order {
  id        Int       @id @default(autoincrement())
  userId    Int
  amount    Decimal
  isDeleted Boolean   @default(false)    // インデックス用
  deletedAt DateTime?                     // ログ用
  deletedBy Int?                         // 削除者の記録
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  
  user User @relation(fields: [userId], references: [id])
  
  @@index([isDeleted]) // パフォーマンス向上のため
}

具体例

基本的なソフトデリート実装

スキーマ定義とマイグレーション

まず、ユーザー管理システムを例に、deletedAtフィールドを使った基本的なソフトデリートを実装してみましょう。以下のスキーマから始めます:

prisma// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  deletedAt DateTime? // ソフトデリート用フィールド
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  
  posts Post[]       // ユーザーの投稿
  
  @@map("users")
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String?
  userId    Int
  deletedAt DateTime? // ソフトデリート用フィールド
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  
  user User @relation(fields: [userId], references: [id])
  
  @@map("posts")
}

上記のスキーマでは、UserとPostの両方にdeletedAtフィールドを追加しています。このフィールドがnullの場合は有効なレコード、日時が設定されている場合は削除済みレコードとして扱います。

CRUD操作の実装

次に、ソフトデリートに対応したCRUD操作を実装していきます。まずは基本的なユーザー操作から始めましょう:

typescript// lib/userService.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export class UserService {
  // 有効なユーザーの一覧取得
  static async getActiveUsers() {
    return await prisma.user.findMany({
      where: {
        deletedAt: null // 削除されていないユーザーのみ
      },
      select: {
        id: true,
        email: true,
        name: true,
        createdAt: true,
        updatedAt: true
      }
    })
  }

  // 特定ユーザーの取得(削除チェック付き)
  static async getUserById(id: number) {
    const user = await prisma.user.findFirst({
      where: {
        id,
        deletedAt: null
      }
    })
    
    if (!user) {
      throw new Error(`User with ID ${id} not found or has been deleted`)
    }
    
    return user
  }

  // ユーザーの新規作成
  static async createUser(email: string, name?: string) {
    try {
      return await prisma.user.create({
        data: {
          email,
          name
        }
      })
    } catch (error) {
      if (error.code === 'P2002') {
        throw new Error('Email already exists')
      }
      throw error
    }
  }
}

上記のコードでは、すべての取得系メソッドでdeletedAt: nullの条件を追加することで、削除されていないユーザーのみを対象としています。エラーハンドリングも適切に行い、削除されたユーザーにアクセスしようとした場合の対応も含めています。

ソフトデリートの実装

続いて、実際の削除処理とデータ復元機能を実装します:

typescript// lib/userService.ts(続き)
export class UserService {
  // ... 前のメソッドは省略

  // ユーザーのソフトデリート
  static async softDeleteUser(id: number, deletedBy?: number) {
    // 削除前にユーザーが存在し、かつ削除されていないことを確認
    const existingUser = await this.getUserById(id)
    
    const deletedUser = await prisma.user.update({
      where: { id },
      data: {
        deletedAt: new Date()
      }
    })
    
    // 関連する投稿も一緒にソフトデリート
    await prisma.post.updateMany({
      where: {
        userId: id,
        deletedAt: null
      },
      data: {
        deletedAt: new Date()
      }
    })
    
    return deletedUser
  }

  // ユーザーの復元
  static async restoreUser(id: number) {
    // 削除されたユーザーを検索
    const deletedUser = await prisma.user.findFirst({
      where: {
        id,
        deletedAt: { not: null }
      }
    })
    
    if (!deletedUser) {
      throw new Error(`Deleted user with ID ${id} not found`)
    }
    
    const restoredUser = await prisma.user.update({
      where: { id },
      data: {
        deletedAt: null
      }
    })
    
    // 関連する投稿も一緒に復元
    await prisma.post.updateMany({
      where: {
        userId: id,
        deletedAt: { not: null }
      },
      data: {
        deletedAt: null
      }
    })
    
    return restoredUser
  }

  // 物理削除(管理者用)
  static async hardDeleteUser(id: number) {
    // トランザクション内で関連データも含めて削除
    return await prisma.$transaction(async (tx) => {
      // 関連する投稿を先に削除
      await tx.post.deleteMany({
        where: { userId: id }
      })
      
      // ユーザーを削除
      return await tx.user.delete({
        where: { id }
      })
    })
  }
}

このように実装することで、ユーザーを削除する際に関連する投稿も自動的にソフトデリートされ、復元時には投稿も一緒に復元されるカスケード機能を実現できます。

中間テーブルでのソフトデリート

リレーション含むソフトデリート

多対多の関係や複雑なリレーションを持つ場合のソフトデリート実装を見てみましょう。ここでは、ユーザーとプロジェクトの多対多関係を例に説明します:

prisma// 拡張されたスキーマ例
model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  deletedAt DateTime?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  
  // 多対多リレーション(中間テーブル経由)
  userProjects UserProject[]
  
  @@map("users")
}

model Project {
  id          Int       @id @default(autoincrement())
  name        String
  description String?
  deletedAt   DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  
  // 多対多リレーション(中間テーブル経由)
  userProjects UserProject[]
  
  @@map("projects")
}

model UserProject {
  id        Int       @id @default(autoincrement())
  userId    Int
  projectId Int
  role      String    @default("member") // member, admin, etc.
  deletedAt DateTime? // 中間テーブルでもソフトデリート
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  
  user    User    @relation(fields: [userId], references: [id])
  project Project @relation(fields: [projectId], references: [id])
  
  // 同じユーザーが同じプロジェクトに重複参加しないように
  @@unique([userId, projectId])
  @@map("user_projects")
}

上記のスキーマでは、中間テーブルUserProjectにもソフトデリート機能を追加しています。これにより、ユーザーとプロジェクトの関係も論理削除できるようになります。

複雑なリレーションでの実装例

中間テーブルを含むソフトデリートサービスを実装してみましょう:

typescript// lib/projectService.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export class ProjectService {
  // プロジェクトの有効メンバー取得
  static async getActiveProjectMembers(projectId: number) {
    return await prisma.userProject.findMany({
      where: {
        projectId,
        deletedAt: null,
        // ユーザーとプロジェクトも削除されていないものに限定
        user: { deletedAt: null },
        project: { deletedAt: null }
      },
      include: {
        user: {
          select: {
            id: true,
            email: true,
            name: true
          }
        }
      }
    })
  }

  // ユーザーのプロジェクト参加
  static async addUserToProject(
    userId: number, 
    projectId: number, 
    role: string = 'member'
  ) {
    // 既存の関係をチェック(削除済みも含む)
    const existingRelation = await prisma.userProject.findFirst({
      where: {
        userId,
        projectId
      }
    })

    if (existingRelation) {
      if (existingRelation.deletedAt) {
        // 削除済みの関係があれば復元
        return await prisma.userProject.update({
          where: { id: existingRelation.id },
          data: {
            deletedAt: null,
            role,
            updatedAt: new Date()
          }
        })
      } else {
        throw new Error('User is already a member of this project')
      }
    }

    // 新規作成
    return await prisma.userProject.create({
      data: {
        userId,
        projectId,
        role
      }
    })
  }
}

このように実装することで、ユーザーがプロジェクトから脱退した場合でも履歴が保持され、後から復元することが可能になります。

カスケード削除の対応

プロジェクトが削除された場合の関連データの取り扱いを実装します:

typescript// lib/projectService.ts(続き)
export class ProjectService {
  // ... 前のメソッドは省略

  // プロジェクトのソフトデリート(カスケード対応)
  static async softDeleteProject(projectId: number, deletedBy?: number) {
    return await prisma.$transaction(async (tx) => {
      // プロジェクトの存在確認
      const project = await tx.project.findFirst({
        where: {
          id: projectId,
          deletedAt: null
        }
      })

      if (!project) {
        throw new Error(`Project with ID ${projectId} not found or already deleted`)
      }

      // 1. プロジェクトをソフトデリート
      const deletedProject = await tx.project.update({
        where: { id: projectId },
        data: { deletedAt: new Date() }
      })

      // 2. 関連するユーザープロジェクト関係もソフトデリート
      await tx.userProject.updateMany({
        where: {
          projectId,
          deletedAt: null
        },
        data: { deletedAt: new Date() }
      })

      // 3. プロジェクトに関連するタスクもソフトデリート(存在する場合)
      // await tx.task.updateMany({
      //   where: {
      //     projectId,
      //     deletedAt: null
      //   },
      //   data: { deletedAt: new Date() }
      // })

      return deletedProject
    })
  }

  // ユーザーのプロジェクト脱退(個別の関係削除)
  static async removeUserFromProject(userId: number, projectId: number) {
    const userProject = await prisma.userProject.findFirst({
      where: {
        userId,
        projectId,
        deletedAt: null
      }
    })

    if (!userProject) {
      throw new Error('User is not a member of this project')
    }

    return await prisma.userProject.update({
      where: { id: userProject.id },
      data: { deletedAt: new Date() }
    })
  }

  // 削除されたプロジェクトの復元
  static async restoreProject(projectId: number) {
    return await prisma.$transaction(async (tx) => {
      // 削除されたプロジェクトを検索
      const deletedProject = await tx.project.findFirst({
        where: {
          id: projectId,
          deletedAt: { not: null }
        }
      })

      if (!deletedProject) {
        throw new Error(`Deleted project with ID ${projectId} not found`)
      }

      // プロジェクトを復元
      const restoredProject = await tx.project.update({
        where: { id: projectId },
        data: { deletedAt: null }
      })

      // 関連するユーザープロジェクト関係も復元
      await tx.userProject.updateMany({
        where: {
          projectId,
          deletedAt: { not: null }
        },
        data: { deletedAt: null }
      })

      return restoredProject
    })
  }
}

この実装により、プロジェクトの削除時に関連するすべてのデータが適切にソフトデリートされ、復元時には整合性を保ったまま復元されます。

高度な実装パターン

ミドルウェアを使った自動化

毎回deletedAt: nullの条件を書くのは面倒であり、忘れやすいものです。Prismaのミドルウェア機能を使って、この処理を自動化してみましょう:

typescript// lib/prismaMiddleware.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

// ソフトデリート用ミドルウェア
prisma.$use(async (params, next) => {
  // 削除処理をソフトデリートに変更
  if (params.action === 'delete') {
    params.action = 'update'
    params.args['data'] = { deletedAt: new Date() }
  }
  
  // 一括削除処理をソフトデリートに変更
  if (params.action === 'deleteMany') {
    params.action = 'updateMany'
    if (params.args.data !== undefined) {
      params.args.data['deletedAt'] = new Date()
    } else {
      params.args['data'] = { deletedAt: new Date() }
    }
  }

  // 検索系クエリに自動でdeleted条件を追加
  if (params.action === 'findUnique' || params.action === 'findFirst') {
    params.args.where['deletedAt'] = null
  }
  
  if (params.action === 'findMany') {
    if (params.args.where) {
      if (!params.args.where.deletedAt) {
        params.args.where['deletedAt'] = null
      }
    } else {
      params.args['where'] = { deletedAt: null }
    }
  }

  // カウント系クエリにも適用
  if (params.action === 'count') {
    if (params.args.where) {
      if (!params.args.where.deletedAt) {
        params.args.where['deletedAt'] = null
      }
    } else {
      params.args['where'] = { deletedAt: null }
    }
  }

  return next(params)
})

export { prisma }

このミドルウェアを使うことで、通常のdeleteが自動的にソフトデリートに変換され、検索系のクエリでも削除済みデータが自動的に除外されます。

カスタムクライアント拡張

Prisma 4.7以降では、クライアント拡張機能を使ってより柔軟なソフトデリート機能を実装できます:

typescript// lib/prismaExtensions.ts
import { PrismaClient } from '@prisma/client'

// ソフトデリート用の拡張クライアント
const extendedPrisma = new PrismaClient().$extends({
  name: 'softDelete',
  model: {
    $allModels: {
      // ソフトデリートメソッドの追加
      async softDelete<T>(
        this: T,
        where: Parameters<T['delete']>[0]['where']
      ): Promise<Parameters<T['delete']>[0]> {
        const context = Prisma.getExtensionContext(this)
        
        return (context as any).update({
          where,
          data: {
            deletedAt: new Date()
          }
        })
      },

      // 削除されたレコードのみを取得
      async findManyDeleted<T>(
        this: T,
        args?: Omit<Parameters<T['findMany']>[0], 'where'> & {
          where?: Parameters<T['findMany']>[0]['where']
        }
      ): Promise<Array<Parameters<T['findMany']>[0]>> {
        const context = Prisma.getExtensionContext(this)
        
        return (context as any).findMany({
          ...args,
          where: {
            ...args?.where,
            deletedAt: { not: null }
          }
        })
      },

      // 復元メソッド
      async restore<T>(
        this: T,
        where: Parameters<T['update']>[0]['where']
      ): Promise<Parameters<T['update']>[0]> {
        const context = Prisma.getExtensionContext(this)
        
        return (context as any).update({
          where: {
            ...where,
            deletedAt: { not: null }
          },
          data: {
            deletedAt: null
          }
        })
      },

      // 削除されていないレコードのみを取得(デフォルト動作の上書き)
      async findMany<T>(
        this: T,
        args?: Parameters<T['findMany']>[0]
      ): Promise<Array<Parameters<T['findMany']>[0]>> {
        const context = Prisma.getExtensionContext(this)
        
        return (context as any).findMany({
          ...args,
          where: {
            ...args?.where,
            deletedAt: null
          }
        })
      }
    }
  }
})

export { extendedPrisma as prisma }

使用例:

typescript// app/api/users/route.ts
import { prisma } from '@/lib/prismaExtensions'

export async function DELETE(request: Request) {
  const { searchParams } = new URL(request.url)
  const userId = parseInt(searchParams.get('id') || '0')

  try {
    // カスタムメソッドを使用してソフトデリート
    const deletedUser = await prisma.user.softDelete({
      where: { id: userId }
    })
    
    return Response.json({ 
      message: 'User soft deleted successfully',
      user: deletedUser 
    })
  } catch (error) {
    return Response.json(
      { error: 'Failed to delete user' },
      { status: 500 }
    )
  }
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const includeDeleted = searchParams.get('includeDeleted') === 'true'

  try {
    let users
    
    if (includeDeleted) {
      // 削除されたユーザーのみを取得
      users = await prisma.user.findManyDeleted()
    } else {
      // 通常の検索(削除されていないもののみ)
      users = await prisma.user.findMany()
    }
    
    return Response.json({ users })
  } catch (error) {
    return Response.json(
      { error: 'Failed to fetch users' },
      { status: 500 }
    )
  }
}

パフォーマンス最適化パターン

大量のデータを扱う場合のパフォーマンス最適化も重要です。以下のような戦略を検討しましょう:

typescript// lib/optimizedSoftDelete.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export class OptimizedSoftDeleteService {
  // 部分インデックスを活用した高速検索
  static async getActiveUsersOptimized(limit: number = 100) {
    // スキーマでは @@index([deletedAt]) を設定済み
    return await prisma.user.findMany({
      where: {
        deletedAt: null
      },
      take: limit,
      orderBy: {
        createdAt: 'desc'
      }
    })
  }

  // 削除データの定期的なアーカイブ
  static async archiveOldDeletedRecords(daysOld: number = 90) {
    const cutoffDate = new Date()
    cutoffDate.setDate(cutoffDate.getDate() - daysOld)

    // アーカイブテーブルに移動後、元テーブルから削除
    return await prisma.$transaction(async (tx) => {
      // 1. アーカイブテーブルにコピー
      const oldDeletedUsers = await tx.user.findMany({
        where: {
          deletedAt: {
            lt: cutoffDate,
            not: null
          }
        }
      })

      if (oldDeletedUsers.length > 0) {
        // アーカイブテーブルが存在する場合
        // await tx.userArchive.createMany({
        //   data: oldDeletedUsers.map(user => ({
        //     ...user,
        //     archivedAt: new Date()
        //   }))
        // })

        // 2. 元テーブルから物理削除
        const deletedIds = oldDeletedUsers.map(user => user.id)
        await tx.user.deleteMany({
          where: {
            id: { in: deletedIds }
          }
        })
      }

      return { archivedCount: oldDeletedUsers.length }
    })
  }

  // 削除カウントの効率的な取得
  static async getDeletedCount(tableName: string) {
    // 削除フラグにインデックスが設定されている前提
    const result = await prisma.$queryRaw`
      SELECT COUNT(*) as count 
      FROM ${tableName} 
      WHERE deleted_at IS NOT NULL
    `
    return result[0].count
  }
}

このように、インデックス戦略、アーカイブ機能、効率的なクエリを組み合わせることで、大規模データでも高いパフォーマンスを維持できます。

まとめ

Prismaでのソフトデリート実装は、データ復旧要求への対応やコンプライアンス要件の満たし方として非常に有効な手段です。本記事では、基本的なdeletedAtフィールドの実装から、複雑なリレーションを持つ中間テーブルでの応用、さらには高度なミドルウェアやクライアント拡張機能を使った自動化まで、段階的にご紹介いたしました。

実装時に押さえておきたいポイントをまとめます:

#ポイント重要度実装難易度
1削除フラグのインデックス設定
2リレーションデータのカスケード対応
3ミドルウェアによる自動化
4パフォーマンス最適化
5アーカイブ戦略の検討

ソフトデリートは「保険」のようなものです。普段は意識しないかもしれませんが、いざという時に必ずあなたとユーザーを救ってくれるでしょう。特に本番環境でのデータ復旧要求は、エンジニアにとって大きなストレスとなりがちですが、適切なソフトデリート実装があれば、自信を持って対応できるはずです。

プロジェクトの要件や規模に応じて、今回ご紹介した実装パターンを組み合わせながら、最適なソフトデリート戦略を構築してください。最初は基本的な実装から始めて、必要に応じて高度な機能を追加していくアプローチがおすすめです。

あなたのプロジェクトでも、安心してデータを管理できるソフトデリート機能を実装していただけると幸いです。

関連リンク