database

Prisma ORM実践ガイド - 型安全なデータベース操作の極意

2025.12.02

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を活用することで、型安全で保守性の高いデータベース操作が実現できます。

参考リンク

← 一覧に戻る