T-CREATOR

Prisma Client の使い方:基本クエリから応用まで

Prisma Client の使い方:基本クエリから応用まで

データベース操作の新しい時代へようこそ。あなたが今まで感じていた「なぜ SQL は複雑なのか」「なぜ型エラーが頻発するのか」といった悩みを、Prisma Client が一挙に解決してくれるでしょう。

現代の Web アプリケーション開発において、データベースとの連携は避けて通れない道です。しかし、従来のアプローチでは開発者を悩ませる多くの課題がありました。Prisma Client は、そんな課題を解決し、開発体験を劇的に向上させる革新的なツールなのです。

この記事では、Prisma Client の基本的な使い方から実践的な応用まで、段階的に学んでいただけるよう構成しています。あなたの開発スキルが一段階上がることをお約束いたします。

背景

従来の ORM/データベースアクセスの課題

これまでの Web アプリケーション開発では、データベースとの連携において多くの困難がありました。生の SQL を書く場合、複雑なクエリになると可読性が著しく低下し、メンテナンスが困難になります。

また、従来の ORM を使用する場合でも、以下のような問題が頻繁に発生していました。

#課題具体的な問題
1型安全性の不足実行時まで型エラーが発見されない
2SQL インジェクションセキュリティリスクの増大
3N+1 問題パフォーマンスの劣化
4設定の複雑さ初期設定に時間がかかる

Prisma Client が解決する問題

Prisma Client は、これらの課題を根本的に解決します。特に注目すべきは、型安全性開発者体験の向上です。

コンパイル時に型チェックが行われるため、データベーススキーマとコードの不整合を事前に検出できます。これにより、本番環境でのトラブルを大幅に減らすことができるのです。

課題

型安全性の欠如

従来のデータベースアクセスでは、以下のようなコードが一般的でした。

javascript// 従来のアプローチ - 型安全ではない
const user = await db.query(
  'SELECT * FROM users WHERE id = ?',
  [userId]
);
console.log(user.nmae); // タイポがあってもコンパイル時に検出されない

このコードの問題点は、user.nmaeのタイポがコンパイル時に検出されないことです。実行時になって初めてエラーが発生し、本番環境でのトラブルの原因となっていました。

複雑なクエリの記述

リレーションを含む複雑なクエリを書く際、SQL の記述が非常に煩雑になります。

sql-- 複雑なJOINクエリの例
SELECT u.*, p.title, p.content, c.name as category_name
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
LEFT JOIN categories c ON p.category_id = c.id
WHERE u.active = 1
ORDER BY p.created_at DESC
LIMIT 10;

このようなクエリは可読性が低く、メンテナンスが困難です。また、スキーマが変更された際の修正漏れも発生しやすくなります。

パフォーマンスの最適化

N+1 問題は、特に深刻なパフォーマンス課題でした。

javascript// N+1問題の例
const users = await User.findAll();
for (const user of users) {
  // 各ユーザーに対してクエリが実行される(N+1問題)
  const posts = await Post.findAll({
    where: { authorId: user.id },
  });
  user.posts = posts;
}

解決策

Prisma Client の特徴と利点

Prisma Client は、これらの課題を以下の特徴で解決します。

#特徴利点
1型安全な自動生成コンパイル時の型チェック
2直感的な API学習コストの低減
3自動最適化N+1 問題の自動解決
4リレーション操作複雑なクエリの簡素化

型安全なデータベースアクセス

Prisma Client を使用すると、以下のように型安全なコードが書けます。

typescript// Prisma Client - 型安全なアプローチ
const user = await prisma.user.findUnique({
  where: { id: userId },
});
console.log(user?.name); // 型チェックにより、タイポがコンパイル時に検出される

この例では、userオブジェクトの型が自動的に推論され、存在しないプロパティにアクセスしようとするとコンパイルエラーが発生します。

具体例

基本的な CRUD 操作

セットアップとインストール

まず、プロジェクトに Prisma を導入しましょう。以下のコマンドで必要なパッケージをインストールします。

bash# Prismaのインストール
yarn add prisma @prisma/client

# Prisma CLIの初期化
yarn prisma init

次に、schema.prismaファイルでデータベースモデルを定義します。

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

