T-CREATOR

Prisma でリレーション(1 対多・多対多)を徹底攻略

Prisma でリレーション(1 対多・多対多)を徹底攻略

データベース設計において、テーブル間の関係性を正しく構築することは、アプリケーションの成否を左右する重要な要素です。しかし、従来の ORM では複雑なリレーションの実装に多くの時間を費やし、エラーに悩まされた経験をお持ちの方も多いのではないでしょうか。

本記事では、次世代 ORM「Prisma」を使って、1 対多・多対多のリレーションを効率的に実装する方法を基礎から徹底解説します。実際のエラーコードとその解決策も含めて、あなたがリレーション設計で迷わないよう、実践的なノウハウをお伝えいたします。

背景:なぜリレーションが重要なのか

データベース設計の核心

モダンな Web アプリケーションでは、ユーザー・投稿・コメント・タグなど、複数のエンティティが複雑に関係し合います。これらの関係性を正しく設計することで、以下のメリットが得られます:

#メリット具体例
1データの整合性確保ユーザーが削除されても、そのユーザーの投稿は適切に管理される
2効率的なクエリ実行1 回のクエリで関連データを取得できる
3スケーラビリティ向上データ量が増加しても、パフォーマンスが維持される
4開発効率の向上複雑な JOIN クエリを書く必要がない

実世界での活用例

SNS アプリを例に考えてみましょう。ユーザーは複数の投稿を作成し(1 対多)、投稿には複数のタグが付けられ(多対多)、さらにユーザー同士がフォローし合う(多対多)という関係性が存在します。

これらの関係性を正しく設計することで、「特定のタグが付いた投稿をしたフォローしているユーザー一覧」といった複雑な条件での検索も、効率的に実行できるようになります。

課題:従来の ORM でのリレーション実装の難しさ

複雑なクエリ構築

従来の ORM では、リレーションを扱う際に以下のような課題がありました:

typescript// 従来のORMでの複雑なクエリ例
const usersWithPosts = await db.query(
  `
  SELECT u.*, p.title, p.content
  FROM users u
  LEFT JOIN posts p ON u.id = p.user_id
  WHERE u.created_at > ?
  ORDER BY u.created_at DESC
`,
  [new Date('2024-01-01')]
);

上記のような生の SQL を書く必要があり、型安全性が保証されませんでした。

よくある実装エラー

従来の ORM では、以下のようなエラーに遭遇することが頻繁にありました:

sqlError: Column 'user_id' cannot be null
Error: Cannot add or update a child row: a foreign key constraint fails
Error: Duplicate entry 'user_id-tag_id' for key 'unique_user_tag'

これらのエラーは、リレーションの設計や実装が複雑であることが原因でした。

N+1 問題の発生

特に深刻だったのが、N+1 問題です:

typescript// 問題のあるコード例
const users = await User.findAll(); // 1回のクエリ
for (const user of users) {
  const posts = await Post.findAll({
    where: { userId: user.id },
  }); // N回のクエリ
  console.log(`${user.name}: ${posts.length} posts`);
}

100 人のユーザーがいれば、101 回のクエリが実行されてしまいます。

解決策:Prisma スキーマでのリレーション定義方法

Prisma の革新的なアプローチ

Prisma では、schema.prismaファイルで直感的にリレーションを定義できます。型安全性が保証され、複雑なクエリも簡潔に書けるのが特徴です。

基本的なスキーマ構造

まず、Prisma スキーマの基本構造を理解しましょう:

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

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

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

リレーションの記述方法

Prisma では、以下の 3 種類のリレーションが定義できます:

#リレーション種別使用場面記述方法
11 対多(One-to-Many)ユーザー ↔ 投稿@relation
2多対多(Many-to-Many)投稿 ↔ タグ@relation + 中間テーブル
3自己参照(Self-Relation)カテゴリ階層@relation + 自己参照

具体例:1 対多リレーション(User ↔ Post)の実装

スキーマ定義

1 対多リレーションは、最も基本的なリレーションです。1 人のユーザーが複数の投稿を作成できる関係を実装してみましょう:

prisma// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 1対多リレーション:1人のユーザーが複数の投稿を持つ
  posts     Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 外部キー:投稿は1人のユーザーに属する
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
}

マイグレーション実行

スキーマを定義したら、データベースに反映させます:

bash# マイグレーション生成
yarn prisma migrate dev --name add_user_post_relation

# Prisma Client 再生成
yarn prisma generate

実装コード:データ作成

ユーザーと投稿を作成するコードを実装してみましょう:

