Prismaは、Node.js/TypeScript向けの次世代ORMです。型安全なクエリ、直感的なスキーマ定義、強力なマイグレーション機能を備え、モダンなアプリケーション開発に最適です。本記事では、実践的なPrismaの活用方法を解説します。
Prismaの基本構成
flowchart TB
App["Your Application<br/>(TypeScript)"]
Client["Prisma Client<br/>(Generated Type-Safe API)"]
Engine["Prisma Engine<br/>(Query Engine + Rust)"]
DB["Database<br/>(PostgreSQL, MySQL, SQLite, etc.)"]
App --> Client --> Engine --> DB
セットアップ
1. インストールと初期化
# Prisma CLIとクライアントのインストール
npm install prisma --save-dev
npm install @prisma/client
# Prismaの初期化(PostgreSQLの場合)
npx prisma init --datasource-provider postgresql
2. 環境変数の設定
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
# 本番環境用(接続プール付き)
DATABASE_URL="postgresql://user:password@db.example.com:5432/mydb?schema=public&connection_limit=10&pool_timeout=30"
スキーマ設計
基本的なスキーマ定義
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch", "filteredRelationCount"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ユーザーモデル
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// リレーション
posts Post[]
comments Comment[]
profile Profile?
sessions Session[]
@@index([email])
@@map("users") // テーブル名をカスタマイズ
}
model Profile {
id String @id @default(cuid())
bio String?
avatar String?
website String?
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String?
excerpt String?
published Boolean @default(false)
publishedAt DateTime?
viewCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// リレーション
authorId String
author User @relation(fields: [authorId], references: [id])
categories Category[]
tags Tag[]
comments Comment[]
@@index([authorId])
@@index([slug])
@@index([published, publishedAt])
@@map("posts")
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post[]
@@map("categories")
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
@@map("tags")
}
model Comment {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// リレーション
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id])
// 自己参照リレーション(返信機能)
parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@index([postId])
@@index([authorId])
@@map("comments")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
expires DateTime
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// 列挙型
enum Role {
USER
ADMIN
MODERATOR
}
高度なスキーマ機能
// 複合ユニークキー
model OrderItem {
orderId String
productId String
quantity Int
price Decimal @db.Decimal(10, 2)
order Order @relation(fields: [orderId], references: [id])
product Product @relation(fields: [productId], references: [id])
@@id([orderId, productId]) // 複合主キー
@@map("order_items")
}
// JSONフィールドと配列
model Product {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
metadata Json? // JSONフィールド
images String[] // 配列(PostgreSQL)
orderItems OrderItem[]
@@map("products")
}
// フルテキスト検索用インデックス(PostgreSQL)
model Article {
id String @id @default(cuid())
title String
content String
@@index([title, content], type: Gin) // GINインデックス
@@map("articles")
}
マイグレーション
基本的なマイグレーションフロー
# 開発環境:スキーマ変更を検出してマイグレーション作成
npx prisma migrate dev --name init
# マイグレーションのみ作成(適用しない)
npx prisma migrate dev --create-only --name add_user_avatar
# 本番環境:マイグレーション適用
npx prisma migrate deploy
# マイグレーション状態の確認
npx prisma migrate status
# データベースをリセット(開発環境のみ)
npx prisma migrate reset
マイグレーションのカスタマイズ
-- prisma/migrations/20250102_add_full_text_search/migration.sql
-- 手動でSQLを追加
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX posts_title_trgm_idx ON posts USING gin (title gin_trgm_ops);
CREATE INDEX posts_content_trgm_idx ON posts USING gin (content gin_trgm_ops);
-- 検索用の関数を作成
CREATE OR REPLACE FUNCTION search_posts(search_query TEXT)
RETURNS SETOF posts AS $$
SELECT *
FROM posts
WHERE
title ILIKE '%' || search_query || '%'
OR content ILIKE '%' || search_query || '%'
ORDER BY
CASE
WHEN title ILIKE search_query THEN 0
WHEN title ILIKE search_query || '%' THEN 1
WHEN title ILIKE '%' || search_query || '%' THEN 2
ELSE 3
END,
published_at DESC NULLS LAST;
$$ LANGUAGE SQL STABLE;
Prisma Clientの使用
基本的なCRUD操作
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
// グローバルインスタンスを使用(開発時のホットリロード対応)
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// services/user.service.ts
import { prisma } from '@/lib/prisma';
import { Prisma, User, Role } from '@prisma/client';
import bcrypt from 'bcryptjs';
// 型定義
type UserCreateInput = {
email: string;
name?: string;
password: string;
};
type UserWithProfile = Prisma.UserGetPayload<{
include: { profile: true };
}>;
// ユーザー作成
async function createUser(data: UserCreateInput): Promise<User> {
const hashedPassword = await bcrypt.hash(data.password, 12);
return prisma.user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
profile: {
create: {}, // 空のプロフィールを同時作成
},
},
});
}
// 単一ユーザー取得
async function getUserById(id: string): Promise<UserWithProfile | null> {
return prisma.user.findUnique({
where: { id },
include: {
profile: true,
},
});
}
// メールでユーザー取得
async function getUserByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({
where: { email },
});
}
// ユーザー一覧取得(ページネーション付き)
async function getUsers(options: {
page?: number;
limit?: number;
role?: Role;
search?: string;
}): Promise<{ users: User[]; total: number }> {
const { page = 1, limit = 10, role, search } = options;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {
...(role && { role }),
...(search && {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return { users, total };
}
// ユーザー更新
async function updateUser(
id: string,
data: Prisma.UserUpdateInput
): Promise<User> {
return prisma.user.update({
where: { id },
data,
});
}
// ユーザー削除(カスケード削除)
async function deleteUser(id: string): Promise<void> {
await prisma.user.delete({
where: { id },
});
}
export const userService = {
createUser,
getUserById,
getUserByEmail,
getUsers,
updateUser,
deleteUser,
};
リレーションを含むクエリ
// services/post.service.ts
import { prisma } from '@/lib/prisma';
import { Prisma, Post } from '@prisma/client';
// 投稿の型定義(リレーション込み)
const postWithRelations = Prisma.validator<Prisma.PostDefaultArgs>()({
include: {
author: {
select: {
id: true,
name: true,
email: true,
profile: {
select: { avatar: true },
},
},
},
categories: true,
tags: true,
_count: {
select: { comments: true },
},
},
});
type PostWithRelations = Prisma.PostGetPayload<typeof postWithRelations>;
// 公開済み投稿の取得
async function getPublishedPosts(options: {
page?: number;
limit?: number;
categorySlug?: string;
tagName?: string;
}): Promise<{ posts: PostWithRelations[]; total: number }> {
const { page = 1, limit = 10, categorySlug, tagName } = options;
const where: Prisma.PostWhereInput = {
published: true,
publishedAt: { lte: new Date() },
...(categorySlug && {
categories: {
some: { slug: categorySlug },
},
}),
...(tagName && {
tags: {
some: { name: tagName },
},
}),
};
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
...postWithRelations,
skip: (page - 1) * limit,
take: limit,
orderBy: { publishedAt: 'desc' },
}),
prisma.post.count({ where }),
]);
return { posts, total };
}
// スラッグで投稿取得(閲覧数を増加)
async function getPostBySlug(slug: string): Promise<PostWithRelations | null> {
const post = await prisma.post.update({
where: { slug, published: true },
data: {
viewCount: { increment: 1 },
},
...postWithRelations,
});
return post;
}
// 投稿作成
async function createPost(data: {
title: string;
content: string;
authorId: string;
categoryIds: string[];
tagNames: string[];
}): Promise<Post> {
const slug = generateSlug(data.title);
return prisma.post.create({
data: {
title: data.title,
slug,
content: data.content,
excerpt: data.content.slice(0, 200),
author: { connect: { id: data.authorId } },
categories: {
connect: data.categoryIds.map(id => ({ id })),
},
tags: {
connectOrCreate: data.tagNames.map(name => ({
where: { name },
create: { name },
})),
},
},
});
}
// 投稿の公開
async function publishPost(id: string): Promise<Post> {
return prisma.post.update({
where: { id },
data: {
published: true,
publishedAt: new Date(),
},
});
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.concat('-', Date.now().toString(36));
}
export const postService = {
getPublishedPosts,
getPostBySlug,
createPost,
publishPost,
};
トランザクション
// services/order.service.ts
import { prisma } from '@/lib/prisma';
import { Prisma } from '@prisma/client';
interface OrderItem {
productId: string;
quantity: number;
}
// 対話型トランザクション
async function createOrder(
userId: string,
items: OrderItem[]
): Promise<Order> {
return prisma.$transaction(async (tx) => {
// 1. 商品情報と在庫を確認
const products = await tx.product.findMany({
where: {
id: { in: items.map(item => item.productId) },
},
});
// 在庫チェック
for (const item of items) {
const product = products.find(p => p.id === item.productId);
if (!product) {
throw new Error(`Product not found: ${item.productId}`);
}
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for: ${product.name}`);
}
}
// 2. 注文を作成
const total = items.reduce((sum, item) => {
const product = products.find(p => p.id === item.productId)!;
return sum + product.price.toNumber() * item.quantity;
}, 0);
const order = await tx.order.create({
data: {
userId,
status: 'PENDING',
total: new Prisma.Decimal(total),
items: {
create: items.map(item => {
const product = products.find(p => p.id === item.productId)!;
return {
productId: item.productId,
quantity: item.quantity,
price: product.price,
};
}),
},
},
include: {
items: {
include: { product: true },
},
},
});
// 3. 在庫を減少
await Promise.all(
items.map(item =>
tx.product.update({
where: { id: item.productId },
data: {
stock: { decrement: item.quantity },
},
})
)
);
return order;
}, {
maxWait: 5000, // 最大待機時間
timeout: 10000, // タイムアウト
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
}
// バッチトランザクション(複数操作を一括実行)
async function transferPoints(
fromUserId: string,
toUserId: string,
amount: number
): Promise<void> {
await prisma.$transaction([
prisma.user.update({
where: { id: fromUserId },
data: { points: { decrement: amount } },
}),
prisma.user.update({
where: { id: toUserId },
data: { points: { increment: amount } },
}),
prisma.pointTransaction.create({
data: {
fromUserId,
toUserId,
amount,
type: 'TRANSFER',
},
}),
]);
}
高度なクエリ
// 集計クエリ
async function getPostStats(authorId: string) {
return prisma.post.aggregate({
where: { authorId },
_count: true,
_sum: { viewCount: true },
_avg: { viewCount: true },
_max: { viewCount: true },
});
}
// グループ化
async function getPostCountByCategory() {
return prisma.post.groupBy({
by: ['published'],
where: { publishedAt: { not: null } },
_count: { id: true },
_avg: { viewCount: true },
orderBy: { _count: { id: 'desc' } },
});
}
// 生SQLクエリ
async function searchPosts(query: string) {
return prisma.$queryRaw<Post[]>`
SELECT p.*,
ts_rank(to_tsvector('japanese', title || ' ' || content),
plainto_tsquery('japanese', ${query})) as rank
FROM posts p
WHERE to_tsvector('japanese', title || ' ' || content)
@@ plainto_tsquery('japanese', ${query})
ORDER BY rank DESC
LIMIT 20
`;
}
// フルテキスト検索(プレビュー機能)
async function searchPostsFullText(query: string) {
return prisma.post.findMany({
where: {
OR: [
{ title: { search: query } },
{ content: { search: query } },
],
},
orderBy: {
_relevance: {
fields: ['title', 'content'],
search: query,
sort: 'desc',
},
},
});
}
パフォーマンス最適化
N+1問題の解決
// ❌ N+1問題が発生するコード
async function getPostsWithAuthorsBad() {
const posts = await prisma.post.findMany();
// 各投稿ごとにクエリが発生(N回)
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author?.name);
}
}
// ✅ includeでリレーションを一括取得
async function getPostsWithAuthorsGood() {
const posts = await prisma.post.findMany({
include: {
author: {
select: { name: true },
},
},
});
for (const post of posts) {
console.log(post.title, post.author.name);
}
}
// ✅ selectで必要なフィールドのみ取得
async function getPostTitlesWithAuthors() {
return prisma.post.findMany({
select: {
id: true,
title: true,
author: {
select: { name: true },
},
},
});
}
接続プールの設定
// 本番環境向け設定
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
// 接続プール設定はURLパラメータで指定
// ?connection_limit=10&pool_timeout=30
});
// サーバーレス環境向け(Prisma Accelerate)
// schema.prismaで設定
// datasource db {
// provider = "postgresql"
// url = env("DATABASE_URL")
// directUrl = env("DIRECT_URL") // マイグレーション用
// }
クエリログとデバッグ
// 詳細なクエリログ
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'error' },
{ emit: 'stdout', level: 'warn' },
],
});
// クエリイベントのリスニング
prisma.$on('query', (e) => {
console.log('Query:', e.query);
console.log('Params:', e.params);
console.log('Duration:', e.duration, 'ms');
});
// スロークエリの検出
prisma.$on('query', (e) => {
if (e.duration > 100) {
console.warn(`Slow query (${e.duration}ms):`, e.query);
}
});
テスト戦略
テスト用のセットアップ
// tests/helpers/prisma.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ||
'postgresql://test:test@localhost:5432/test_db';
export function createTestPrismaClient(): PrismaClient {
return new PrismaClient({
datasources: {
db: { url: TEST_DATABASE_URL },
},
});
}
export async function resetDatabase(prisma: PrismaClient): Promise<void> {
// テーブルを削除順序を考慮してクリア
const tablenames = await prisma.$queryRaw<
Array<{ tablename: string }>
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await prisma.$executeRawUnsafe(
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`
);
}
}
}
// テストファクトリー
export function createUserFactory(prisma: PrismaClient) {
return {
create: async (overrides: Partial<Prisma.UserCreateInput> = {}) => {
return prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'hashed_password',
...overrides,
},
});
},
};
}
統合テスト
// tests/integration/user.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { createTestPrismaClient, resetDatabase, createUserFactory } from '../helpers/prisma';
import { userService } from '@/services/user.service';
describe('UserService', () => {
let prisma: PrismaClient;
let userFactory: ReturnType<typeof createUserFactory>;
beforeAll(async () => {
prisma = createTestPrismaClient();
userFactory = createUserFactory(prisma);
});
beforeEach(async () => {
await resetDatabase(prisma);
});
afterAll(async () => {
await prisma.$disconnect();
});
describe('createUser', () => {
it('should create a user with profile', async () => {
const user = await userService.createUser({
email: 'new@example.com',
name: 'New User',
password: 'password123',
});
expect(user.email).toBe('new@example.com');
expect(user.name).toBe('New User');
const userWithProfile = await prisma.user.findUnique({
where: { id: user.id },
include: { profile: true },
});
expect(userWithProfile?.profile).toBeDefined();
});
it('should throw on duplicate email', async () => {
await userFactory.create({ email: 'exists@example.com' });
await expect(
userService.createUser({
email: 'exists@example.com',
password: 'password123',
})
).rejects.toThrow();
});
});
describe('getUsers', () => {
it('should return paginated users', async () => {
// 15人のユーザーを作成
await Promise.all(
Array.from({ length: 15 }, (_, i) =>
userFactory.create({ email: `user${i}@example.com` })
)
);
const result = await userService.getUsers({ page: 1, limit: 10 });
expect(result.users).toHaveLength(10);
expect(result.total).toBe(15);
});
});
});
本番運用のベストプラクティス
エラーハンドリング
// lib/prisma-errors.ts
import { Prisma } from '@prisma/client';
export class DatabaseError extends Error {
constructor(
message: string,
public code: string,
public originalError?: Error
) {
super(message);
this.name = 'DatabaseError';
}
}
export function handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2002':
throw new DatabaseError(
`Unique constraint violation on ${error.meta?.target}`,
'UNIQUE_CONSTRAINT',
error
);
case 'P2025':
throw new DatabaseError(
'Record not found',
'NOT_FOUND',
error
);
case 'P2003':
throw new DatabaseError(
'Foreign key constraint failed',
'FOREIGN_KEY_CONSTRAINT',
error
);
default:
throw new DatabaseError(
`Database error: ${error.message}`,
error.code,
error
);
}
}
if (error instanceof Prisma.PrismaClientValidationError) {
throw new DatabaseError(
'Invalid data provided',
'VALIDATION_ERROR',
error
);
}
throw error;
}
// 使用例
async function safeCreateUser(data: UserCreateInput) {
try {
return await userService.createUser(data);
} catch (error) {
handlePrismaError(error);
}
}
ヘルスチェックと監視
// lib/health.ts
import { prisma } from './prisma';
export async function checkDatabaseHealth(): Promise<{
healthy: boolean;
latency: number;
error?: string;
}> {
const start = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return {
healthy: true,
latency: Date.now() - start,
};
} catch (error) {
return {
healthy: false,
latency: Date.now() - start,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// メトリクス収集
export async function getDatabaseMetrics() {
const [
userCount,
postCount,
activeConnections,
] = await Promise.all([
prisma.user.count(),
prisma.post.count(),
prisma.$queryRaw<[{ count: bigint }]>`
SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
`.then(r => Number(r[0].count)),
]);
return {
userCount,
postCount,
activeConnections,
timestamp: new Date().toISOString(),
};
}
まとめ
Prismaは、型安全性と開発者体験を重視したモダンなORMです。
Prismaの強み
| 特徴 | メリット |
|---|---|
| 型安全なクエリ | コンパイル時のエラー検出 |
| 直感的なスキーマ | Prisma Schemaで一元管理 |
| 自動マイグレーション | 安全なスキーマ変更 |
| リレーション処理 | N+1問題の回避が容易 |
採用時の注意点
- 複雑な生SQLが必要な場合は
$queryRawを活用 - 大量データの一括処理は
createMany/updateManyを使用 - サーバーレス環境では接続プールに注意
Prismaを活用することで、型安全で保守性の高いデータベース操作が実現できます。