datasource db {
  provider = "postgresql" // または "mysql", "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

スキーマを定義した後、以下のコマンドで Prisma Client を生成します。

bash# データベースマイグレーション
yarn prisma migrate dev --name init

# Prisma Clientの生成
yarn prisma generate

Create(作成)操作

新しいデータを作成する際の Prisma Client の使い方をご紹介します。

typescript// ユーザーの作成
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const createUser = async () => {
  try {
    const user = await prisma.user.create({
      data: {
        email: 'john@example.com',
        name: 'John Doe',
      },
    });

    console.log('ユーザーが正常に作成されました:', user);
    return user;
  } catch (error) {
    console.error('ユーザー作成エラー:', error);
    throw error;
  }
};

関連データと一緒に作成する場合は、ネストした create 操作を使用します。

typescript// ユーザーと投稿を同時に作成
const createUserWithPost = async () => {
  try {
    const userWithPost = await prisma.user.create({
      data: {
        email: 'jane@example.com',
        name: 'Jane Smith',
        posts: {
          create: [
            {
              title: '初投稿',
              content: 'Prisma Clientを使ってみました!',
              published: true,
            },
          ],
        },
      },
      include: {
        posts: true, // 作成された投稿も含めて返す
      },
    });

    return userWithPost;
  } catch (error) {
    if (error.code === 'P2002') {
      console.error('メールアドレスが既に使用されています');
    }
    throw error;
  }
};

Read(読み取り)操作

データの取得方法について詳しく見ていきましょう。

typescript// 単一のユーザーを取得
const getUser = async (userId: number) => {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: {
      posts: {
        where: { published: true }, // 公開済みの投稿のみ
        orderBy: { createdAt: 'desc' },
      },
    },
  });

  if (!user) {
    throw new Error(
      `ユーザーID ${userId} が見つかりません`
    );
  }

  return user;
};

複数のレコードを取得する場合の例です。

typescript// 複数のユーザーを条件付きで取得
const getUsers = async (
  page: number = 1,
  limit: number = 10
) => {
  const skip = (page - 1) * limit;

  const users = await prisma.user.findMany({
    where: {
      posts: {
        some: { published: true }, // 公開済み投稿があるユーザーのみ
      },
    },
    include: {
      _count: {
        select: { posts: true }, // 投稿数を含める
      },
    },
    orderBy: { createdAt: 'desc' },
    skip,
    take: limit,
  });

  return users;
};

Update(更新)操作

データの更新処理を実装してみましょう。

typescript// 単一のユーザー情報を更新
const updateUser = async (
  userId: number,
  data: { name?: string; email?: string }
) => {
  try {
    const updatedUser = await prisma.user.update({
      where: { id: userId },
      data,
      include: {
        posts: { take: 5 }, // 最新の5投稿を含める
      },
    });

    return updatedUser;
  } catch (error) {
    if (error.code === 'P2025') {
      throw new Error('更新対象のユーザーが見つかりません');
    }
    if (error.code === 'P2002') {
      throw new Error(
        '指定されたメールアドレスは既に使用されています'
      );
    }
    throw error;
  }
};

複数のレコードを一括更新する場合の例です。

typescript// 複数の投稿を一括更新
const publishPosts = async (authorId: number) => {
  const result = await prisma.post.updateMany({
    where: {
      authorId,
      published: false,
    },
    data: {
      published: true,
    },
  });

  console.log(`${result.count}件の投稿が公開されました`);
  return result;
};

Delete(削除)操作

安全なデータ削除の実装方法をご紹介します。

typescript// 単一の投稿を削除
const deletePost = async (postId: number) => {
  try {
    const deletedPost = await prisma.post.delete({
      where: { id: postId },
    });

    console.log('投稿が削除されました:', deletedPost.title);
    return deletedPost;
  } catch (error) {
    if (error.code === 'P2025') {
      throw new Error('削除対象の投稿が見つかりません');
    }
    throw error;
  }
};

関連データと一緒に削除する場合の例です。

typescript// ユーザーとその投稿を全て削除
const deleteUserWithPosts = async (userId: number) => {
  // トランザクションを使用して安全に削除
  const result = await prisma.$transaction(async (tx) => {
    // まず投稿を削除
    const deletedPosts = await tx.post.deleteMany({
      where: { authorId: userId },
    });

    // その後ユーザーを削除
    const deletedUser = await tx.user.delete({
      where: { id: userId },
    });

    return {
      deletedUser,
      deletedPostsCount: deletedPosts.count,
    };
  });

  return result;
};

中級クエリ

リレーション操作

