跳到主要內容

Middleware 範例:軟刪除

以下範例使用 middleware 來執行軟刪除。軟刪除是指透過變更欄位 (例如將 deleted 變更為 true) 來將記錄標記為已刪除,而不是實際從資料庫中移除。使用軟刪除的原因包括

  • 法規要求您必須將資料保留一段時間
  • 「垃圾桶」/「資源回收筒」功能,讓使用者可以還原已刪除的內容
警告

注意: 此頁面示範 middleware 的範例用法。我們不打算將此範例作為功能完整的軟刪除功能,並且它未涵蓋所有邊緣案例。例如,middleware 不適用於巢狀寫入,因此不會捕捉到您在 update 查詢中使用 deletedeleteMany 作為選項的情況。

此範例使用以下 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:儲存記錄狀態

將名為 deleted 的欄位新增至 Post 模型。您可以根據您的需求在兩種欄位類型之間選擇

  • Boolean,預設值為 false

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

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

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

此範例為了簡化起見,使用 Boolean 欄位類型。

步驟 2:軟刪除 middleware

新增執行以下任務的 middleware

  • 攔截 Post 模型的 delete()deleteMany() 查詢
  • params.action 分別變更為 updateupdateMany
  • 引入 data 引數並設定 { deleted: true },如果存在其他篩選引數則保留

執行以下範例來測試軟刪除 middleware

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!

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

註解掉 middleware 以查看訊息變更。

✔ 此軟刪除方法的優點包括

  • 軟刪除發生在資料存取層級,這表示除非您使用原始 SQL,否則無法刪除記錄

✘ 此軟刪除方法的缺點包括

  • 內容仍然可以讀取和更新,除非您明確依 where: { deleted: false } 篩選 - 在具有大量查詢的大型專案中,軟刪除的內容仍可能顯示的風險
  • 您仍然可以使用原始 SQL 來刪除記錄
提示

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

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

在步驟 2 中,我們實作了防止 Post 記錄被刪除的 middleware。但是,您仍然可以讀取和更新已刪除的記錄。此步驟探討了兩種防止讀取和更新已刪除記錄的方法。

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

選項 1:在您自己的應用程式程式碼中實作篩選器

在此選項中

  • Prisma Client middleware 負責防止記錄被刪除
  • 您自己的應用程式程式碼 (可以是 GraphQL API、REST API、模組) 負責在必要時篩選掉已刪除的文章 ({ where: { deleted: false } }),以進行讀取和更新資料 - 例如,getPost GraphQL resolver 永遠不會傳回已刪除的文章

✔ 此軟刪除方法的優點包括

  • Prisma Client 的 create/update 查詢沒有變更 - 如果您需要已刪除的記錄,您可以輕鬆請求它們
  • 在 middleware 中修改查詢可能會產生一些意想不到的後果,例如變更查詢傳回類型 (請參閱選項 2)

✘ 此軟刪除方法的缺點包括

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

選項 2:使用 middleware 來決定讀取/更新已刪除記錄的行為

選項二使用 Prisma Client middleware 來防止傳回軟刪除的記錄。下表說明 middleware 如何影響每個查詢

查詢Middleware 邏輯傳回類型變更
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 版本開始,您可以使用 middleware 將軟刪除套用至 findFirstOrThrow()findUniqueOrThrow()
  • 為什麼您可以將 findMany(){ where: { deleted: true } } 篩選器一起使用,但不能與 updateMany() 一起使用?
    此特定範例的編寫目的是為了支援使用者可以還原其已刪除的部落格文章 (這需要軟刪除文章的清單) 的情境 - 但使用者不應能夠編輯已刪除的文章。
  • 我仍然可以 connectconnectOrCreate 已刪除的文章嗎?
    在此範例中 - 可以。middleware 不會阻止您將現有的軟刪除文章連線至使用者。

執行以下範例以查看 middleware 如何影響每個查詢

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 受到影響,因為 middleware 將查詢變更為 updateMany
  • 無法處理具有 ANDORevery 等的複雜查詢...
  • 在使用來自另一個模型的 include 時無法處理篩選。

FAQ

我可以將全域 includeDeleted 新增至 Post 模型嗎?

您可能會想透過將 includeDeleted 屬性新增至 Post 模型來「駭入」您的 API,並使以下查詢成為可能

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

注意:您仍然需要編寫 middleware。

我們 ✘ 不 建議此方法,因為它會用不代表真實資料的欄位污染 schema。