seansie's blog

捨棄 Middleware!用 Prisma Client Extensions 完美實作軟刪除(Soft Delete)

在 Prisma 中,官方推薦使用 Prisma Client Extensions$extends)來取代已棄用的 Middleware。透過擴充 query 物件,我們可以攔截特定 Model 的 CRUD 操作,將 delete / deleteMany 轉為 update,並在查詢(find*countaggregate)時自動注入 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