NestJSのPrisma + SQL Server実装パターン。PrismaServiceのDI・接続設定・論理削除・Clockファクトリ・トランザクション・マイグレーション/シードワークフロー・SQL Server固有の制限をカバーする。リポジトリの実装・マイグレーションの作成・データベースレイヤーの設定時に使用する。
基本ルールは api-design.instructions.md を参照。
deleteFlg: 0 フィルタを漏らしていないか(論理削除済みデータの混入)$queryRaw を使う場合、Prisma.sql を使用しているか(SQLインジェクション対策)include で一括取得しているか)this.prisma を使っていないか(tx を使うこと)標準実装:
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
PrismaModule は @Global() でルートモジュールに一度だけインポート// prisma.module.ts
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
.env に定義(コード直書き禁止):
DATABASE_URL="sqlserver://localhost:1433;database=task_manager;user=sa;password=YourPassword123;trustServerCertificate=true"
schema.prisma の datasource 設定⚠️ Prisma 7 破壊的変更:
datasource.urlは Prisma 7 で廃止。接続 URL はprisma.config.tsに移行すること。
// schema.prisma — Prisma 7 以降は url を書かない
datasource db {
provider = "sqlserver"
}
generator client {
provider = "prisma-client-js"
}
// prisma.config.ts(プロジェクトルートに配置)
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
earlyAccess: true,
schema: path.join(__dirname, "prisma", "schema.prisma"),
migrate: {
async url(): Promise<string> {
return process.env["DATABASE_URL"] ?? "";
},
},
});
デフォルトは num_cpus * 2 + 1。調整は connection_limit パラメータで:
DATABASE_URL="sqlserver://...;connection_limit=10"
| 項目 | PostgreSQL との差異 |
|---|---|
jsonb 型 | 非対応。String 型で代替 |
uuid() デフォルト値 | @default(uuid()) 使用可能 |
autoincrement() | 使用可能。本プロジェクトは文字列 ID 採用 |
@@unique 複合制約 | 使用可能 |
| ケースセンシティビティ | Collation 依存。デフォルトは大小文字不区別の場合あり |
本プロジェクトは DELETE_FLG カラム(deleteFlg)で管理。deletedAt: DateTime? は不使用。
(共通監査カラム: basic-design.md §6)
model Task {
id String @id
// ... 他フィールド
deleteFlg Int @default(0) @map("DELETE_FLG") // 0: 有効 / 1: 削除済み
}
// 有効レコードのみ(必ず deleteFlg: 0)
const tasks = await this.prisma.task.findMany({
where: { deleteFlg: 0 },
});
// 論理削除(updatedAt/updatedById も更新)
await this.prisma.task.update({
where: { id },
data: {
deleteFlg: 1,
updatedAt: this.clock.now(),
updatedById: userId,
},
});
// 削除済み含む全件(監査用)
const allTasks = await this.prisma.task.findMany();
deleteFlg フィルタ漏れ = L1 違反。 Repository は原則
where: { deleteFlg: 0 }を内包。
new Date() 直接使用禁止→ Clock 経由。
// packages/backend/src/clock/clock.ts
export interface Clock {
now(): Date;
}
export class SystemClock implements Clock {
now(): Date {
return new Date(); // UTC
}
}
// app.module.ts
import { SystemClock } from "./clock/clock";
@Module({
providers: [{ provide: "Clock", useClass: SystemClock }],
})
export class AppModule {}
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
@Inject("Clock") private readonly clock: Clock,
) {}
async softDelete(id: string): Promise<Result<void>> {
await this.prisma.task.update({
where: { id },
data: {
deleteFlg: 1,
updatedAt: this.clock.now(),
},
});
return { ok: true, value: undefined };
}
}
const mockClock: Clock = { now: () => new Date("2026-04-10T00:00:00Z") };
複数テーブルにまたがる操作は $transaction で行う:
// 複数テーブル操作は $transaction
const result = await this.prisma.$transaction(async (tx) => {
const task = await tx.task.findUnique({ where: { id } });
if (!task) return { ok: false, error: { type: "NOT_FOUND", message: "..." } };
await tx.taskHistory.create({
data: { ...task, archivedAt: this.clock.now() },
});
await tx.task.update({ where: { id }, data: patch });
return { ok: true, value: task };
});
$transaction 内は tx を使用。this.prisma だと同一トランザクションにならない# スキーマ変更後マイグレーション作成・適用
pnpm --filter backend prisma migrate dev --name <変更内容>
# Prisma Client 再生成
pnpm --filter backend prisma generate
# シード投入
pnpm --filter backend prisma db seed
# 未適用マイグレーションのみ適用
pnpm --filter backend prisma migrate deploy
prisma/seed.ts の実装場所prisma/
├── schema.prisma
├── seed.ts ← シードエントリポイント
└── migrations/ ← 自動生成(手動編集禁止)
package.json の prisma.seed でシードを登録:
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
include / select でリレーション一括取得:
// NG: ループ内 findUnique(N+1)
const tasks = await this.prisma.task.findMany({ where: { deleteFlg: 0 } });
for (const task of tasks) {
const products = await this.prisma.taskProduct.findMany({
where: { taskId: task.id },
});
// ...
}
// OK: include で一括取得
const tasks = await this.prisma.task.findMany({
where: { deleteFlg: 0 },
include: {
products: { include: { product: true } },
dependencies: true,
},
});
Prisma 例外を処理し、内部構造をクライアントに漏らさない。
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
async findById(id: string): Promise<Result<Task>> {
try {
const task = await this.prisma.task.findUnique({
where: { id, deleteFlg: 0 },
});
if (!task) {
return { ok: false, error: { type: "NOT_FOUND", message: "タスクが見つかりません" } };
}
return { ok: true, value: task };
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) {
if (err.code === "P2025") {
return { ok: false, error: { type: "NOT_FOUND", message: "タスクが見つかりません" } };
}
}
throw err;
}
}
| コード | 意味 | 対応 |
|---|---|---|
P2002 | ユニーク制約違反 | CONFLICT エラーとして返す |
P2025 | レコードが見つからない | NOT_FOUND エラーとして返す |
P2003 | 外部キー制約違反 | INVALID_REFERENCE エラーとして返す |
| 機能 | 対応方針 |
|---|---|
| Temporal Table | 非サポート。監査ログ(ADR-0011)で代替 |
| Full-Text Search (CONTAINS / FREETEXT) | $queryRaw + Prisma.sql で実装 |
| ROWVERSION (楽観的ロック) | Bytes 型で取扱 |
NEWSEQUENTIALID() | @default(dbgenerated("NEWSEQUENTIALID()")) または UUID v7 |
updatedAt の楽観的ロックは同一ミリ秒の更新で競合を見落す可能性あり。
ROWVERSION は更新ごとに自動インクリメント。
⚠️ Prisma 7 破壊的変更:
@db.RowVersionネイティブ型は Prisma 7 で未サポート。Bytes型のみで定義し、マイグレーション SQL でROWVERSION型に手動変更するか、$queryRawで ROWVERSION カラムを操作すること。
model Task {
// ... 他フィールド
// Prisma 7: @db.RowVersion は使用不可。Bytes で定義
rowVersion Bytes @map("ROW_VERSION")
}
// 楽観的ロック: rowVersion 確認
async update(
id: string,
data: TaskPatch,
expectedRowVersion: Buffer,
): Promise<Result<Task>> {
const updated = await this.prisma.task.updateMany({
where: { id, deleteFlg: 0, rowVersion: expectedRowVersion },
data: { ...data, updatedAt: this.clock.now() },
});
if (updated.count === 0) {
return {
ok: false,
error: { type: "CONFLICT", message: "他のユーザーにより変更されています。再読み込みしてください。" },
};
}
return await this.findById(id);
}
ROWVERSIONは自動生成。@default不要。UPDATE でrowVersionを指定しないこと。
// $queryRaw + Prisma.sql で実装($queryRawUnsafe 禁止)
async searchByContent(keyword: string): Promise<Result<Task[]>> {
const tasks = await this.prisma.$queryRaw<Task[]>(
Prisma.sql`
SELECT * FROM tasks
WHERE CONTAINS(DESCRIPTION, ${keyword})
AND DELETE_FLG = 0
`,
);
return { ok: true, value: tasks };
}
SQL Server では、複数パスで同一テーブルにカスケードが到達する場合にエラーになる。
自己参照リレーションや Task → TaskDependency のような複数 FK では onDelete: NoAction, onUpdate: NoAction を明示すること。
// NG: SQL Server で循環カスケードエラー
task Task @relation("TaskSuccessor", fields: [taskId], references: [id])
// OK: NoAction で循環を断つ
task Task @relation("TaskSuccessor", fields: [taskId], references: [id], onDelete: NoAction, onUpdate: NoAction)
本プロジェクトで NoAction が必要なリレーション:
TaskDependency.task/TaskDependency.dependsOn(Task 経由の複数パス)UserRole.user/UserRole.role/UserRole.department(Department 経由の複数パス)Department.parent(自己参照)
$queryRawの戻り値はunknown[]。z.array(TaskSchema).parse()でバリデーションすること。
docs/design/database/table-definition.md — エンティティ定義(SSOT)docs/decisions/0003-database-sqlserver-prisma.md — 採用経緯.github/instructions/api-design.instructions.md — 削除ポリシー・Clock ルール