跳到主要內容

中介軟體示例:軟刪除

以下示例使用 中介軟體 執行**軟刪除**。軟刪除意味著透過將 `deleted` 等欄位更改為 `true` 來將記錄**標記為已刪除**,而不是實際從資料庫中移除。使用軟刪除的原因包括:

  • 法規要求您必須在一定時間內保留資料
  • “垃圾箱”/“回收站”功能,允許使用者恢復已刪除的內容
警告

**注意**:本頁面演示了中介軟體的示例用法。我們不打算讓此示例成為一個完全功能的軟刪除特性,它也不涵蓋所有極端情況。例如,該中介軟體不適用於巢狀寫入,因此無法捕獲您在 `update` 查詢中將 `delete` 或 `deleteMany` 作為選項使用的情況。

此示例使用以下 schema - 請注意 `Post` 模型上的 `deleted` 欄位

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

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
name String?
email String @unique
posts Post[]
followers User[] @relation("UserToUser")
user User? @relation("UserToUser", fields: [userId], references: [id])
userId Int?
}

model Post {
id Int @id @default(autoincrement())
title String
content String?
user User? @relation(fields: [userId], references: [id])
userId Int?
tags Tag[]
views Int @default(0)
deleted Boolean @default(false)
}

model Category {
id Int @id @default(autoincrement())
parentCategory Category? @relation("CategoryToCategory", fields: [categoryId], references: [id])
category Category[] @relation("CategoryToCategory")
categoryId Int?
}

model Tag {
tagName String @id // Must be unique
posts Post[]
}

步驟 1:儲存記錄狀態

向 `Post` 模型新增一個名為 `deleted` 的欄位。您可以根據您的需求選擇兩種欄位型別:

  • `Boolean` 型別,預設值為 `false`

    model Post {
    id Int @id @default(autoincrement())
    ...
    deleted Boolean @default(false)
    }
  • 建立一個可為空的 `DateTime` 欄位,以便您確切知道記錄何時被標記為刪除 - `NULL` 表示記錄尚未被刪除。在某些情況下,儲存記錄刪除時間可能是法規要求。

    model Post {
    id Int @id @default(autoincrement())
    ...
    deleted DateTime?
    }

**注意**:使用兩個單獨的欄位(`isDeleted` 和 `deletedDate`)可能導致這兩個欄位不同步 - 例如,記錄可能被標記為已刪除但沒有關聯的日期。

為簡單起見,本示例使用 `Boolean` 欄位型別。

步驟 2:軟刪除中介軟體

新增一個執行以下任務的中介軟體

  • 攔截 `Post` 模型的 `delete()` 和 `deleteMany()` 查詢
  • 將 `params.action` 分別更改為 `update` 和 `updateMany`
  • 引入 `data` 引數並設定 `{ deleted: true }`,同時保留其他過濾器引數(如果存在)

執行以下示例以測試軟刪除中介軟體

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient({})

async function main() {
/***********************************/
/* SOFT DELETE MIDDLEWARE */
/***********************************/

prisma.$use(async (params, next) => {
// Check incoming query type
if (params.model == 'Post') {
if (params.action == 'delete') {
// Delete queries
// Change action to an update
params.action = 'update'
params.args['data'] = { deleted: true }
}
if (params.action == 'deleteMany') {
// Delete many queries
params.action = 'updateMany'
if (params.args.data != undefined) {
params.args.data['deleted'] = true
} else {
params.args['data'] = { deleted: true }
}
}
}
return next(params)
})

/***********************************/
/* TEST */
/***********************************/

const titles = [
{ title: 'How to create soft delete middleware' },
{ title: 'How to install Prisma' },
{ title: 'How to update a record' },
]

console.log('\u001b[1;34mSTARTING SOFT DELETE TEST \u001b[0m')
console.log('\u001b[1;34m#################################### \u001b[0m')

let i = 0
let posts = new Array()

// Create 3 new posts with a randomly assigned title each time
for (i == 0; i < 3; i++) {
const createPostOperation = prisma.post.create({
data: titles[Math.floor(Math.random() * titles.length)],
})
posts.push(createPostOperation)
}

var postsCreated = await prisma.$transaction(posts)

console.log(
'Posts created with IDs: ' +
'\u001b[1;32m' +
postsCreated.map((x) => x.id) +
'\u001b[0m'
)

// Delete the first post from the array
const deletePost = await prisma.post.delete({
where: {
id: postsCreated[0].id, // Random ID
},
})

// Delete the 2nd two posts
const deleteManyPosts = await prisma.post.deleteMany({
where: {
id: {
in: [postsCreated[1].id, postsCreated[2].id],
},
},
})

const getPosts = await prisma.post.findMany({
where: {
id: {
in: postsCreated.map((x) => x.id),
},
},
})

console.log()

console.log(
'Deleted post with ID: ' + '\u001b[1;32m' + deletePost.id + '\u001b[0m'
)
console.log(
'Deleted posts with IDs: ' +
'\u001b[1;32m' +
[postsCreated[1].id + ',' + postsCreated[2].id] +
'\u001b[0m'
)
console.log()
console.log(
'Are the posts still available?: ' +
(getPosts.length == 3
? '\u001b[1;32m' + 'Yes!' + '\u001b[0m'
: '\u001b[1;31m' + 'No!' + '\u001b[0m')
)
console.log()
console.log('\u001b[1;34m#################################### \u001b[0m')
// 4. Count ALL posts
const f = await prisma.post.findMany({})
console.log('Number of posts: ' + '\u001b[1;32m' + f.length + '\u001b[0m')

// 5. Count DELETED posts
const r = await prisma.post.findMany({
where: {
deleted: true,
},
})
console.log(
'Number of SOFT deleted posts: ' + '\u001b[1;32m' + r.length + '\u001b[0m'
)
}