Prisma Client の真の力は、リレーション操作で発揮されます。複雑なデータ構造も直感的に扱えるのです。

typescript// 深いネストのリレーションデータを取得
const getUserWithDetailedPosts = async (userId: number) => {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: {
      posts: {
        include: {
          comments: {
            include: {
              author: {
                select: { name: true, email: true }, // 必要なフィールドのみ選択
              },
            },
          },
          _count: {
            select: { comments: true, likes: true },
          },
        },
      },
    },
  });

  return user;
};

リレーションの作成と更新を同時に行う例です。

typescript// 既存のユーザーに新しい投稿を関連付け
const addPostToUser = async (
  userId: number,
  postData: { title: string; content: string }
) => {
  const updatedUser = await prisma.user.update({
    where: { id: userId },
    data: {
      posts: {
        create: {
          title: postData.title,
          content: postData.content,
          published: false,
        },
      },
    },
    include: {
      posts: {
        orderBy: { createdAt: 'desc' },
        take: 1, // 最新の投稿のみ
      },
    },
  });

  return updatedUser;
};

フィルタリングとソート

高度な検索機能を実装してみましょう。

typescript// 複雑な条件でのフィルタリング
const searchPosts = async (searchParams: {
  keyword?: string;
  authorName?: string;
  published?: boolean;
  dateFrom?: Date;
  dateTo?: Date;
}) => {
  const {
    keyword,
    authorName,
    published,
    dateFrom,
    dateTo,
  } = searchParams;

  const posts = await prisma.post.findMany({
    where: {
      AND: [
        // キーワード検索(タイトルまたは内容)
        keyword
          ? {
              OR: [
                {
                  title: {
                    contains: keyword,
                    mode: 'insensitive',
                  },
                },
                {
                  content: {
                    contains: keyword,
                    mode: 'insensitive',
                  },
                },
              ],
            }
          : {},

        // 著者名での検索
        authorName
          ? {
              author: {
                name: {
                  contains: authorName,
                  mode: 'insensitive',
                },
              },
            }
          : {},

        // 公開状態での絞り込み
        published !== undefined ? { published } : {},

        // 日付範囲での絞り込み
        dateFrom ? { createdAt: { gte: dateFrom } } : {},
        dateTo ? { createdAt: { lte: dateTo } } : {},
      ],
    },
    include: {
      author: {
        select: { name: true },
      },
      _count: {
        select: { comments: true },
      },
    },
    orderBy: [
      { published: 'desc' }, // 公開済みを優先
      { createdAt: 'desc' }, // 新しい順
    ],
  });

  return posts;
};

ページネーション

効率的なページネーション機能を実装します。

typescript// カーソルベースのページネーション
const getPostsWithCursorPagination = async (
  cursor?: number,
  limit: number = 10
) => {
  const posts = await prisma.post.findMany({
    take: limit + 1, // 次のページがあるかチェック用に+1
    cursor: cursor ? { id: cursor } : undefined,
    skip: cursor ? 1 : 0, // カーソルレコード自体をスキップ
    where: { published: true },
    include: {
      author: {
        select: { name: true },
      },
    },
    orderBy: { createdAt: 'desc' },
  });

  const hasNextPage = posts.length > limit;
  const postsToReturn = hasNextPage
    ? posts.slice(0, -1)
    : posts;
  const nextCursor = hasNextPage
    ? postsToReturn[postsToReturn.length - 1].id
    : null;

  return {
    posts: postsToReturn,
    hasNextPage,
    nextCursor,
  };
};

オフセットベースのページネーションも実装できます。

typescript// オフセットベースのページネーション(総数付き)
const getPostsWithOffsetPagination = async (
  page: number = 1,
  limit: number = 10
) => {
  const skip = (page - 1) * limit;

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      skip,
      take: limit,
      where: { published: true },
      include: {
        author: {
          select: { name: true },
        },
      },
      orderBy: { createdAt: 'desc' },
    }),
    prisma.post.count({
      where: { published: true },
    }),
  ]);

  const totalPages = Math.ceil(total / limit);

  return {
    posts,
    pagination: {
      currentPage: page,
      totalPages,
      totalItems: total,
      itemsPerPage: limit,
      hasNextPage: page < totalPages,
      hasPreviousPage: page > 1,
    },
  };
};

応用クエリ

トランザクション

データの整合性を保つトランザクション処理を実装しましょう。