typescript// user-post.service.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// ユーザーと投稿を同時に作成
async function createUserWithPosts() {
  try {
    const userWithPosts = await prisma.user.create({
      data: {
        email: 'john@example.com',
        name: 'John Doe',
        posts: {
          create: [
            {
              title: 'Prismaの基本的な使い方',
              content: 'Prismaは次世代のORMです...',
              published: true,
            },
            {
              title: 'TypeScriptとPrismaの組み合わせ',
              content: '型安全性が保証されます...',
              published: false,
            },
          ],
        },
      },
      include: {
        posts: true, // 作成した投稿も含めて取得
      },
    });

    console.log(
      'ユーザーと投稿を作成しました:',
      userWithPosts
    );
  } catch (error) {
    console.error('エラーが発生しました:', error);
  }
}

実装コード:データ取得

関連データを効率的に取得する方法を見てみましょう:

typescript// データ取得の例
async function getUserWithPosts(userId: number) {
  try {
    const user = await prisma.user.findUnique({
      where: { id: userId },
      include: {
        posts: {
          where: { published: true }, // 公開済みの投稿のみ取得
          orderBy: { createdAt: 'desc' }, // 作成日時の降順でソート
          take: 10, // 最新10件のみ取得
        },
      },
    });

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

    return user;
  } catch (error) {
    console.error('データ取得エラー:', error);
    throw error;
  }
}

よくあるエラーと解決策

1 対多リレーションでよく発生するエラーとその対処法をご紹介します:

エラー 1:外部キー制約違反

vbnetError: Foreign key constraint failed on the field: `Post_authorId_fkey (index)`

原因:存在しないユーザー ID を指定して投稿を作成しようとした場合 解決策:事前にユーザーの存在確認を行う

typescript// 修正版:ユーザー存在確認付きの投稿作成
async function createPostSafely(
  authorId: number,
  postData: any
) {
  // まずユーザーが存在するかチェック
  const user = await prisma.user.findUnique({
    where: { id: authorId },
  });

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

  // ユーザーが存在する場合のみ投稿を作成
  return await prisma.post.create({
    data: {
      ...postData,
      authorId: authorId,
    },
  });
}

エラー 2:必須フィールドの指定忘れ

kotlinPrismaClientValidationError: Argument authorId for data.authorId is missing

原因:必須の外部キーフィールドが指定されていない 解決策:TypeScript の型システムを活用して防ぐ

typescript// 型安全な投稿作成関数
interface CreatePostInput {
  title: string;
  content?: string;
  published?: boolean;
  authorId: number; // 必須フィールドとして明示
}

async function createPost(input: CreatePostInput) {
  return await prisma.post.create({
    data: input,
  });
}

具体例:多対多リレーション(User ↔ Tag)の実装

スキーマ定義

多対多リレーションは、中間テーブルを使用して実現します。投稿とタグの多対多関係を実装してみましょう:

prisma// schema.prisma
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 1対多リレーション(User ↔ Post)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])

  // 多対多リレーション(Post ↔ Tag)
  tags      PostTag[]
}

model Tag {
  id        Int      @id @default(autoincrement())
  name      String   @unique
  color     String?  @default("#3B82F6")
  createdAt DateTime @default(now())

  // 多対多リレーション(Tag ↔ Post)
  posts     PostTag[]
}

// 中間テーブル:PostとTagの関係を管理
model PostTag {
  id     Int  @id @default(autoincrement())
  postId Int
  tagId  Int

  // 外部キー制約
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag    Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  // 同じ投稿に同じタグを複数回付けることを防ぐ
  @@unique([postId, tagId])
}

実装コード:タグ付き投稿の作成

タグを指定して投稿を作成する実装を見てみましょう:

typescript// 投稿にタグを付けて作成
async function createPostWithTags(
  postData: any,
  tagNames: string[]
) {
  try {
    // 既存のタグを取得、存在しない場合は作成
    const tags = await Promise.all(
      tagNames.map(async (tagName) => {
        return await prisma.tag.upsert({
          where: { name: tagName },
          update: {}, // 既存の場合は更新しない
          create: { name: tagName },
        });
      })
    );

    // 投稿を作成し、タグとの関係も同時に作成
    const post = await prisma.post.create({
      data: {
        ...postData,
        tags: {
          create: tags.map((tag) => ({
            tagId: tag.id,
          })),
        },
      },
      include: {
        tags: {
          include: {
            tag: true,
          },
        },
        author: true,
      },
    });

    return post;
  } catch (error) {
    console.error('投稿作成エラー:', error);
    throw error;
  }
}

実装コード:複雑な検索クエリ

特定のタグが付いた投稿を検索する実装です:

