Prismaを使って、いろいろなDB操作をマスターしよう〜トランザクション編〜(前編) | 技術ブログ
はじめに事前準備データモデルの定義データの用意トランザクションの管理概要全体の実装の確認Sequential Operations基本的な形具体的な実装の詳細実際の挙動確認エラー時の挙動確認まと...
https://www.wantedly.com/companies/jointcrew/post_articles/971058
はじめに
Interactive Transactions
基本的な形
具体的な実装の詳細
実際の挙動確認
エラー時の挙動確認
Nested Writes
基本的な形
具体的な実装の詳細
実際の挙動確認
エラー時の挙動確認
まとめ
前回はトランザクション管理の前編として、Sequential Operationsについて紹介しました。
本記事では前回紹介しきれなかったInteractive Transactions、Nested Writesについて紹介していきます。
〜前編の記事はこちら〜
なお、基本的な環境構築については初回の記事を参考にしてください。
一連の処理をひとまとまりとして扱いながら、結果によって処理を分岐させたり中断させたりしたい場合に使います。
await prisma.$transaction(async (tx) => {
// 様々な処理
})
txはPrismaClientのインスタンスです。
つまり、prisma.モデル名.操作メソッドのようにtx.モデル名.操作メソッドとしてクエリ操作ができます。
await prisma.$transaction(async (tx) => {
for (const item of items) {
// 在庫状況更新
const updatedProduct = await tx.product.update({
where: { id: item.id },
data: { stock: { decrement: item.quantity } },
});
const remainingStock = updatedProduct.stock;
// 在庫不足エラー
if (remainingStock < 0) {
throw new Error(`${updatedProduct.name}の在庫が不足しています`);
}
// 再注文フラグを立てる
if (remainingStock < 5) {
await tx.product.update({
where: { id: item.id },
data: { needsReorder: true },
});
}
}
});
tx.product.updateによって、Productテーブルに対してリクエストで受け取った数量の分だけ商品を減らす処理を実行しています。
処理の途中で同時に複数のリクエストが発生すると、在庫不足が生じる可能性があります。
そのため、更新後の数量をチェックして在庫が負の値になっている場合には処理を中断するようにしました。
また、在庫数が規定より少なくなった場合には再発注できるようにしておきたいので、再発注フラグを立てる処理も追加しています。
このように、DBの処理の結果に応じて別の処理を実行しながらも、一連の処理として扱いたい場合に使えるのがInteractive Transactionsです。
サーバーは立ち上がってる前提で進めます。
以下のコマンドを実行して、事前の結果を確認します。
curl http://localhost:3000/check-order
以下のように商品の状態一覧が確認できます。
{
"success": true,
"allProducts": [
{ "id": 1, "name": "マウス", "stock": 50, "needsReorder": false },
{ "id": 2, "name": "キーボード", "stock": 30, "needsReorder": false },
{ "id": 3, "name": "ノートパソコン", "stock": 10, "needsReorder": false }
]
}
処理を実行してみましょう。
マウス(id:1)とキーボード(id:2)の商品数を減らす処理です。
# Mac用
curl -X POST http://localhost:3000/interactive-order \
-H "Content-Type: application/json" \
-d '{"items":[{"id":1,"quantity":1},{"id":2,"quantity":2}]}'
# Windows用
curl -X POST http://localhost:3000/interactive-order ^
-H "Content-Type: application/json" ^
-d "{\"items\":[{\"id\":1,\"quantity\":1,\"price\":80000,\"orderId\":1,\"productId\":1},{\"id\":2,\"quantity\":2,\"price\":2000,\"orderId\":1,\"productId\":2}]}"
SQLのログは以下のように出力されています。
Query: BEGIN
Params: []
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [1,1]
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [2,2]
---
Query: COMMIT
Params: []
---
1つのトランザクションの中で商品数分の更新処理が実行されているのがわかります。
結果も確認してみます。
curl http://localhost:3000/check-order
以下の通り、マウスとキーボードともに正しく商品在庫が減っていることがわかります。
{
"success": true,
"allProducts": [
{ "id": 1, "name": "マウス", "stock": 49, "needsReorder": false },
{ "id": 2, "name": "キーボード", "stock": 28, "needsReorder": false },
{ "id": 3, "name": "ノートパソコン", "stock": 10, "needsReorder": false }
]
}
では、次に再注文フラグが立つかを検証してみましょう。
ノートパソコン(id:3)の商品在庫が5より小さくなるようなリクエストを送ります。
# Mac用
curl -X POST http://localhost:3000/interactive-order \
-H "Content-Type: application/json" \
-d '{"items":[{"id":3,"quantity":6}]}'
# Windows用
curl -X POST http://localhost:3000/interactive-order ^
-H "Content-Type: application/json" ^
-d "{\"items\":[{\"id\":3,\"quantity\":6}]}"
SQLのログは以下のように出力されています。
stockの更新処理だけでなく、先程はなかったneedsReorderの更新処理も実行されています。
Query: BEGIN
Params: []
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [6,3]
---
Query: UPDATE "public"."Product" SET "needsReorder" = $1 WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [true,3]
---
Query: COMMIT
Params: []
---
結果も確認します。
curl http://localhost:3000/check-order
ノートパソコン(id:3)のstockがへり、needsReorderがtrueとなっていることがわかります。
{
"success": true,
"allProducts": [
{ "id": 1, "name": "マウス", "stock": 49, "needsReorder": false },
{ "id": 2, "name": "キーボード", "stock": 28, "needsReorder": false },
{ "id": 3, "name": "ノートパソコン", "stock": 4, "needsReorder": true }
]
}
商品在庫の更新結果を受けて、別の処理を実行できることが確認できました。
次に、処理の中止のパターンも確認します。
マウス(id:1)、キーボード(id:2)は通常の数、ノートパソコン(id:3)は在庫数を超えた数をリクエストする処理です。
# Mac用
curl -X POST http://localhost:3000/interactive-order \
-H "Content-Type: application/json" \
-d '{"items":[{"id":1,"quantity":5},{"id":2,"quantity":8},{"id":3,"quantity":10}]}'
# Windows用
curl -X POST http://localhost:3000/interactive-order ^
-H "Content-Type: application/json" ^
-d "{\"items\":[{\"id\":1,\"quantity\":5},{\"id\":2,\"quantity\":8},{\"id\":3,\"quantity\":10}]}"
SQLのログは以下のように出力されています。
3つめの更新処理のあと、ROLLBACKされていることがわかります。
Query: BEGIN
Params: []
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [5,1]
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [8,2]
---
Query: UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1) WHERE ("public"."Product"."id" = $2 AND 1=1) RETURNING "public"."Product"."id", "public"."Product"."name", "public"."Product"."stock", "public"."Product"."price", "public"."Product"."needsReorder"
Params: [10,3]
---
Query: ROLLBACK
Params: []
---
Interactive order error: Error: ノートパソコンの在庫が不足しています
at /Users/kosukeyoshida/lab/prisma-basic/index.ts:124:19
at Generator.next (<anonymous>)
at fulfilled (/Users/kosukeyoshida/lab/prisma-basic/index.ts:5:58)
結果も確認します。
curl http://localhost:3000/check-order
ノートパソコンだけでなく、マウス、キーボードの在庫数も変わっていないことがわかります。
{
"success": true,
"allProducts": [
{ "id": 1, "name": "マウス", "stock": 49, "needsReorder": false },
{ "id": 2, "name": "キーボード", "stock": 28, "needsReorder": false },
{ "id": 3, "name": "ノートパソコン", "stock": 4, "needsReorder": true }
]
}
ただ単にSQLを実行するだけでなく、その結果に応じて複雑な処理を実行することができるのでより柔軟なクエリ操作が可能になります。
リレーションのあるテーブルに対して、一括した処理を実行したい場合に使います。
Prismaのcreate、またはupdateの操作時にリレーションのあるテーブルと入れ子にしたい操作を指定します。
実際の中身を見たほうがわかりやすいので、早速具体的な実装を確認します。
まず、はじめに示したモデル定義の一部を抜粋します。
model Order {
id Int @id @default(autoincrement())
email String
total Int
status String @default("pending")
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 注文明細
model OrderItem {
id Int @id @default(autoincrement())
quantity Int
price Int
order Order @relation(fields: [orderId], references: [id])
orderId Int
product Product @relation(fields: [productId], references: [id])
productId Int
}
OrderテーブルとOrderItemの間のリレーションがitemsとして定義されています。
Prismaのリレーション定義について詳しく知りたい方は、以下の記事の「1対1のリレーション > データモデルの定義」を参考にしてください。
実際の処理は以下のようになっています。
await prisma.order.create({
data: {
email: email,
total: totalAmount,
status: "completed",
items: {
create: orderItems,
},
},
});
dataに指定しているオブジェクトのうち、email、total、statusはOrderのカラムで、値として作成するデータが指定されています。
一方、itemsはカラムではなくリレーションを示すフィールド名で、値としてオブジェクトを指定します。
このオブジェクトが入れ子(Nested)にする処理の指定になります。
オブジェクトに設定できるプロパティは、元となっているクエリ操作(ここではorder.create)によって決められています。
様々な種類がありますが、create操作の場合に入れ子にできるのは一般的にはcreateとcreateManyです。update操作の場合にはそれらに加えてupdate、deleteなどがあります。
一見するとcreateが単一データ、createManyが複数データの挿入に思えますが、createでも複数データの挿入が可能です。
また、指定するデータの形式も異なります。
// 入れ子にしたcreateの単一データ
items: {
create: {/* データ */}
}
// 入れ子にしたcreateの複数データ
items: {
create:[{/* データ1 */}, {/* データ2 */}...]
}
// 入れ子にしたcreateManyのデータ
items: {
createMany: { data: [{/* データ1 */}, {/* データ2 */}...] }
}
入れ子にしたcreateの場合、データのオブジェクト、もしくはオブジェクトの配列を指定します。
入れ子にしたcreateManyの場合、dataプロパティを持つオブジェクトを指定し、その値としてデータオブジェクトの配列を指定します。
また、入れ子にしたcreateの場合、更に追加で入れ子にした処理を指定できますが、createManyではできません。
より詳細な操作を知りたい方は以下の公式サイトのドキュメントを参考にしてください。
まずは現在の状態を確認します。
curl http://localhost:3000/check-nested
まだ何も操作していないのでデータはありません。
{ "success": true, "recentOrders": [] }
では、以下のコマンドで注文と注文明細を同時に作成してみましょう。
# Mac用
curl -X POST http://localhost:3000/nested-order \
-H "Content-Type: application/json" \
-d '{"email":"charlie@example.com","items":[{"id":1,"quantity":1,"price":80000,"orderId":1,"productId":1},{"id":2,"quantity":1,"price":5000,"orderId":1,"productId":3}]}'
# Windows用
curl -X POST http://localhost:3000/nested-order ^
-H "Content-Type: application/json" ^
-d "{\"email\":\"charlie@example.com\",\"items\":[{\"id\":1,\"quantity\":1,\"price\":80000,\"orderId\":1,\"productId\":1},{\"id\":2,\"quantity\":1,\"price\":5000,\"orderId\":1,\"productId\":3}]}"
SQLのログは以下のように出力されています。
※SELECTが実行されているのはPrismaの仕様です。
Query: BEGIN
Params: []
---
Query: INSERT INTO "public"."Order" ("email","total","status","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5) RETURNING "public"."Order"."id"
Params: ["charlie@example.com",85000,"completed","2025-08-04 20:40:46.488 UTC","2025-08-04 20:40:46.488 UTC"]
---
Query: INSERT INTO "public"."OrderItem" ("productId","price","quantity","orderId") VALUES ($1,$2,$3,$4), ($5,$6,$7,$8)
Params: [1,80000,1,1,3,5000,1,1]
---
Query: SELECT "public"."Order"."id", "public"."Order"."email", "public"."Order"."total", "public"."Order"."status", "public"."Order"."createdAt", "public"."Order"."updatedAt" FROM "public"."Order" WHERE "public"."Order"."id" = $1 LIMIT $2 OFFSET $3
Params: [1,1,0]
---
Query: COMMIT
Params: []
---
結果は以下のようになります。
Orderテーブルだけでなく、OrderItemテーブルのデータも一緒に作成されていることがわかります。
{
"success": true,
"recentOrders": [
{
"total": 85000,
"status": "completed",
"items": [
{ "quantity": 1,
"price": 80000,
"product": { "name": "マウス" }
},
{
"quantity": 1,
"price": 5000,
"product": { "name": "ノートパソコン" }
}
]
}
]
}
では、関連テーブル作成時にエラーが起きた場合はどうなるでしょうか。
存在しないProductのIDを指定することで、エラーを発生させます。
# Mac用
curl -X POST http://localhost:3000/nested-order \
-H "Content-Type: application/json" \
-d '{"email":"charlie@example.com","items":[{"id":1,"quantity":1,"price":80000,"orderId":1,"productId":1},{"id":2,"quantity":1,"price":5000,"orderId":1,"productId":9999}]}'
# Windows用
curl -X POST http://localhost:3000/nested-order ^
-H "Content-Type: application/json" ^
-d "{\"email\":\"charlie@example.com\",\"items\":[{\"id\":1,\"quantity\":1,\"price\":80000,\"orderId\":1,\"productId\":1},{\"id\":2,\"quantity\":1,\"price\":5000,\"orderId\":1,\"productId\":9999}]}"
SQLのログは以下のように出力されています。
OrderItemのINSERT処理の後にROLLBACKされています。
Query: BEGIN
Params: []
---
Query: INSERT INTO "public"."Order" ("email","total","status","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5) RETURNING "public"."Order"."id"
Params: ["charlie@example.com",85000,"completed","2025-08-05 01:13:40.676 UTC","2025-08-05 01:13:40.676 UTC"]
---
Query: INSERT INTO "public"."OrderItem" ("quantity","orderId","price","productId") VALUES ($1,$2,$3,$4), ($5,$6,$7,$8)
Params: [1,2,80000,1,1,2,5000,9999]
---
Query: ROLLBACK
Params: []
---
Nested order error: PrismaClientKnownRequestError:
Invalid `prisma.order.create()` invocation in
/Users/kosukeyoshida/lab/prisma-basic/index.ts:176:26
173 price: item.price,
174 };
175 });
→ 176 await prisma.order.create(
Foreign key constraint violated: `OrderItem_productId_fkey (index)`
at $n.handleRequestError (/Users/kosukeyoshida/lab/prisma-basic/node_modules/@prisma/client/runtime/library.js:121:7315)
at $n.handleAndLogRequestError (/Users/kosukeyoshida/lab/prisma-basic/node_modules/@prisma/client/runtime/library.js:121:6623)
at $n.request (/Users/kosukeyoshida/lab/prisma-basic/node_modules/@prisma/client/runtime/library.js:121:6307)
at async l (/Users/kosukeyoshida/lab/prisma-basic/node_modules/@prisma/client/runtime/library.js:130:9633) {
code: 'P2003',
clientVersion: '5.22.0',
meta: {
modelName: 'Order',
field_name: 'OrderItem_productId_fkey (index)'
}
}
結果を確認します。
curl http://localhost:3000/check-nested
OrderItemだけで無く、Orderのデータも更新されてないことがわかります。
{
"success": true,
"recentOrders": [
{
"total": 85000,
"status": "completed",
"items": [
{ "quantity": 1,
"price": 80000,
"product": { "name": "マウス" } },
{
"quantity": 1,
"price": 5000,
"product": { "name": "ノートパソコン" }
}
]
}
]
}
Nested Writesによって、リレーションのあるテーブルのデータを整合性を保ったまま一括で作成することが可能になります。
今回はデータ整合性・パフォーマンスの中でもトランザクションに着目して、3つのトランザクション管理方法を2回に分けて紹介しました。
実際の業務ではトランザクション管理は重要なポイントの1つになります。
処理方法をしっかり理解して、データ不整合が起きないような実装を行っていきたいですね。
また、Prismaを使ったDB関連の操作はまだまだ他にもあるので、実務で知見をためながら機会があれば紹介していければと思います。