main()

示例輸出如下

STARTING SOFT DELETE TEST
####################################
Posts created with IDs: 587,588,589

Deleted post with ID: 587
Deleted posts with IDs: 588,589

Are the posts still available?: Yes!

####################################
提示

註釋掉中介軟體以檢視訊息變化。

✔ 這種軟刪除方法的優點包括

  • 軟刪除發生在資料訪問層,這意味著除非您使用原始 SQL,否則無法刪除記錄

✘ 這種軟刪除方法的缺點包括

  • 除非您明確透過 `where: { deleted: false }` 進行過濾,否則內容仍可被讀取和更新 - 在具有大量查詢的大型專案中,存在軟刪除內容仍會被顯示的風險
  • 您仍然可以使用原始 SQL 刪除記錄
提示

您可以在資料庫層面建立規則或觸發器(MySQLPostgreSQL),以防止記錄被刪除。

步驟 3:可選地防止讀取/更新軟刪除記錄

在步驟 2 中,我們實現了防止 `Post` 記錄被刪除的中介軟體。然而,您仍然可以讀取和更新已刪除的記錄。本步驟探討了兩種防止讀取和更新已刪除記錄的方法。

**注意**:這些選項只是帶有優缺點的一些想法,您可以選擇完全不同的做法。

選項 1:在您自己的應用程式程式碼中實現過濾器

在此選項中

  • Prisma Client 中介軟體負責防止記錄被刪除
  • 您自己的應用程式程式碼(可以是 GraphQL API、REST API、模組)負責在讀取和更新資料時,根據需要過濾掉已刪除的帖子(`{ where: { deleted: false } }`)——例如,`getPost` GraphQL 解析器從不返回已刪除的帖子

✔ 這種軟刪除方法的優點包括

  • Prisma Client 的建立/更新查詢沒有變化 - 如果您需要已刪除的記錄,可以輕鬆請求它們
  • 在中介軟體中修改查詢可能會產生一些意想不到的後果,例如更改查詢返回型別(參見選項 2)

✘ 這種軟刪除方法的缺點包括

  • 與軟刪除相關的邏輯維護在兩個不同的地方
  • 如果您的 API 介面非常龐大且由多個貢獻者維護,可能難以強制執行某些業務規則(例如,從不允許更新已刪除的記錄)

選項 2:使用中介軟體確定已刪除記錄的讀取/更新查詢行為

選項二使用 Prisma Client 中介軟體來阻止軟刪除的記錄被返回。下表描述了中介軟體如何影響每個查詢

查詢中介軟體邏輯返回型別變更
`findUnique()`🔧 將查詢更改為 `findFirst`(因為不能將 `deleted: false` 過濾器應用於 `findUnique()`)
🔧 新增 `where: { deleted: false }` 過濾器以排除軟刪除的帖子
🔧 從 5.0.0 版本開始,您可以使用 `findUnique()` 應用 `delete: false` 過濾器,因為非唯一欄位已公開
無變更
`findMany`🔧 預設新增 `where: { deleted: false }` 過濾器以排除軟刪除的帖子
🔧 允許開發者透過指定 `deleted: true` 來**明確請求**軟刪除的帖子
無變更
`update`🔧 將查詢更改為 `updateMany`(因為不能將 `deleted: false` 過濾器應用於 `update`)
🔧 新增 `where: { deleted: false }` 過濾器以排除軟刪除的帖子
`{ count: n }` 而不是 `Post`
`updateMany`🔧 新增 `where: { deleted: false }` 過濾器以排除軟刪除的帖子無變更
  • 無法將軟刪除與 `findFirstOrThrow()` 或 `findUniqueOrThrow()` 結合使用嗎?
    5.1.0 版本開始,您可以透過使用中介軟體來對 `findFirstOrThrow()` 或 `findUniqueOrThrow()` 應用軟刪除。
  • 為什麼允許 `findMany()` 使用 `{ where: { deleted: true } }` 過濾器,而不允許 `updateMany()` 使用?
    這個特定的示例旨在支援使用者可以恢復其已刪除部落格文章(這需要一個軟刪除文章列表)的場景 - 但使用者不應能夠編輯已刪除文章。
  • 我仍然可以 `connect` 或 `connectOrCreate` 一個已刪除的帖子嗎?
    在此示例中 - 可以。中介軟體不會阻止您將一個現有的、軟刪除的帖子連線到使用者。