typescript// 指定したタグが付いた投稿を検索
async function findPostsByTags(tagNames: string[]) {
  try {
    const posts = await prisma.post.findMany({
      where: {
        tags: {
          some: {
            tag: {
              name: {
                in: tagNames, // 指定したタグのいずれかを含む投稿
              },
            },
          },
        },
        published: true,
      },
      include: {
        author: {
          select: {
            name: true,
            email: true,
          },
        },
        tags: {
          include: {
            tag: true,
          },
        },
      },
      orderBy: {
        createdAt: 'desc',
      },
    });

    return posts;
  } catch (error) {
    console.error('投稿検索エラー:', error);
    throw error;
  }
}

よくあるエラーと解決策

エラー 1:ユニーク制約違反

sqlError: Unique constraint failed on the constraint: `PostTag_postId_tagId_key`

原因:同じ投稿に同じタグを複数回付けようとした場合 解決策:事前チェックまたは upsert を使用

typescript// 安全なタグ追加関数
async function addTagToPost(postId: number, tagId: number) {
  try {
    const existingRelation =
      await prisma.postTag.findUnique({
        where: {
          postId_tagId: {
            postId: postId,
            tagId: tagId,
          },
        },
      });

    if (existingRelation) {
      console.log('このタグは既に追加されています');
      return existingRelation;
    }

    return await prisma.postTag.create({
      data: {
        postId: postId,
        tagId: tagId,
      },
    });
  } catch (error) {
    console.error('タグ追加エラー:', error);
    throw error;
  }
}

エラー 2:N+1 問題の発生

typescript// 問題のあるコード
const posts = await prisma.post.findMany();
for (const post of posts) {
  const tags = await prisma.postTag.findMany({
    where: { postId: post.id },
    include: { tag: true },
  });
  console.log(`${post.title}: ${tags.length} tags`);
}

解決策include を使用して一括取得

typescript// 修正版:一括取得でN+1問題を解決
const postsWithTags = await prisma.post.findMany({
  include: {
    tags: {
      include: {
        tag: true,
      },
    },
  },
});

postsWithTags.forEach((post) => {
  console.log(`${post.title}: ${post.tags.length} tags`);
});

具体例:自己参照リレーション(カテゴリ階層)の実装

スキーマ定義

自己参照リレーションは、同じテーブル内でのリレーションです。カテゴリの階層構造を実装してみましょう:

prisma// schema.prisma
model Category {
  id        Int      @id @default(autoincrement())
  name      String
  slug      String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 自己参照リレーション:親カテゴリ
  parentId  Int?
  parent    Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])

  // 自己参照リレーション:子カテゴリ
  children  Category[] @relation("CategoryHierarchy")

  // 投稿との関係
  posts     Post[]
}

model Post {
  id         Int      @id @default(autoincrement())
  title      String
  content    String?
  published  Boolean  @default(false)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  // カテゴリとの関係
  categoryId Int?
  category   Category? @relation(fields: [categoryId], references: [id])

  // 他のリレーション
  authorId   Int
  author     User     @relation(fields: [authorId], references: [id])
  tags       PostTag[]
}

実装コード:階層カテゴリの作成

階層構造を持つカテゴリを作成する実装です:

typescript// カテゴリ階層の作成
async function createCategoryHierarchy() {
  try {
    // 親カテゴリを作成
    const techCategory = await prisma.category.create({
      data: {
        name: 'テクノロジー',
        slug: 'technology',
      },
    });

    // 子カテゴリを作成
    const webCategory = await prisma.category.create({
      data: {
        name: 'Web開発',
        slug: 'web-development',
        parentId: techCategory.id,
      },
    });

    // 孫カテゴリを作成
    const frontendCategory = await prisma.category.create({
      data: {
        name: 'フロントエンド',
        slug: 'frontend',
        parentId: webCategory.id,
      },
    });

    console.log('カテゴリ階層を作成しました');
    return { techCategory, webCategory, frontendCategory };
  } catch (error) {
    console.error('カテゴリ作成エラー:', error);
    throw error;
  }
}

実装コード:階層データの取得

階層構造を含むカテゴリデータを取得する実装です:

typescript// 階層構造を含むカテゴリ取得
async function getCategoryTree() {
  try {
    const categories = await prisma.category.findMany({
      where: {
        parentId: null, // ルートカテゴリのみ取得
      },
      include: {
        children: {
          include: {
            children: {
              include: {
                children: true, // 最大4階層まで取得
              },
            },
          },
        },
        _count: {
          select: {
            posts: true, // 各カテゴリの投稿数も取得
          },
        },
      },
    });

    return categories;
  } catch (error) {
    console.error('カテゴリ取得エラー:', error);
    throw error;
  }
}