typescript// 複雑なビジネスロジックをトランザクションで処理
const transferUserPosts = async (
  fromUserId: number,
  toUserId: number
) => {
  try {
    const result = await prisma.$transaction(async (tx) => {
      // 移譲元ユーザーの確認
      const fromUser = await tx.user.findUnique({
        where: { id: fromUserId },
        include: { _count: { select: { posts: true } } },
      });

      if (!fromUser) {
        throw new Error('移譲元ユーザーが見つかりません');
      }

      // 移譲先ユーザーの確認
      const toUser = await tx.user.findUnique({
        where: { id: toUserId },
      });

      if (!toUser) {
        throw new Error('移譲先ユーザーが見つかりません');
      }

      // 投稿の移譲
      const updatedPosts = await tx.post.updateMany({
        where: { authorId: fromUserId },
        data: { authorId: toUserId },
      });

      // ログの作成
      await tx.transferLog.create({
        data: {
          fromUserId,
          toUserId,
          postsCount: updatedPosts.count,
          transferredAt: new Date(),
        },
      });

      return {
        transferredPosts: updatedPosts.count,
        fromUser: fromUser.name,
        toUser: toUser.name,
      };
    });

    return result;
  } catch (error) {
    console.error('投稿移譲エラー:', error);
    throw error;
  }
};

集計関数

データの分析に便利な集計機能を活用しましょう。

typescript// ユーザー統計情報の取得
const getUserStatistics = async () => {
  const stats = await prisma.user.aggregate({
    _count: {
      id: true,
    },
    _avg: {
      posts: {
        _count: true,
      },
    },
    _max: {
      createdAt: true,
    },
    _min: {
      createdAt: true,
    },
  });

  // 月別のユーザー登録数
  const monthlyStats = await prisma.$queryRaw`
    SELECT 
      DATE_TRUNC('month', "createdAt") as month,
      COUNT(*) as user_count
    FROM "User"
    GROUP BY DATE_TRUNC('month', "createdAt")
    ORDER BY month DESC
    LIMIT 12
  `;

  return {
    totalUsers: stats._count.id,
    averagePostsPerUser: stats._avg.posts?._count || 0,
    oldestUser: stats._min.createdAt,
    newestUser: stats._max.createdAt,
    monthlyRegistrations: monthlyStats,
  };
};

グループ化された集計も簡単に実行できます。

typescript// 投稿の統計情報(著者別)
const getPostStatsByAuthor = async () => {
  const authorStats = await prisma.post.groupBy({
    by: ['authorId'],
    _count: {
      id: true,
    },
    _avg: {
      viewCount: true,
    },
    having: {
      id: {
        _count: {
          gt: 5, // 5投稿以上の著者のみ
        },
      },
    },
    orderBy: {
      _count: {
        id: 'desc',
      },
    },
  });

  // 著者情報を追加
  const enrichedStats = await Promise.all(
    authorStats.map(async (stat) => {
      const author = await prisma.user.findUnique({
        where: { id: stat.authorId },
        select: { name: true, email: true },
      });

      return {
        author,
        postCount: stat._count.id,
        averageViews: stat._avg.viewCount,
      };
    })
  );

  return enrichedStats;
};

Raw クエリ

Prisma Client では、必要に応じて生の SQL クエリも実行できます。

typescript// 複雑な分析クエリの実行
const getAdvancedAnalytics = async (
  startDate: Date,
  endDate: Date
) => {
  try {
    const result = await prisma.$queryRaw`
      WITH post_metrics AS (
        SELECT 
          p.id,
          p.title,
          p."authorId",
          u.name as author_name,
          p."createdAt",
          COUNT(c.id) as comment_count,
          COUNT(l.id) as like_count
        FROM "Post" p
        LEFT JOIN "User" u ON p."authorId" = u.id
        LEFT JOIN "Comment" c ON p.id = c."postId"
        LEFT JOIN "Like" l ON p.id = l."postId"
        WHERE p."createdAt" BETWEEN ${startDate} AND ${endDate}
        GROUP BY p.id, p.title, p."authorId", u.name, p."createdAt"
      )
      SELECT 
        author_name,
        COUNT(*) as post_count,
        AVG(comment_count) as avg_comments,
        AVG(like_count) as avg_likes,
        MAX(comment_count + like_count) as max_engagement
      FROM post_metrics
      GROUP BY author_name
      ORDER BY avg_likes DESC
      LIMIT 10
    `;

    return result;
  } catch (error) {
    console.error('分析クエリエラー:', error);
    throw error;
  }
};