執行以下示例以檢視中介軟體如何影響每個查詢

import { PrismaClient, Prisma } from '@prisma/client'

const prisma = new PrismaClient({})

async function main() {
/***********************************/
/* SOFT DELETE MIDDLEWARE */
/***********************************/

prisma.$use(async (params, next) => {
if (params.model == 'Post') {
if (params.action === 'findUnique' || params.action === 'findFirst') {
// Change to findFirst - you cannot filter
// by anything except ID / unique with findUnique()
params.action = 'findFirst'
// Add 'deleted' filter
// ID filter maintained
params.args.where['deleted'] = false
}
if (
params.action === 'findFirstOrThrow' ||
params.action === 'findUniqueOrThrow'
) {
if (params.args.where) {
if (params.args.where.deleted == undefined) {
// Exclude deleted records if they have not been explicitly requested
params.args.where['deleted'] = false
}
} else {
params.args['where'] = { deleted: false }
}
}
if (params.action === 'findMany') {
// Find many queries
if (params.args.where) {
if (params.args.where.deleted == undefined) {
params.args.where['deleted'] = false
}
} else {
params.args['where'] = { deleted: false }
}
}
}
return next(params)
})

prisma.$use(async (params, next) => {
if (params.model == 'Post') {
if (params.action == 'update') {
// Change to updateMany - you cannot filter
// by anything except ID / unique with findUnique()
params.action = 'updateMany'
// Add 'deleted' filter
// ID filter maintained
params.args.where['deleted'] = false
}
if (params.action == 'updateMany') {
if (params.args.where != undefined) {
params.args.where['deleted'] = false
} else {
params.args['where'] = { deleted: false }
}
}
}
return next(params)
})

prisma.$use(async (params, next) => {
// Check incoming query type
if (params.model == 'Post') {
if (params.action == 'delete') {
// Delete queries
// Change action to an update
params.action = 'update'
params.args['data'] = { deleted: true }
}
if (params.action == 'deleteMany') {
// Delete many queries
params.action = 'updateMany'
if (params.args.data != undefined) {
params.args.data['deleted'] = true
} else {
params.args['data'] = { deleted: true }
}
}
}
return next(params)
})

/***********************************/
/* TEST */
/***********************************/

const titles = [
{ title: 'How to create soft delete middleware' },
{ title: 'How to install Prisma' },
{ title: 'How to update a record' },
]

console.log('\u001b[1;34mSTARTING SOFT DELETE TEST \u001b[0m')
console.log('\u001b[1;34m#################################### \u001b[0m')

let i = 0
let posts = new Array()

// Create 3 new posts with a randomly assigned title each time
for (i == 0; i < 3; i++) {
const createPostOperation = prisma.post.create({
data: titles[Math.floor(Math.random() * titles.length)],
})
posts.push(createPostOperation)
}

var postsCreated = await prisma.$transaction(posts)

console.log(
'Posts created with IDs: ' +
'\u001b[1;32m' +
postsCreated.map((x) => x.id) +
'\u001b[0m'
)

// Delete the first post from the array
const deletePost = await prisma.post.delete({
where: {
id: postsCreated[0].id, // Random ID
},
})

// Delete the 2nd two posts
const deleteManyPosts = await prisma.post.deleteMany({
where: {
id: {
in: [postsCreated[1].id, postsCreated[2].id],
},
},
})

const getOnePost = await prisma.post.findUnique({
where: {
id: postsCreated[0].id,
},
})

const getOneUniquePostOrThrow = async () =>
await prisma.post.findUniqueOrThrow({
where: {
id: postsCreated[0].id,
},
})

const getOneFirstPostOrThrow = async () =>
await prisma.post.findFirstOrThrow({
where: {
id: postsCreated[0].id,
},
})

const getPosts = await prisma.post.findMany({
where: {
id: {
in: postsCreated.map((x) => x.id),
},
},
})

const getPostsAnDeletedPosts = await prisma.post.findMany({
where: {
id: {
in: postsCreated.map((x) => x.id),
},
deleted: true,
},
})

const updatePost = await prisma.post.update({
where: {
id: postsCreated[1].id,
},
data: {
title: 'This is an updated title (update)',
},
})

const updateManyDeletedPosts = await prisma.post.updateMany({
where: {
deleted: true,
id: {
in: postsCreated.map((x) => x.id),
},
},
data: {
title: 'This is an updated title (updateMany)',
},
})

console.log()

console.log(
'Deleted post (delete) with ID: ' +
'\u001b[1;32m' +
deletePost.id +
'\u001b[0m'
)
console.log(
'Deleted posts (deleteMany) with IDs: ' +
'\u001b[1;32m' +
[postsCreated[1].id + ',' + postsCreated[2].id] +
'\u001b[0m'
)
console.log()
console.log(
'findUnique: ' +
(getOnePost?.id != undefined
? '\u001b[1;32m' + 'Posts returned!' + '\u001b[0m'
: '\u001b[1;31m' +
'Post not returned!' +
'(Value is: ' +
JSON.stringify(getOnePost) +
')' +
'\u001b[0m')
)
try {
console.log('findUniqueOrThrow: ')
await getOneUniquePostOrThrow()
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code == 'P2025'
)
console.log(
'\u001b[1;31m' +
'PrismaClientKnownRequestError is catched' +
'(Error name: ' +
error.name +
')' +
'\u001b[0m'
)
}
try {
console.log('findFirstOrThrow: ')
await getOneFirstPostOrThrow()
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code == 'P2025'
)
console.log(
'\u001b[1;31m' +
'PrismaClientKnownRequestError is catched' +
'(Error name: ' +
error.name +
')' +
'\u001b[0m'
)
}
console.log()
console.log(
'findMany: ' +
(getPosts.length == 3
? '\u001b[1;32m' + 'Posts returned!' + '\u001b[0m'
: '\u001b[1;31m' + 'Posts not returned!' + '\u001b[0m')
)
console.log(
'findMany ( delete: true ): ' +
(getPostsAnDeletedPosts.length == 3
? '\u001b[1;32m' + 'Posts returned!' + '\u001b[0m'
: '\u001b[1;31m' + 'Posts not returned!' + '\u001b[0m')
)
console.log()
console.log(
'update: ' +
(updatePost.id != undefined
? '\u001b[1;32m' + 'Post updated!' + '\u001b[0m'
: '\u001b[1;31m' +
'Post not updated!' +
'(Value is: ' +
JSON.stringify(updatePost) +
')' +
'\u001b[0m')
)
console.log(
'updateMany ( delete: true ): ' +
(updateManyDeletedPosts.count == 3
? '\u001b[1;32m' + 'Posts updated!' + '\u001b[0m'
: '\u001b[1;31m' + 'Posts not updated!' + '\u001b[0m')
)
console.log()
console.log('\u001b[1;34m#################################### \u001b[0m')
// 4. Count ALL posts
const f = await prisma.post.findMany({})
console.log(
'Number of active posts: ' + '\u001b[1;32m' + f.length + '\u001b[0m'
)

