Middleware sample: soft delete
The following sample uses middleware to perform a soft delete. Soft delete means that a record is marked as deleted by changing a field like deleted
to true
rather than actually being removed from the database. Reasons to use a soft delete include:
- Regulatory requirements that mean you have to keep data for a certain amount of time
- 'Trash' / 'bin' functionality that allows users to restore content that was deleted
Note: This page demonstrates a sample use of middleware. We do not intend the sample to be a fully functional soft delete feature and it does not cover all edge cases. For example, the middleware does not work with nested writes and therefore won't capture situations where you use delete
or deleteMany
as an option e.g. in an update
query.
This sample uses the following schema - note the deleted
field on the Post
model:
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[]
}
Step 1: Store status of record
Add a field named deleted
to the Post
model. You can choose between two field types depending on your requirements:
-
Boolean
with a default value offalse
:model Post {
id Int @id @default(autoincrement())
...
deleted Boolean @default(false)
} -
Create a nullable
DateTime
field so that you know exactly when a record was marked as deleted -NULL
indicates that a record has not been deleted. In some cases, storing when a record was removed may be a regulatory requirement:model Post {
id Int @id @default(autoincrement())
...
deleted DateTime?
}
Note: Using two separate fields (
isDeleted
anddeletedDate
) may result in these two fields becoming out of sync - for example, a record may be marked as deleted but have no associated date.)
This sample uses a Boolean
field type for simplicity.
Step 2: Soft delete middleware
Add a middleware that performs the following tasks:
- Intercepts
delete()
anddeleteMany()
queries for thePost
model - Changes the
params.action
toupdate
andupdateMany
respectively - Introduces a
data
argument and sets{ deleted: true }
, preserving other filter arguments if they exist
Run the following sample to test the soft delete 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()
The sample outputs the following:
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!
####################################
Comment out the middleware to see the message change.
✔ Pros of this approach to soft delete include:
- Soft delete happens at data access level, which means that you cannot delete records unless you use raw SQL
✘ Cons of this approach to soft delete include:
- Content can still be read and updated unless you explicitly filter by
where: { deleted: false }
- in a large project with a lot of queries, there is a risk that soft deleted content will still be displayed - You can still use raw SQL to delete records
You can create rules or triggers (MySQL and PostgreSQL) at a database level to prevent records from being deleted.
Step 3: Optionally prevent read/update of soft deleted records
In step 2, we implemented middleware that prevents Post
records from being deleted. However, you can still read and update deleted records. This step explores two ways to prevent the reading and updating of deleted records.
Note: These options are just ideas with pros and cons, you may choose to do something entirely different.
Option 1: Implement filters in your own application code
In this option:
- Prisma Client middleware is responsible for preventing records from being deleted
- Your own application code (which could be a GraphQL API, a REST API, a module) is responsible for filtering out deleted posts where necessary (
{ where: { deleted: false } }
) when reading and updating data - for example, thegetPost
GraphQL resolver never returns a deleted post
✔ Pros of this approach to soft delete include:
- No change to Prisma Client's create/update queries - you can easily request deleted records if you need them
- Modifying queries in middleware can have some unintended consequences, such as changing query return types (see option 2)
✘ Cons of this approach to soft delete include:
- Logic relating to soft delete maintained in two different places
- If your API surface is very large and maintained by multiple contributors, it may be difficult to enforce certain business rules (for example, never allow deleted records to be updated)
Option 2: Use middleware to determine the behavior of read/update queries for deleted records
Option two uses Prisma Client middleware to prevent soft deleted records from being returned. The following table describes how the middleware affects each query:
Query | Middleware logic | Changes to return type |
---|---|---|
findUnique() | 🔧 Change query to findFirst (because you cannot apply deleted: false filters to findUnique() ) 🔧 Add where: { deleted: false } filter to exclude soft deleted posts 🔧 From version 5.0.0, you can use findUnique() to apply delete: false filters since non unique fields are exposed. | No change |
findMany | 🔧 Add where: { deleted: false } filter to exclude soft deleted posts by default 🔧 Allow developers to explicitly request soft deleted posts by specifying deleted: true | No change |
update |