Prismaを使って、いろいろなDB操作をマスターしよう〜トランザクション編〜(後編) | 技術ブログ
はじめにInteractive Transactions基本的な形具体的な実装の詳細実際の挙動確認エラー時の挙動確認Nested Writes基本的な形具体的な実装の詳細実際の挙動確認エラー時の...
https://www.wantedly.com/companies/jointcrew/post_articles/997434
はじめに
事前準備
データモデルの定義
データの用意
トランザクションの管理
概要
全体の実装の確認
Sequential Operations
基本的な形
具体的な実装の詳細
実際の挙動確認
エラー時の挙動確認
まとめ
前回は応用編として複雑なクエリの実行方法を確認しました。
データ整合性やパフォーマンスの中でも特に重要なトランザクションに着目して解説していきます。
かなりのボリュームがあるので、前編・後編に分けて紹介します。
なお、基本的な環境構築については初回の記事を参考にしてください。
5つのテーブルを使用します。
schema.prismaに以下のような定義を追加します。
// 他の定義は省略
// 口座
model Account {
id Int @id @default(autoincrement())
email String @unique
name String
balance Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sentTransfers Transfer[] @relation("SentTransfers")
recievedTransfers Transfer[] @relation("ReceivedTransfers")
}
// 取引履歴
model Transfer {
id Int @id @default(autoincrement())
amount Int
status String @default("pending")
sender Account @relation("SentTransfers", fields: [senderId], references: [id])
senderId Int
receiver Account @relation("ReceivedTransfers", fields: [receiverId], references: [id])
receiverId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 商品
model Product {
id Int @id @default(autoincrement())
name String
stock Int @default(0)
price Int
needsReorder Boolean @default(false)
orderItems OrderItem[]
}
// 注文
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
}
以下のコマンドを実行することで、schema.prismaの設定を反映します。
npx prisma migrate dev --name init
データ投入用のスクリプトとして、Account(口座)とProduct(商品)のデータを定義しました。
このスクリプトは、prismaフォルダ配下にseed.tsというファイルで作成します。
/* prisma/seed.ts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// 既存データのクリーンアップ
await prisma.transfer.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.product.deleteMany();
await prisma.account.deleteMany();
// アカウントデータの作成
const accounts = await Promise.all([
prisma.account.create({
data: {
email: "alice@example.com",
name: "Alice",
balance: 100000,
},
}),
prisma.account.create({
data: {
email: "bob@example.com",
name: "Bob",
balance: 50000,
},
}),
prisma.account.create({
data: {
email: "charlie@example.com",
name: "Charlie",
balance: 75000,
},
}),
]);
// 商品データの作成
const products = await Promise.all([
prisma.product.create({
data: {
name: "ノートパソコン",
stock: 10,
price: 80000,
},
}),
prisma.product.create({
data: {
name: "マウス",
stock: 50,
price: 2000,
},
}),
prisma.product.create({
data: {
name: "キーボード",
stock: 30,
price: 5000,
},
}),
]);
console.log("テストデータの作成が完了しました。");
console.log("アカウント:", accounts.length);
console.log("商品:", products.length);
}
main()
.catch((e) => {
console.error("データ作成中にエラーが発生しました:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
以下のコマンドを実行することで、データが投入されます。
npx ts-node prisma/seed.ts
ここまでで準備は完了です。
Prismaではトランザクションの処理を行うために、主に3つの方法が用意されています。
Sequential Operations
1つのトランザクションとして処理したい複数のクエリを配列形式で扱います。
配列に設定したクエリ操作を順番に実行することができます。
本記事では、Sequential Operationsを扱います。
Interactive Transactions
1つのトランザクションとして処理したいクエリを非同期関数の形で扱います。
クエリの結果を使用した処理なども指定できるため、インタラクティブな操作が可能になります。
詳細は後編にて解説します。
Nested Writes
リレーションのあるテーブルに対して一括でクエリを実行することができます。
詳細は後編にて解説します。
3つの方法について、それぞれどのような実装となるのかを確認します。
まず、全体を示してから個別の処理について1つずつ解説していきます。
/* index.ts */
import express, { Request, Response } from "express";
import { PrismaClient, Prisma } from "@prisma/client";
// expressの設定
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
// リッスンするポートの設定
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
// ルートパスへのリクエスト
app.get("/", (req, res) => {
res.send("Transaction Server is running");
});
// Prismaインスタンスの生成
const prisma = new PrismaClient({
log: [{ emit: "event", level: "query" }],
});
// SQLログの出力
prisma.$on("query", (e: Prisma.QueryEvent) => {
console.log("Query: " + e.query);
console.log("Params: " + e.params);
console.log("---");
});
// リクエストボディ
interface TransferRequest {
senderEmail: string;
receiverEmail: string;
amount: number;
enableErrorMode: boolean; // エラーを起こすためのフラグ
}
/**
* Sequential Operations
*/
app.post(
"/sequential-transfer",
async (req: Request<{}, {}, TransferRequest>, res: Response) => {
try {
const { senderEmail, receiverEmail, amount, enableErrorMode } = req.body;
const sender = await prisma.account.findUnique({
where: { email: senderEmail },
});
const receiver = await prisma.account.findUnique({
where: { email: receiverEmail },
});
// 本来はsender、receiverのチェックなどをするがここではスキップ
// 取引履歴作成用処理
const createTransfer = prisma.transfer.create({
data: {
amount: amount,
status: "completed",
senderId: sender!.id,
receiverId: receiver!.id,
},
});
// 送金元口座更新用処理
const updateSenderAccount = prisma.account.update({
where: { id: sender!.id },
data: { balance: { decrement: amount } },
});
// 着金先口座更新用処理
const updateReceiverAccount = prisma.account.update({
// エラーを起こす用のフラグで設定値を分岐
where: { id: enableErrorMode ? 9999 : receiver!.id },
data: { balance: { increment: amount } },
});
// 一連の処理を配列にする
const operations = [
createTransfer,
updateSenderAccount,
updateReceiverAccount,
];
await prisma.$transaction(operations);
res.json({
success: true,
message: "送金処理が完了しました",
});
} catch (error: any) {
console.error("Sequential transfer error:", error);
res.status(500).json({ success: false, error: error.message });
}
}
);
interface InteractiveOrderInput {
id: number;
quantity: number;
}
interface InteractiveOrderRequest {
items: InteractiveOrderInput[];
}
/**
* Interactive Transactions
*/
app.post(
"/interactive-order",
async (req: Request<{}, {}, InteractiveOrderRequest>, res: Response) => {
try {
const { items } = req.body;
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 },
});
}
}
});
res.json({
success: true,
message: "注文処理が完了しました",
});
} catch (error: any) {
console.error("Interactive order error:", error);
res.status(500).json({ success: false, error: error.message });
}
}
);
interface NestedOrderInput {
id: number;
quantity: number;
price: number;
productId: number;
}
interface NestedOrderRequest {
email: string;
items: NestedOrderInput[];
}
/**
* Nested Writes
*/
app.post(
"/nested-order",
async (req: Request<{}, {}, NestedOrderRequest>, res: Response) => {
try {
const { email, items } = req.body;
const totalAmount = items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
// OrderItemに登録する用のデータ
const orderItems = items.map((item) => {
return {
productId: item.productId,
quantity: item.quantity,
price: item.price,
};
});
await prisma.order.create({
data: {
email: email,
total: totalAmount,
status: "completed",
items: {
create: orderItems,
},
},
});
res.json({
success: true,
message: "注文と明細を同時作成しました",
});
} catch (error: any) {
console.error("Nested order error:", error);
res.status(500).json({ success: false, error: error.message });
}
}
);
また、それぞれの処理結果を確認する用の実装も用意しておきます。
/* index.ts */
// 続き
// ===========================================
// 各処理の結果確認用エンドポイント
// ===========================================
/**
* Sequential Transfer結果確認
*/
app.get("/check-transfer", async (req: Request, res: Response) => {
try {
const accounts = await prisma.account.findMany({
select: {
email: true,
name: true,
balance: true,
},
});
const recentTransfers = await prisma.transfer.findMany({
select: {
amount: true,
status: true,
sender: { select: { email: true } },
receiver: { select: { email: true } },
},
});
res.json({
success: true,
accounts: accounts,
recentTransfers: recentTransfers,
});
} catch (error: any) {
console.error("Check transfer error:", error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Interactive Order結果確認
*/
app.get("/check-order", async (req: Request, res: Response) => {
try {
const products = await prisma.product.findMany({
select: {
id: true,
name: true,
stock: true,
needsReorder: true,
},
});
res.json({
success: true,
allProducts: products,
});
} catch (error: any) {
console.error("Check order error:", error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Nested Order結果確認
*/
app.get("/check-nested", async (req: Request, res: Response) => {
try {
const recentOrders = await prisma.order.findMany({
select: {
total: true,
status: true,
items: {
select: {
quantity: true,
price: true,
product: {
select: {
name: true,
},
},
},
},
},
});
res.json({
success: true,
recentOrders: recentOrders,
});
} catch (error: any) {
console.error("Check nested error:", error);
res.status(500).json({ success: false, error: error.message });
}
});
複数の処理を順番に実行し、すべてが成功した場合のみ結果を確定させたい場合に使います。 一つでも失敗すれば全体を取り消します。
await prisma.$transaction(operations);
operationsはPrismaのクエリ操作の配列です。
ここでは以下の処理がoperationsとして設定されています。
// 取引履歴作成用処理
const createTransfer = prisma.transfer.create({
data: {
amount: amount,
status: "completed",
senderId: sender!.id,
receiverId: receiver!.id,
},
});
// 送金元口座更新用処理
const updateSenderAccount = prisma.account.update({
where: { id: sender!.id },
data: { balance: { decrement: amount } },
});
// 着金先口座更新用処理
const updateReceiverAccount = prisma.account.update({
// エラーを起こす用のフラグで設定値を分岐
where: { id: enableErrorMode ? 9999 : receiver!.id },
data: { balance: { increment: amount } },
});
// 一連の処理を配列にする
const operations = [
createTransfer,
updateSenderAccount,
updateReceiverAccount,
];
ポイントは、配列に設定するクエリ処理を呼び出す際にawaitを付けないことです。
クエリ操作は非同期処理なので、通常は以下のようにawaitを付けて実行します。
createの実行結果としてTransferテーブルのオブジェクトがresultに返却されます。
では、同じような形でawaitを付けない場合を見てみましょう。
戻り値としてPrisma__TransferClient型のオブジェクトが返却されます。
これはPrisma特有の処理で、createはまだ実行されていません。
awaitは非同期処理の実行完了を待つための宣言であり、付けても付けなくても処理は実行されます。
しかし、Prismaはawaitを付けないことで処理の実行を保留することができます。
これによって、$transactionに対してクエリ操作そのものを配列として渡すことができるため、一連の処理を1つのトランザクションとして扱えるようになります。
まずは以下のコマンドを実行してサーバーを立ち上げます。
npx ts-node index.ts
次に、別のターミナルを立ち上げてSequential Transfer結果確認用のAPIを実行します。
以降、コマンド実行時はこのターミナルで行います。
curl http://localhost:3000/check-transfer
結果は以下のようになります。(見やすいように整形しています。)
はじめに、seed.tsスクリプトで投入したデータを確認できます。
{
"success": true,
"accounts": [
{ "email": "charlie@example.com", "name": "Charlie", "balance": 75000 },
{ "email": "alice@example.com", "name": "Alice", "balance": 100000 },
{ "email": "bob@example.com", "name": "Bob", "balance": 50000 }
],
"recentTransfers": []
}
では、処理を実行してみます。
今回の例では、AliceからBobへ10,000円分を送金する処理を試します。
なお、一部環境差異があるため以降処理を実行するためのコマンドはMac用のターミナル用とWindowsのコマンドプロンプト用の2つを併記します。
# Mac用
curl -X POST http://localhost:3000/sequential-transfer \
-H "Content-Type: application/json" \
-d '{"senderEmail":"alice@example.com","receiverEmail":"bob@example.com","amount":10000}'
# Windows用
curl -X POST http://localhost:3000/sequential-transfer ^
-H "Content-Type: application/json" ^
-d "{\"senderEmail\":\"alice@example.com\",\"receiverEmail\":\"bob@example.com\",\"amount\":10000}"
サーバーを立ち上げた方のターミナルには、以下のようなログが出力されているはずです。
Query: BEGIN
Params: []
---
Query: INSERT INTO "public"."Transfer" ("amount","status","senderId","receiverId","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5,$6) RETURNING "public"."Transfer"."id", "public"."Transfer"."amount", "public"."Transfer"."status", "public"."Transfer"."senderId", "public"."Transfer"."receiverId", "public"."Transfer"."createdAt", "public"."Transfer"."updatedAt"
Params: [10000,"completed",2,3,"2025-08-04 02:06:03.045 UTC","2025-08-04 02:06:03.045 UTC"]
---
Query: UPDATE "public"."Account" SET "balance" = ("public"."Account"."balance" - $1), "updatedAt" = $2 WHERE ("public"."Account"."id" = $3 AND 1=1) RETURNING "public"."Account"."id", "public"."Account"."email", "public"."Account"."name", "public"."Account"."balance", "public"."Account"."createdAt", "public"."Account"."updatedAt"
Params: [10000,"2025-08-04 02:06:03.045 UTC",2]
---
Query: UPDATE "public"."Account" SET "balance" = ("public"."Account"."balance" + $1), "updatedAt" = $2 WHERE ("public"."Account"."id" = $3 AND 1=1) RETURNING "public"."Account"."id", "public"."Account"."email", "public"."Account"."name", "public"."Account"."balance", "public"."Account"."createdAt", "public"."Account"."updatedAt"
Params: [10000,"2025-08-04 02:06:03.045 UTC",3]
---
Query: COMMIT
Params: []
---
BEGINの後に取引履歴作成用処理、送金元口座更新用処理、着金先口座更新用処理が実行され、最後にCOMMITが実行されているのがわかります。
これは一連の処理が1つのトランザクションで扱われていることを示しています。
Sequential Transfer結果確認用のAPIで実行結果を確認します。
curl http://localhost:3000/check-transfer
結果は以下のようになります。
{
"success": true,
"accounts": [
{ "email": "charlie@example.com", "name": "Charlie", "balance": 75000 },
{ "email": "alice@example.com", "name": "Alice", "balance": 90000 },
{ "email": "bob@example.com", "name": "Bob", "balance": 60000 }
],
"recentTransfers": [
{
"amount": 10000,
"status": "completed",
"sender": { "email": "alice@example.com" },
"receiver": { "email": "bob@example.com" }
},
]
}
recentTransfersに新たな取引履歴が追加されているのが確認できます。
残高は、Aliceが100,000円から90,000円へ減少し、Bobは50,000円から60,000円へ増加しています。
テスト用に着金先口座の更新処理に条件分岐を追加し、特定条件で強制的にエラーが発生するようにしました。
以下のコマンドを実行すると、このエラーを意図的に発生させられます。
# Mac用
curl -X POST http://localhost:3000/sequential-transfer \
-H "Content-Type: application/json" \
-d '{"senderEmail":"alice@example.com","receiverEmail":"bob@example.com","amount":10000,"enableErrorMode":true}'
# Windows用
curl -X POST http://localhost:3000/sequential-transfer ^
-H "Content-Type: application/json" ^
-d "{\"senderEmail\":\"alice@example.com\",\"receiverEmail\":\"bob@example.com\",\"amount\":10000,\"enableErrorMode\":true}"
SQLのログは以下のように出力されています。
Query: BEGIN
Params: []
---
Query: INSERT INTO "public"."Transfer" ("amount","status","senderId","receiverId","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5,$6) RETURNING "public"."Transfer"."id", "public"."Transfer"."amount", "public"."Transfer"."status", "public"."Transfer"."senderId", "public"."Transfer"."receiverId", "public"."Transfer"."createdAt", "public"."Transfer"."updatedAt"
Params: [10000,"completed",2,3,"2025-08-04 06:14:54.500 UTC","2025-08-04 06:14:54.500 UTC"]
---
Query: UPDATE "public"."Account" SET "balance" = ("public"."Account"."balance" - $1), "updatedAt" = $2 WHERE ("public"."Account"."id" = $3 AND 1=1) RETURNING "public"."Account"."id", "public"."Account"."email", "public"."Account"."name", "public"."Account"."balance", "public"."Account"."createdAt", "public"."Account"."updatedAt"
Params: [10000,"2025-08-04 06:14:54.500 UTC",2]
---
Query: UPDATE "public"."Account" SET "balance" = ("public"."Account"."balance" + $1), "updatedAt" = $2 WHERE ("public"."Account"."id" = $3 AND 1=1) RETURNING "public"."Account"."id", "public"."Account"."email", "public"."Account"."name", "public"."Account"."balance", "public"."Account"."createdAt", "public"."Account"."updatedAt"
Params: [10000,"2025-08-04 06:14:54.500 UTC",9999]
---
Query: ROLLBACK
Params: []
---
Sequential transfer error: PrismaClientKnownRequestError:
Invalid `prisma.account.update()` invocation in
/Users/kosukeyoshida/lab/prisma-basic/index.ts:73:52
70 });
71
72 // 着金先口座更新用処理
→ 73 const updateReceiverAccount = prisma.account.update(
An operation failed because it depends on one or more records that were required but not found. Record to update not found.
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: 'P2025',
clientVersion: '5.22.0',
meta: { modelName: 'Account', cause: 'Record to update not found.' }
}
先ほどとは異なり、COMMITではなくROLLBACKが実行されていることがわかります。
結果も確認してみましょう。
curl http://localhost:3000/check-transfer
以下の通り、データに変化はありません。
{
"success": true,
"accounts": [
{ "email": "charlie@example.com", "name": "Charlie", "balance": 75000 },
{ "email": "alice@example.com", "name": "Alice", "balance": 90000 },
{ "email": "bob@example.com", "name": "Bob", "balance": 60000 }
],
"recentTransfers": [
{
"amount": 10000,
"status": "completed",
"sender": { "email": "alice@example.com" },
"receiver": { "email": "bob@example.com" }
},
]
}
このように、トランザクション内で失敗するとすでに実行済の処理も取り消されます。
一連の処理を配列として渡すだけでトランザクション管理ができるので、直感的でわかりやすい操作になっています。
今回はPrismaのトランザクション管理の中でも基本的なSequential Operationsに絞って解説しました。
この方法を理解しておくことで、データ整合性を保ちながら安全に複数の処理をまとめることができます。
他の2つの管理方法(Interactive Transactions、Nested Writes)については、後編で詳しくご紹介します。
〜後編の記事はこちら〜