<CodeLearn/>
データベース レッスン5

データベース総合演習

ユーザー管理システムのスキーマ設計からCRUD APIまでを構築しよう

演習の概要

この演習では、ユーザー管理システムのバックエンドを構築します。 データベース設計から API 実装までの一連の流れを体験しましょう。

機能要件

  • - ユーザーの作成・一覧・詳細・更新・削除
  • - 部署(Department)の管理
  • - ユーザーと部署の紐づけ(1対多)
  • - スキル(Skill)の管理とユーザーへの紐づけ(多対多)

使用技術

  • - Next.js(App Router / Route Handlers)
  • - Prisma(ORM)
  • - PostgreSQL または SQLite
  • - TypeScript

Step 1: スキーマ設計

まず ER 図を考え、それを Prisma スキーマに落とし込みます。

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"    // 手軽に始めるため SQLite を使用
  url      = "file:./dev.db"
}

model User {
  id           Int      @id @default(autoincrement())
  name         String
  email        String   @unique
  role         String   @default("member") // "admin" | "member" | "viewer"
  isActive     Boolean  @default(true) @map("is_active")
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")

  // リレーション
  department   Department? @relation(fields: [departmentId], references: [id])
  departmentId Int?        @map("department_id")
  skills       UserSkill[]

  @@map("users")
}

model Department {
  id          Int      @id @default(autoincrement())
  name        String   @unique
  description String?
  createdAt   DateTime @default(now()) @map("created_at")

  users User[]

  @@map("departments")
}

model Skill {
  id    Int    @id @default(autoincrement())
  name  String @unique
  users UserSkill[]

  @@map("skills")
}

// 中間テーブル(User と Skill の多対多)
model UserSkill {
  user    User  @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId  Int   @map("user_id")
  skill   Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
  skillId Int   @map("skill_id")

  @@id([userId, skillId])
  @@map("user_skills")
}
# マイグレーションを実行
npx prisma migrate dev --name init

# シードデータの投入(後述)
npx prisma db seed

Step 2: Prisma Client の初期化

開発環境でホットリロード時に接続が増えないよう、シングルトンパターンで初期化します。

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

シードスクリプトで初期データを投入します。

// prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  // 部署の作成
  const dev = await prisma.department.create({
    data: { name: "開発部", description: "プロダクト開発を担当" },
  });
  const design = await prisma.department.create({
    data: { name: "デザイン部", description: "UI/UXデザインを担当" },
  });

  // スキルの作成
  const ts = await prisma.skill.create({ data: { name: "TypeScript" } });
  const react = await prisma.skill.create({ data: { name: "React" } });
  const figma = await prisma.skill.create({ data: { name: "Figma" } });

  // ユーザーの作成(リレーション付き)
  await prisma.user.create({
    data: {
      name: "田中太郎",
      email: "tanaka@example.com",
      role: "admin",
      departmentId: dev.id,
      skills: {
        create: [{ skillId: ts.id }, { skillId: react.id }],
      },
    },
  });

  await prisma.user.create({
    data: {
      name: "鈴木花子",
      email: "suzuki@example.com",
      role: "member",
      departmentId: design.id,
      skills: {
        create: [{ skillId: figma.id }, { skillId: react.id }],
      },
    },
  });

  console.log("Seed data created!");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Step 3: CRUD API の実装

Next.js の Route Handlers でユーザー管理 API を実装します。

// src/app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users - ユーザー一覧
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const role = searchParams.get("role");
  const departmentId = searchParams.get("departmentId");

  const users = await prisma.user.findMany({
    where: {
      ...(role && { role }),
      ...(departmentId && { departmentId: Number(departmentId) }),
      isActive: true,
    },
    include: {
      department: true,
      skills: { include: { skill: true } },
    },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(users);
}

// POST /api/users - ユーザー作成
export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { name, email, role, departmentId, skillIds } = body;

    // バリデーション
    if (!name || !email) {
      return NextResponse.json(
        { error: "name と email は必須です" },
        { status: 400 }
      );
    }

    const user = await prisma.user.create({
      data: {
        name,
        email,
        role: role ?? "member",
        departmentId: departmentId ?? null,
        skills: skillIds
          ? { create: skillIds.map((id: number) => ({ skillId: id })) }
          : undefined,
      },
      include: {
        department: true,
        skills: { include: { skill: true } },
      },
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error: any) {
    if (error.code === "P2002") {
      return NextResponse.json(
        { error: "このメールアドレスは既に使用されています" },
        { status: 409 }
      );
    }
    return NextResponse.json(
      { error: "サーバーエラーが発生しました" },
      { status: 500 }
    );
  }
}
// src/app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users/:id - ユーザー詳細
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const user = await prisma.user.findUnique({
    where: { id: Number(params.id) },
    include: {
      department: true,
      skills: { include: { skill: true } },
    },
  });

  if (!user) {
    return NextResponse.json(
      { error: "ユーザーが見つかりません" },
      { status: 404 }
    );
  }

  return NextResponse.json(user);
}

// PATCH /api/users/:id - ユーザー更新
export async function PATCH(
  request: Request,
  { params }: { params: { id: string } }
) {
  const body = await request.json();
  const { name, email, role, departmentId, skillIds } = body;

  const user = await prisma.user.update({
    where: { id: Number(params.id) },
    data: {
      ...(name && { name }),
      ...(email && { email }),
      ...(role && { role }),
      ...(departmentId !== undefined && { departmentId }),
      ...(skillIds && {
        skills: {
          deleteMany: {},  // 既存のスキルをすべて削除
          create: skillIds.map((id: number) => ({ skillId: id })),
        },
      }),
    },
    include: {
      department: true,
      skills: { include: { skill: true } },
    },
  });

  return NextResponse.json(user);
}

// DELETE /api/users/:id - ユーザー削除(論理削除)
export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  await prisma.user.update({
    where: { id: Number(params.id) },
    data: { isActive: false },
  });

  return NextResponse.json({ message: "ユーザーを無効化しました" });
}

Step 4: 動作確認

curl や HTTPクライアントで API をテストします。

# ユーザー一覧を取得
curl http://localhost:3000/api/users

# ロールでフィルタリング
curl http://localhost:3000/api/users?role=admin

# ユーザーを作成
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "佐藤一郎",
    "email": "sato@example.com",
    "role": "member",
    "departmentId": 1,
    "skillIds": [1, 2]
  }'

# ユーザー詳細を取得
curl http://localhost:3000/api/users/1

# ユーザーを更新
curl -X PATCH http://localhost:3000/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{ "name": "佐藤一郎(更新)", "role": "admin" }'

# ユーザーを削除(論理削除)
curl -X DELETE http://localhost:3000/api/users/3

npx prisma studio を起動すると、 ブラウザ上でデータベースの中身を直接確認・編集できます。

まとめ

  • 要件から ER 図を考え、Prisma スキーマでモデルとリレーションを定義する
  • マイグレーションでスキーマの変更をデータベースに反映する
  • Prisma Client のシングルトンパターンで開発環境での接続増加を防ぐ
  • シードスクリプトで初期データを投入し、開発を効率化する
  • Next.js Route Handlers で RESTful な CRUD API を実装する
  • 論理削除(isActive フラグ)で安全なデータ管理を実現する