ミドルウェアサンプル: ソフトデリート
以下のサンプルでは、ミドルウェアを使用して、ソフトデリートを実行します。ソフトデリートとは、レコードをデータベースから実際に削除するのではなく、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: レコードのステータスを保存する
deleted
という名前のフィールドをPost
モデルに追加します。要件に応じて、2 つのフィールドタイプから選択できます。
-
Boolean
型、デフォルト値はfalse
model Post {
id Int @id @default(autoincrement())
...
deleted Boolean @default(false)
} -
レコードが削除済みとしてマークされた日時を正確に把握できるように、nullable な
DateTime
フィールドを作成します。NULL
は、レコードが削除されていないことを示します。場合によっては、レコードが削除された日時を保存することが規制要件となる場合があります。model Post {
id Int @id @default(autoincrement())
...
deleted DateTime?
}
注: 2 つの別々のフィールド (
isDeleted
とdeletedDate
) を使用すると、これらの 2 つのフィールドが同期しなくなる可能性があります (たとえば、レコードが削除済みとしてマークされていても、関連する日付がない場合があります)。
このサンプルでは、簡略化のために 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!
####################################
メッセージの変更を確認するには、ミドルウェアをコメントアウトしてください。
✔ このソフトデリートアプローチの利点は次のとおりです。
- ソフトデリートはデータアクセスレベルで発生するため、raw SQL を使用しない限りレコードを削除できません。
✘ このソフトデリートアプローチの欠点は次のとおりです。
where: { deleted: false }
で明示的にフィルタリングしない限り、コンテンツは引き続き読み取りおよび更新できます。クエリが多い大規模なプロジェクトでは、ソフトデリートされたコンテンツが引き続き表示されるリスクがあります。- raw SQL を使用してレコードを削除することもできます。
データベースレベルでルールまたはトリガー (MySQL および PostgreSQL) を作成して、レコードが削除されないようにすることができます。
ステップ 3: オプションでソフトデリートされたレコードの読み取り/更新を防止する
ステップ 2 では、Post
レコードが削除されないようにするミドルウェアを実装しました。ただし、削除されたレコードを読み取りおよび更新することは依然として可能です。このステップでは、削除されたレコードの読み取りと更新を防止する 2 つの方法を探ります。
注: これらのオプションは単なるアイデアであり、長所と短所があります。まったく異なる方法を選択することもできます。
オプション 1: 独自のアプリケーションコードでフィルターを実装する
このオプションでは
- Prisma Client ミドルウェアは、レコードが削除されないようにする責任があります。
- 独自のアプリケーションコード (GraphQL API、REST API、モジュールなど) は、データの読み取りおよび更新時に、必要に応じて削除された投稿をフィルタリングする責任があります (
{ where: { deleted: false } }
)。たとえば、getPost
GraphQL リゾルバーは、削除された投稿を返すことはありません。
✔ このソフトデリートアプローチの利点は次のとおりです。
- Prisma Client の create/update クエリに変更はありません。必要に応じて、削除されたレコードを簡単にリクエストできます。
- ミドルウェアでクエリを変更すると、クエリの戻り値の型を変更するなど、意図しない結果が生じる可能性があります (オプション 2 を参照)。
✘ このソフトデリートアプローチの欠点は次のとおりです。
- ソフトデリートに関連するロジックが 2 つの異なる場所に保持されます。
- API サーフェスが非常に大きく、複数のコントリビューターによって管理されている場合、特定のビジネスルール (たとえば、削除されたレコードを更新することを決して許可しないなど) を強制するのが難しい場合があります。
オプション 2: ミドルウェアを使用して、削除されたレコードの読み取り/更新クエリの動作を決定する
オプション 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 } フィルターを追加して、ソフトデリートされた投稿を除外する | Post の代わりに { count: n } |
updateMany | 🔧 where: { deleted: false } フィルターを追加して、ソフトデリートされた投稿を除外する | 変更なし |
findFirstOrThrow()
またはfindUniqueOrThrow()
でソフトデリートを利用することはできませんか?
バージョン 5.1.0 以降、ミドルウェアを使用することで、ソフトデリートのfindFirstOrThrow()
またはfindUniqueOrThrow()
を適用できます。{ where: { deleted: true } }
フィルターを指定したfindMany()
を使用できるようにするのに、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 } }
がデフォルトのクエリの一部であることがわかりにくい。 - ミドルウェアがクエリを
updateMany
に変更するため、戻り値の型update
が影響を受ける。 AND
、OR
、every
などの複雑なクエリを処理しない。- 別のモデルから
include
を使用する場合のフィルタリングを処理しない。
FAQ
グローバルな includeDeleted
を Post
モデルに追加できますか?
includeDeleted
プロパティを Post
モデルに追加して API を「ハック」し、次のクエリを可能にすることを検討するかもしれません。
prisma.post.findMany({ where: { includeDeleted: true } })
注: それでもミドルウェアを記述する必要があります。
スキーマを実際のデータを表さないフィールドで汚染するため、このアプローチを✘ 推奨しません。