// 5. Count DELETED posts
const r = await prisma.post.findMany({
where: {
deleted: true,
},
})
console.log(
'Number of SOFT deleted posts: ' + '\u001b[1;32m' + r.length + '\u001b[0m'
)
}

main()

示例輸出如下

STARTING SOFT DELETE TEST
####################################
Posts created with IDs: 680,681,682

Deleted post (delete) with ID: 680
Deleted posts (deleteMany) with IDs: 681,682

findUnique: Post not returned!(Value is: [])
findMany: Posts not returned!
findMany ( delete: true ): Posts returned!

update: Post not updated!(Value is: {"count":0})
updateMany ( delete: true ): Posts not updated!

####################################
Number of active posts: 0
Number of SOFT deleted posts: 95

✔ 此方法的優點

  • 開發者可以有意識地選擇在 `findMany` 中包含已刪除的記錄
  • 您不會意外讀取或更新已刪除的記錄

✖ 此方法的缺點

  • 從 API 看不出您沒有獲取所有記錄,以及 `{ where: { deleted: false } }` 是預設查詢的一部分
  • 返回型別 `update` 受影響,因為中介軟體將查詢更改為 `updateMany`
  • 不處理包含 `AND`、`OR`、`every` 等的複雜查詢...
  • 在使用其他模型的 `include` 時不處理過濾。

常見問題

我可以向 `Post` 模型新增一個全域性 `includeDeleted` 嗎?

您可能想透過向 `Post` 模型新增 `includeDeleted` 屬性來“修改”您的 API,並使以下查詢成為可能

prisma.post.findMany({ where: { includeDeleted: true } })

**注意**:您仍然需要編寫中介軟體。

我們**✘ 不建議**採用這種方法,因為它會用不代表真實資料的欄位汙染 schema。

© . This site is unofficial and not affiliated with Prisma Data, Inc.