中间件示例:软删除
以下示例使用 中间件 执行**软删除**。软删除意味着通过更改一个字段(如 `deleted` 为 `true`)来**标记为已删除**,而不是实际从数据库中删除记录。使用软删除的原因包括
- 法规要求您必须保留数据一段时间
- “回收站”/“垃圾箱”功能,允许用户恢复已删除的内容
注意:此页面演示了中间件的示例用法。我们不打算将此示例作为完全可用的软删除功能,并且它不涵盖所有极端情况。例如,中间件不适用于嵌套写入,因此不会捕获在 `update` 查询中使用 `delete` 或 `deleteMany` 作为选项的情况。
此示例使用以下模式 - 请注意 `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` 的字段。您可以根据您的需求在两种字段类型之间进行选择
-
布尔值,默认值为 `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 } })
注意:你仍然需要编写中间件。
我们✘不推荐这种方法,因为它会用不代表实际数据的字段污染模式。