型安全な Raw クエリの実行方法もあります。

typescript// 型安全なRawクエリ
interface PostAnalytics {
  authorName: string;
  postCount: bigint;
  avgComments: number;
  avgLikes: number;
}

const getTypedAnalytics = async (): Promise<
  PostAnalytics[]
> => {
  const result = await prisma.$queryRaw<PostAnalytics[]>`
    SELECT 
      u.name as "authorName",
      COUNT(p.id) as "postCount",
      AVG(COALESCE(comment_stats.comment_count, 0)) as "avgComments",
      AVG(COALESCE(like_stats.like_count, 0)) as "avgLikes"
    FROM "User" u
    LEFT JOIN "Post" p ON u.id = p."authorId"
    LEFT JOIN (
      SELECT "postId", COUNT(*) as comment_count
      FROM "Comment"
      GROUP BY "postId"
    ) comment_stats ON p.id = comment_stats."postId"
    LEFT JOIN (
      SELECT "postId", COUNT(*) as like_count
      FROM "Like"
      GROUP BY "postId"
    ) like_stats ON p.id = like_stats."postId"
    WHERE p.published = true
    GROUP BY u.id, u.name
    HAVING COUNT(p.id) > 0
    ORDER BY "avgLikes" DESC
  `;

  return result;
};

バッチ処理

大量のデータを効率的に処理するバッチ操作を実装しましょう。

typescript// 大量のユーザーデータを一括作成
const createUsersInBatch = async (
  userData: Array<{ email: string; name: string }>
) => {
  const batchSize = 1000; // 1000件ずつ処理
  const results = [];

  for (let i = 0; i < userData.length; i += batchSize) {
    const batch = userData.slice(i, i + batchSize);

    try {
      const batchResult = await prisma.user.createMany({
        data: batch,
        skipDuplicates: true, // 重複をスキップ
      });

      results.push(batchResult);
      console.log(
        `バッチ ${Math.floor(i / batchSize) + 1}: ${
          batchResult.count
        }件作成`
      );

      // メモリを解放するため少し待機
      await new Promise((resolve) =>
        setTimeout(resolve, 100)
      );
    } catch (error) {
      console.error(
        `バッチ ${Math.floor(i / batchSize) + 1} でエラー:`,
        error
      );
      throw error;
    }
  }

  const totalCreated = results.reduce(
    (sum, result) => sum + result.count,
    0
  );
  return { totalCreated, batchCount: results.length };
};

データの一括更新処理も効率的に実行できます。

typescript// 条件に基づく一括更新
const updateInactiveUsers = async () => {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  // 30日間ログインしていないユーザーを非アクティブに
  const result = await prisma.user.updateMany({
    where: {
      lastLoginAt: {
        lt: thirtyDaysAgo,
      },
      active: true,
    },
    data: {
      active: false,
      deactivatedAt: new Date(),
    },
  });

  console.log(
    `${result.count}人のユーザーが非アクティブになりました`
  );

  // 非アクティブユーザーの投稿も非公開に
  const postResult = await prisma.post.updateMany({
    where: {
      author: {
        active: false,
      },
      published: true,
    },
    data: {
      published: false,
    },
  });

  return {
    deactivatedUsers: result.count,
    unpublishedPosts: postResult.count,
  };
};

まとめ

Prisma Client は、現代の Web アプリケーション開発において必須のツールといっても過言ではありません。型安全性、直感的な API、そして強力な機能により、開発者の生産性を大幅に向上させてくれます。

この記事で学んだ内容を振り返ってみましょう。

#学習内容得られる価値
1基本的な CRUD 操作安全で効率的なデータ操作
2リレーション操作複雑なデータ構造の簡単な扱い
3高度なクエリ柔軟で強力なデータ取得
4トランザクションデータ整合性の保証
5集計と Raw クエリ分析機能とパフォーマンス最適化

Prisma Client を使いこなすことで、あなたの開発スキルは確実に向上します。エラーハンドリングも含めた実践的なコードを書けるようになり、保守性の高いアプリケーションを構築できるようになるでしょう。

今日から始めて、Prisma Client の威力を実感してください。きっと、従来のデータベース操作には戻れなくなるはずです。

関連リンク