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 | アーカイブ戦略の検討 | 低 | 高 |
ソフトデリートは「保険」のようなものです。普段は意識しないかもしれませんが、いざという時に必ずあなたとユーザーを救ってくれるでしょう。特に本番環境でのデータ復旧要求は、エンジニアにとって大きなストレスとなりがちですが、適切なソフトデリート実装があれば、自信を持って対応できるはずです。
プロジェクトの要件や規模に応じて、今回ご紹介した実装パターンを組み合わせながら、最適なソフトデリート戦略を構築してください。最初は基本的な実装から始めて、必要に応じて高度な機能を追加していくアプローチがおすすめです。
あなたのプロジェクトでも、安心してデータを管理できるソフトデリート機能を実装していただけると幸いです。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来