捨棄 Middleware!用 Prisma Client Extensions 完美實作軟刪除(Soft Delete)
在 Prisma 中,官方推薦使用 Prisma Client Extensions($extends)來取代已棄用的 Middleware。透過擴充 query 物件,我們可以攔截特定 Model 的 CRUD 操作,將 delete / deleteMany 轉為 update,並在查詢(find*、count、aggregate)時自動注入 deletedAt: null 的過濾條件。
1. 定義 Schema
在需要軟刪除的 Model 中加入 deletedAt 欄位(可為 DateTime? 或 Boolean,推薦使用 DateTime? 以記錄刪除時間戳記):
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
deletedAt DateTime? // 軟刪除時間戳記
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deletedAt]) // 建立索引以加速全域過濾查詢
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
author User @relation(fields: [authorId], references: [id])
deletedAt DateTime?
createdAt DateTime @default(now())
@@index([deletedAt])
}
2. 撰寫軟刪除 Extension
建立一個泛用或針對特定 Model 的 Extension 模組 softDeleteExtension.ts:
// softDeleteExtension.ts
import { Prisma } from '@prisma/client';
export const softDeleteExtension = Prisma.defineExtension({
name: 'softDelete',
query: {
// 針對 User Model 進行攔截
user: {
// 1. 攔截 delete:改寫為 update 並寫入 deletedAt
async delete({ args, query }) {
return (query as any)({
...args,
data: { deletedAt: new Date() },
operation: 'update',
});
},
// 2. 攔截 deleteMany:改寫為 updateMany
async deleteMany({ args, query }) {
return (query as any)({
...args,
data: { deletedAt: new Date() },
operation: 'updateMany',
});
},
// 3. 攔截 findUnique / findUniqueOrThrow
// 由於 findUnique 僅允許唯一鍵,若需過濾 deletedAt 需轉為 findFirst
async findUnique({ args, query }) {
return (query as any)({
where: {
...args.where,
deletedAt: null,
},
operation: 'findFirst',
});
},
async findUniqueOrThrow({ args, query }) {
return (query as any)({
where: {
...args.where,
deletedAt: null,
},
operation: 'findFirstOrThrow',
});
},
// 4. 攔截 findFirst
async findFirst({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
// 5. 攔截 findMany
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
// 6. 攔截 count / aggregate
async count({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async aggregate({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
// 可擴充自訂方法(例如物理刪除、恢復資料)
model: {
user: {
async hardDelete(where: Prisma.UserWhereUniqueInput) {
const prisma = Prisma.getExtensionContext(this);
// 使用 $executeRaw 或底層 client 進行真實物理刪除
return (prisma as any).$queryRaw`DELETE FROM "User" WHERE id = ${where.id}`;
},
async restore(where: Prisma.UserWhereUniqueInput) {
const prisma = Prisma.getExtensionContext(this);
return (prisma as any).update({
where,
data: { deletedAt: null },
});
},
},
},
});
3. 封裝 Prisma Client 與型別導出
使用 $extends 套用擴充,並推導擴充後的 Client 型別:
// db.ts
import { PrismaClient } from '@prisma/client';
import { softDeleteExtension } from './softDeleteExtension';
const basePrisma = new PrismaClient();
// 套用 Extension
export const prisma = basePrisma.$extends(softDeleteExtension);
// 導出包含 Extension 方法的 Client 型別
export type ExtendedPrismaClient = typeof prisma;
4. 實際使用與操作範例
範例:一般 CRUD 與自動過濾
// 1. 軟刪除操作(底層執行 UPDATE "User" SET "deletedAt" = NOW() WHERE id = 1)
await prisma.user.delete({
where: { id: 1 },
});
// 2. 查詢全部(自動注入 WHERE "deletedAt" IS NULL,查不到 id: 1)
const activeUsers = await prisma.user.findMany();
// 3. 查詢單筆(自動過濾軟刪除資料,回傳 null)
const user = await prisma.user.findUnique({
where: { id: 1 },
});
console.log(user); // null
// 4. 統計筆數(自動排除已軟刪除資料)
const count = await prisma.user.count();
範例:特殊情境(查詢回收站 / 恢復資料)
若有管理後台需要查詢「已軟刪除」或「所有包含已刪除」的資料,可提供不套用 Extension 的原始 Client,或在 Extension 中加入專屬自訂函式:
// 情況 A:使用擴充模型方法進行恢復
await prisma.user.restore({ id: 1 });
// 情況 B:查詢回收站(需查詢已刪除項目時,直接覆寫條件或使用原始 client)
const deletedUsers = await prisma.user.findMany({
where: {
deletedAt: { not: null }, // 若有顯式指定,可在 extension 判斷不覆蓋
},
});
5. 注意事項與限制
| 注意事項 | 說明 |
|---|---|
| Unique 欄位衝突 | 若 email 設有 @unique,軟刪除後同一個 Email 再次註冊會觸發 Unique Constraint 錯誤。解決方案通常為:使用複合唯一鍵(如 @@unique([email, deletedAt])),或在刪除時將 Email 加上後綴(如 user@example.com_deleted_1690000000)。 |
關聯載入(include) |
findMany 內的 include: { posts: true } 預設不會自動攔截巢狀 Post 的 deletedAt。若 Post 也需要軟刪除,需在 Extension 的 post 物件中同樣配置攔截規則。 |
| Raw SQL 繞過 | $queryRaw 或 $executeRaw 不會走 Model Query 攔截器,需自行手動撰寫 WHERE deleted_at IS NULL。 |