よくあるエラーと解決策

エラー 1:循環参照の発生

vbnetError: Cannot resolve relation CategoryHierarchy because it is circular

原因:親カテゴリと子カテゴリが循環参照を起こしている 解決策:循環参照チェック機能を実装

typescript// 循環参照チェック機能付きカテゴリ更新
async function updateCategoryParent(
  categoryId: number,
  newParentId: number
) {
  // 循環参照チェック
  const isCircular = await checkCircularReference(
    categoryId,
    newParentId
  );
  if (isCircular) {
    throw new Error(
      '循環参照が発生します。この操作は実行できません。'
    );
  }

  return await prisma.category.update({
    where: { id: categoryId },
    data: { parentId: newParentId },
  });
}

// 循環参照チェック関数
async function checkCircularReference(
  categoryId: number,
  parentId: number
): Promise<boolean> {
  if (categoryId === parentId) {
    return true;
  }

  const parent = await prisma.category.findUnique({
    where: { id: parentId },
    select: { parentId: true },
  });

  if (!parent || !parent.parentId) {
    return false;
  }

  return await checkCircularReference(
    categoryId,
    parent.parentId
  );
}

エラー 2:深い階層での性能問題

深い階層を一度に取得すると性能問題が発生する可能性があります。

解決策:階層を分割して取得

typescript// 階層分割取得
async function getCategoryByLevel(level: number = 1) {
  try {
    const categories = await prisma.category.findMany({
      where: {
        parentId: null,
      },
      include: {
        children:
          level >= 2
            ? {
                include: {
                  children:
                    level >= 3
                      ? {
                          include: {
                            children: level >= 4,
                          },
                        }
                      : undefined,
                },
              }
            : undefined,
      },
    });

    return categories;
  } catch (error) {
    console.error('階層取得エラー:', error);
    throw error;
  }
}

まとめ:リレーション設計のベストプラクティス

設計時の重要な考慮点

Prisma でリレーションを設計する際は、以下の点を心がけることが重要です:

#ポイント詳細説明
1適切なリレーション種別の選択実際のビジネスロジックに合わせて 1 対多、多対多を使い分ける
2インデックスの最適化外部キーには適切なインデックスを設定する
3カスケード削除の設計onDelete: Cascade を適切に設定し、データの整合性を保つ
4N+1 問題の回避includeselect を効果的に使用する
5型安全性の活用TypeScript と Prisma の型システムを最大限活用する

実践的な運用ノウハウ

1. スキーマ変更時の注意点

bash# スキーマを変更した後は必ずマイグレーションを実行
yarn prisma migrate dev --name descriptive_migration_name

# 本番環境では必ずマイグレーションをレビュー
yarn prisma migrate deploy

2. パフォーマンス最適化

typescript// 効率的なデータ取得のパターン
const optimizedQuery = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    posts: {
      select: {
        id: true,
        title: true,
        tags: {
          select: {
            tag: {
              select: {
                name: true,
              },
            },
          },
        },
      },
      where: {
        published: true,
      },
      take: 5, // 最新5件のみ取得
    },
  },
});

3. エラーハンドリングの統一

typescript// エラーハンドリングのベストプラクティス
async function safeRelationOperation<T>(
  operation: () => Promise<T>,
  errorMessage: string = 'データベース操作でエラーが発生しました'
): Promise<T> {
  try {
    return await operation();
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError
    ) {
      // 既知のPrismaエラーの場合
      switch (error.code) {
        case 'P2002':
          throw new Error('重複したデータが存在します');
        case 'P2003':
          throw new Error('外部キー制約に違反しています');
        case 'P2025':
          throw new Error(
            '指定されたデータが見つかりません'
          );
        default:
          throw new Error(
            `${errorMessage}: ${error.message}`
          );
      }
    }
    throw error;
  }
}

最後に

Prisma のリレーション機能は、従来の ORM と比較して格段に使いやすく、型安全性も確保されています。しかし、その真価を発揮するためには、適切な設計とベストプラクティスの理解が不可欠です。

本記事で紹介した実装パターンとエラー対処法を参考に、あなたのプロジェクトでも効率的なリレーション設計を実現してください。最初は複雑に感じるかもしれませんが、一度慣れれば、データベース設計の楽しさと奥深さを実感できるはずです。

リレーション設計は、単なる技術的な作業ではありません。ユーザーの体験を向上させ、開発チームの生産性を高める、クリエイティブな設計活動なのです。ぜひ、本記事の内容を実際のプロジェクトで活用し、より良いアプリケーションを作り上げてください。

関連リンク