中介軟體示例:軟刪除
以下示例使用 中介軟體 執行**軟刪除**。軟刪除意味著透過將 `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 刪除記錄
您可以在資料庫層面建立規則或觸發器(MySQL 和 PostgreSQL),以防止記錄被刪除。
步驟 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。