Prisma ORMの利用:型安全なデータベース操作

2025-08-28

はじめに

前回はMySQLと生SQLを使用してデータベース連携を実装しました。しかし、生SQLにはいくつかの課題があります:

  • タイプミスや構文エラーが実行時まで発見できない
  • テーブルスキーマ変更時の手作業での修正が必要
  • データベースの種類による方言の違いに対応する必要がある

これらの課題を解決するために、今回はPrisma ORMを導入します。Prismaは現代的なTypeScript/JavaScript向けORMで、型安全なデータベース操作を提供します。

Prismaとは

Prismaは次の主要コンポーネントから構成されるオープンソースのデータベースツールキットです:

  1. Prisma Client: 型安全なデータベースクライアント
  2. Prisma Migrate: データベースマイグレーションシステム
  3. Prisma Studio: データベースのデータを閲覧・編集するGUIツール

Prismaのメリット

  1. 型安全性: TypeScriptと完全な統合により、コンパイル時にエラーを検出
  2. 直感的なAPI: チェーン形式のクエリビルダー
  3. データベース抽象化: 同じコードで多种類のデータベースを操作可能
  4. マイグレーション: スキーマ変更のバージョン管理
  5. パフォーマンス: 最適化されたクエリ生成

プロジェクトへのPrisma導入

1. Prismaのインストール

npm install prisma --save-dev
npm install @prisma/client

2. Prismaの初期化

npx prisma init

このコマンドは以下のファイルを作成します:

  • prisma/schema.prisma: Prismaスキーマファイル
  • .env: 環境変数ファイル(データベース接続URL)

3. データベース接続の設定

.envファイルを編集してデータベース接続URLを設定します:

# .env
DATABASE_URL="mysql://app_user:password@localhost:3306/todo_app"

4. スキーマの定義

prisma/schema.prismaファイルを編集してデータモデルを定義します:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("todos")
}

// ユーザーモデル(後の認証機能で使用)
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  name      String?
  todos     Todo[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

データベースマイグレーション

1. 初回マイグレーションの実行

npx prisma migrate dev --name init

このコマンドは:

  1. マイグレーションSQLファイルを生成
  2. データベースにスキーマを適用
  3. Prisma Clientを生成

2. マイグレーションの確認

npx prisma migrate status

Prisma Clientの基本操作

1. Prisma Clientの初期化

// lib/prisma.js
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
});

module.exports = prisma;

2. CRUD操作の実装

前回のMySQL直接操作をPrismaを使用するように書き換えましょう:

// server.js
const express = require('express');
const prisma = require('./lib/prisma'); // Prisma Client

const app = express();
const port = 3000;

app.use(express.json());

// GET /todos - すべてのTODOを取得
app.get('/todos', async (req, res) => {
  try {
    const todos = await prisma.todo.findMany({
      orderBy: { createdAt: 'desc' }
    });
    res.json(todos);
  } catch (error) {
    console.error('データ取得エラー:', error);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

// GET /todos/:id - 特定のTODOを取得
app.get('/todos/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);
    const todo = await prisma.todo.findUnique({
      where: { id }
    });

    if (!todo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    res.json(todo);
  } catch (error) {
    console.error('データ取得エラー:', error);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

// POST /todos - 新しいTODOを作成
app.post('/todos', async (req, res) => {
  try {
    const { title, completed = false } = req.body;

    if (!title || title.trim() === '') {
      return res.status(400).json({ error: 'タイトルは必須です' });
    }

    const todo = await prisma.todo.create({
      data: {
        title: title.trim(),
        completed
      }
    });

    res.status(201).json(todo);
  } catch (error) {
    console.error('データ作成エラー:', error);
    res.status(500).json({ error: 'TODOの作成に失敗しました' });
  }
});

// PUT /todos/:id - TODOを更新
app.put('/todos/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);
    const { title, completed } = req.body;

    // 存在確認
    const existingTodo = await prisma.todo.findUnique({
      where: { id }
    });

    if (!existingTodo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    const todo = await prisma.todo.update({
      where: { id },
      data: {
        title: title !== undefined ? title : existingTodo.title,
        completed: completed !== undefined ? completed : existingTodo.completed
      }
    });

    res.json(todo);
  } catch (error) {
    console.error('データ更新エラー:', error);
    res.status(500).json({ error: 'TODOの更新に失敗しました' });
  }
});

// DELETE /todos/:id - TODOを削除
app.delete('/todos/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id);

    // 存在確認
    const existingTodo = await prisma.todo.findUnique({
      where: { id }
    });

    if (!existingTodo) {
      return res.status(404).json({ error: 'TODOが見つかりません' });
    }

    await prisma.todo.delete({
      where: { id }
    });

    res.status(204).send();
  } catch (error) {
    console.error('データ削除エラー:', error);
    res.status(500).json({ error: 'TODOの削除に失敗しました' });
  }
});

// 高度なクエリの例
app.get('/todos/stats', async (req, res) => {
  try {
    const total = await prisma.todo.count();
    const completed = await prisma.todo.count({
      where: { completed: true }
    });
    const pending = await prisma.todo.count({
      where: { completed: false }
    });

    res.json({
      total,
      completed,
      pending,
      completionRate: total > 0 ? (completed / total * 100).toFixed(1) + '%' : '0%'
    });
  } catch (error) {
    console.error('統計取得エラー:', error);
    res.status(500).json({ error: '統計の取得に失敗しました' });
  }
});

// ページネーションの例
app.get('/todos/paginated', async (req, res) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const skip = (page - 1) * limit;

    const [todos, total] = await Promise.all([
      prisma.todo.findMany({
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' }
      }),
      prisma.todo.count()
    ]);

    res.json({
      todos,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
        hasNext: page < Math.ceil(total / limit),
        hasPrev: page > 1
      }
    });
  } catch (error) {
    console.error('ページネーションエラー:', error);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

// エラーハンドリングミドルウェア
app.use((error, req, res, next) => {
  console.error('予期せぬエラー:', error);
  res.status(500).json({ error: 'サーバー内部エラーが発生しました' });
});

// アプリケーション終了時のPrisma接続クローズ
process.on('beforeExit', async () => {
  await prisma.$disconnect();
});

app.listen(port, () => {
  console.log(`TODO APIサーバーが起動しました: http://localhost:${port}`);
});

Prismaの高度な機能

1. リレーションの扱い

// ユーザーとそのTODOを一緒に取得
app.get('/users/:userId/todos', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);

    const userWithTodos = await prisma.user.findUnique({
      where: { id: userId },
      include: {
        todos: {
          orderBy: { createdAt: 'desc' }
        }
      }
    });

    if (!userWithTodos) {
      return res.status(404).json({ error: 'ユーザーが見つかりません' });
    }

    res.json(userWithTodos);
  } catch (error) {
    console.error('ユーザーTODO取得エラー:', error);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

2. トランザクション処理

// トランザクションの例
app.post('/todos/batch', async (req, res) => {
  try {
    const { todos } = req.body;

    const result = await prisma.$transaction(async (tx) => {
      const createdTodos = [];

      for (const todoData of todos) {
        const todo = await tx.todo.create({
          data: {
            title: todoData.title,
            completed: todoData.completed || false
          }
        });
        createdTodos.push(todo);
      }

      return createdTodos;
    });

    res.status(201).json(result);
  } catch (error) {
    console.error('一括登録エラー:', error);
    res.status(500).json({ error: '一括登録に失敗しました' });
  }
});

3. 集計関数

// 日別のTODO作成統計
app.get('/todos/daily-stats', async (req, res) => {
  try {
    const dailyStats = await prisma.todo.groupBy({
      by: ['createdAt'],
      _count: {
        id: true
      },
      where: {
        createdAt: {
          gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 過去30日間
        }
      },
      orderBy: {
        createdAt: 'asc'
      }
    });

    res.json(dailyStats);
  } catch (error) {
    console.error('統計取得エラー:', error);
    res.status(500).json({ error: '統計の取得に失敗しました' });
  }
});

Prisma Studioの使用

Prismaに付属するGUIツールでデータベースを可視化できます:

npx prisma studio

このコマンドを実行すると、ブラウザでhttp://localhost:5555が開き、データの閲覧や編集が可能になります。

開発時の便利なコマンド

# スキーマ変更後のマイグレーション
npx prisma migrate dev --name add_description_field

# プロダクション環境でのマイグレーション
npx prisma migrate deploy

# Prisma Clientの再生成(スキーマ変更後)
npx prisma generate

# データベースのリセット(開発環境用)
npx prisma migrate reset

次のステップ

Prismaを使用することで、型安全で直感的なデータベース操作が可能になりました。次回は、Fetch APIとAxiosを使用して、ReactフロントエンドからこのAPIに接続する方法を学びます。これにより、フロントエンドとバックエンドの完全な連携が実現します。

まとめ

Prisma ORMの導入により、以下のメリットを得ました:

  1. 型安全性の向上: コンパイル時エラーチェック
  2. 生産性の向上: 直感的なAPIと自動補完
  3. 保守性の向上: スキーマの一元管理
  4. データベース抽象化: 多种類のデータベースに対応
  5. マイグレーション管理: スキーマ変更のバージョン管理

Prismaは現代的なWebアプリケーション開発に不可欠なツールであり、TypeScriptとの相性も抜群です。次回はこのAPIをReactアプリケーションから呼び出す方法を学びましょう。