Prisma 初学者の手引書 (dockerでAPI作成)

# はじめに
新たに TypeORM を学ぼうかと思ったが、 Prisma の理解を深めることを選択したのである。

docker で環境構築をして Prisma の基礎的な動作を hands on（実践する）。DB は MySQL を利用する

## 前提条件
* DB がなにかを知っている
* docker engine をダウンロードできる
* Postman をダウンロードできる

# 導入メリット
Prisma は TypeScript と JavaScript のための強力な型安全なデータベースクライアントを提供します。

これにより、開発者はコンパイル時にエラーを検出でき、デバッグ時間を節約し、ランタイムエラーを減らすことができます。

## vs typeORM
TypeORMではリストやレコードをフィルタリングするために主にSQL演算子を使用している

また、TypeORMでは多くのケースでフィルタクエリの型安全性を失っている。

一方で、Prismaは直感的に使用できる演算子(contains, startsWith, endsWithなど)を提供している


# docker 作成
## ディレクトリ構成
```
prisma_test
├── docker-compose.yml
└── api-server
    └── app
```
`db_data` はコンテナ起動時に生成させる

## Dockerネットワーク
```sh
docker network create prisma-test
docker network list
docker inspect prisma-test
```

## docker-compose
```yml:docker-compose.yml
services:
  db:
    image: mysql:9
    container_name: prisma-test-db-server
    volumes:
      - ./db_data:/var/lib/mysql
    environment:               # 環境変数の設定
      - MYSQL_ROOT_PASSWORD=password
    networks:
      prisma-test:
        aliases:
          - db-container       # db-serverのnetwork内での別名
    stdin_open: true
    tty: true                  # コンテナを自動停止させない

  api:
    image: node:alpine3.19
    volumes:
      - ./api-server/app:/usr/src/app   # bind mount
    container_name: prisma-test-api-server
    networks:
      - prisma-test        # Dockerネットワークに所属させる
    stdin_open: true
    tty: true
    working_dir: /usr/src/app
    depends_on:            # 依存関係
      - db
    ports:
      - 3000:3000


networks:
  prisma-test:
    external: true   # 外部で事前に定義されているネットワークを利用する

volumes:
  db_data:
```

## Docker で コンポーネント を起動
```sh
docker compose up -d
```

## DBコンテナ内初期設定
DB の作成

コンテナ初回起動時に自動的に実行される `/docker-entrypoint-initdb.d/init.sql` を利用すべきだが、`CREATE DATABASE` だけなので直接実行する。

```sh
docker exec -it prisma-test-db-server bash

mysql -u root -p
password
show databases;
CREATE DATABASE sample_db;
show databases;
exit # mysql から脱出

exit # コンテナから脱出
```

※後述の `prisma db push` コマンドだとDATABASEは自動的に作成される
`MySQL database sample_db created at db-container:3306`

## APIコンテナ内初期設定
### prisma をインストール
```sh
npm install express
npm install prisma --save-dev

npx prisma init
```

### `.env` 編集
prisma が DB コンテナに接続できるようにする
```:.env
DATABASE_URL="mysql://root:password@db-container:3306/sample_db"
```

### `schema.prisma` 編集
DB 設計をする
```mermaid
erDiagram
  users {
    int id PK "ID"
    string name "ユーザー名"
    string email "メールアドレス"
  }
  posts {
    Int id PK "ID"
    String title "タイトル"
    String content "内容"
    Int author_id "著者ID"
    DateTime created_at "作成日時"
    DateTime updated_at "更新日時"
  }

  users ||--o{ posts: ""
```

```prisma:schema.prisma
generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

// ユーザーテーブル
model Users {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique

  posts     Posts[]  // 一対多リレーションを定義
}

// 投稿テーブル
model Posts {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  author_id  Int
  author    Users    @relation(fields: [author_id], references: [id]) // リレーションを定義

  // Timestamps
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
}
```

### マイグレーション
```sh
npx prisma migrate dev --name my_migrate_init
```


# API の作成
`/prisma_test/api-server/app` または `/usr/src/app`（マウントしているので同じ）に `app.js` を作成
今回は１つのファイルにすべて記述する

## API の基礎部分
```js:app.js
'use strict';
const express = require('express');
const app = express();
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

// JSON ボディのパース
app.use(express.json());

// index
app.get('/', function(req, res){
  res.send('Hello Express!');
});


// ここに後述の endpoint を追記


// NotFound
app.use(function(req,res){
  res.send("NOT FOUND !")
});

// サーバーの起動
app.listen(3000);
console.log(`サーバが起動しました: http://localhost:3000`);
```

## API の起動
```sh
node app
```

コンポーネント起動時に API も起動するように `docker-compose.yml` に追記
`app.js` を作成する前に `node app` を実行すると、「そんなファイルないよ」エラーになる
```yml:docker-compose.yml
api:
  command: >
    sh -c 'npm install &&
    node app'
```

## db 接続する endpoint の作成
### CRUD の C
```js:app.js
// ユーザーの登録（１件）
app.post('/users', async(req,res)=>{
  // body 取得
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }
  // INSERT 処理
  try {
    const newUser = await prisma.users.create({
      data: { name, email, },
    });
    return res.status(201).json(newUser);
  } catch (error) {
    console.error('Error adding user:', error);
    // Prisma がクライアントに返すエラー
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      // ユニーク制約違反
      if (error.code === 'P2002') {
        console.log('There is a unique constraint violation, a new user cannot be created with this email');
        return res.status(400).json({ error: 'A user with this email already exists' });
      }
    }
    return res.status(500).json({ error: 'Internal server error' });
  }
});

// 投稿の登録（１件）
app.post('/posts', async(req,res)=>{
  // body 取得
  const { title, content, author_id } = req.body;
  if (!title || !content || !author_id) {
    return res.status(400).json({ error: 'Title, content, and author_id are required' });
  }
  // INSERT 処理
  try {
    const newPost = await prisma.posts.create({
      data: { title, content, author_id, },
    });
    return res.status(201).json(newPost);
  } catch (error) {
    console.error('Error adding post:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});
```

### CRUD の R
```js:app.js
// ユーザー一覧の取得
app.get('/users', async (req, res) => {
  try {
    const users = await prisma.users.findMany();
    return res.json(users);
  } catch (error) {
    console.error('Error fetching users:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});

// ユーザーと投稿一覧の取得
app.get('/users/posts', async(req,res)=>{
  try {
    const users = await prisma.users.findMany({
      include: {
        posts: true, // 関連する posts を含める
      },
    });
    return res.json(users);
  } catch (error) {
    console.error('Error fetching users with posts:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});
```

### CRUD の U と D
```js:app.js
// 投稿の更新
app.put('/posts', async(req,res)=>{
  const { id, title, content } = req.body;
  if (id === undefined || (title === undefined && content === undefined)) {
    // `!title` を使用すると、falsy な値（undefined、null、0、空文字列 "" など）の場合に true になります。
    return res.status(400).json({ error: 'ID, and either title or content are required' });
  }
  try {
    // データの更新
    const updatedPost = await prisma.posts.update({
      where: { id }, // 更新するレコードを特定する条件
      data: { title, content }, // 更新するデータ
    });
    // 更新結果をクライアントに返す
    return res.status(200).json(updatedPost);
  } catch (error) {
    console.error('Error updating post:', error);
    if (error.code === 'P2025') {
      // Prisma のエラーコード P2025 は "Record to update not found" を示す
      return res.status(404).json({ error: 'Post not found' });
    }
    return res.status(500).json({ error: 'Internal server error' });
  }
});

// 投稿の削除
app.delete('/posts', async(req,res)=>{
  const { id } = req.body;
  // id が指定されていない場合のエラーハンドリング
  if (id === undefined) {
    return res.status(400).json({ error: 'ID is required' });
  }
  try {
    // レコードの削除
    const deletedPost = await prisma.posts.delete({
      where: { id }, // 削除するレコードを特定する条件
    });
    // 成功レスポンスを返す
    return res.status(200).json(deletedPost);
  } catch (error) {
    console.error('Error deleting post:', error);
    if (error.code === 'P2025') {
      // Prisma のエラーコード P2025 は "Record to delete not found" を示す
      return res.status(404).json({ error: 'Post not found' });
    }
    return res.status(500).json({ error: 'Internal server error' });
  }
});
```

# postman で確認
## postman の使用手順
1. 通信method（GET, POST, PUT, DELESE）を選択。
2. リクエストの下部にある「Body」タブをクリックします。
3. 「raw」を選択し、右側のドロップダウンから「JSON」を選択します。
4. JSON形式でリクエストボディにデータを入力します。

## テストデータ
**ベースURL: `http://localhost:3000`**

<table><thead><tr><th>機能</th><th>endpoint</th><th>method</th><th>sample data</th></tr></thead><tbody>
<tr><td>学生一覧の取得</td><td>

`/students`</td><td>GET</td><td>-</td></tr>
<tr><td>学生と成績一覧の取得</td><td>

`/students/score`</td><td>GET</td><td>-</td></tr>
<tr><td>ユーザーの登録（１件）</td><td>

`/students`</td><td>POST</td><td>
```json
{
  "name": "John Doe",
  "email": "john.doe@example.com"
}
```
</td></tr>
<tr><td>投稿の登録（１件）</td><td>

`/test_results`</td><td>POST</td><td>
```json
{
  "title": "My First Post",
  "content": "My first content.",
  "author_id": 1
}
```
</td></tr>
<tr><td>投稿の更新</td><td>

`/test_results`</td><td>PUT</td><td>
```json
{
  "id": 1,
  "title": "Second Post",
}
```
</td></tr>
<tr><td>投稿の削除</td><td>

`/test_results`</td><td>DELETE</td><td>
```json
{
  "id": 6
}
```
</td></tr>
</tbody></table>

※「投稿の登録（１件）」で関連する `author_id` がないとき、`Internal server error` になるが、 DB の autoincrement は増加している


## DateTime 型を使用する場合の罠
Prisma の DateTime 型は ISO 8601 形式の文字列を期待する
>基本形式: "YYYY-MM-DDTHH:MM:SSZ"
>例: "2012-05-11T14:42:00Z"
>タイムゾーン指定: タイムゾーンを指定する場合は、次のようになります：
>オフセット形式: "YYYY-MM-DDTHH:MM:SS+HH:MM"
>例: "2012-05-11T14:42:00+09:00"


# Prisma Studio を利用する
`docker-compose.yml` 追記
```yml:docker-compose.yml
ports:
  - 5555:5555          # prisma studio 用
```
起動: api サーバー内で以下のコマンドを実行
```sh
npx prisma studio
```
ブラウザで確認
`http://localhost:5555`


# docker-compose.yml でマイグレーションとSeeding
**Seeding**: マイグレーションの実行時にテーブルに初期データを挿入できる

## `docker-compose.yml` 追記
```yml:docker-compose.yml
  db:
    healthcheck:               # 自身のヘルスチェックを行うコマンド
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
      interval: 10s
      timeout: 5s
      retries: 3
  api:
    depends_on:
      db:
        condition: service_healthy # 依存先コンテナのヘルスチェックが完了するまで待機
    command: >
      sh -c 'npm install &&
      npx prisma db push &&
      node prisma/seed.js &&
      node app'
```

## `prisma_test/api-server/app/prisma/seed.js` 作成
<details><summary>（長いので折り畳み）</summary>

```js:/prisma/seed.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

async function main() {
  await prisma.users.createMany({
    data: [
      { name: "Alice", email: "alice@example.com" },
      { name: "Bob", email: "bob@example.com" },
      { name: "Charlie", email: "charlie@example.com" },
      { name: "Diana", email: "diana@example.com" },
      { name: "Eve", email: "eve@example.com" },
      { name: "Frank", email: "frank@example.com" },
      { name: "Grace", email: "grace@example.com" },
      { name: "Hank", email: "hank@example.com" },
      { name: "Ivy", email: "ivy@example.com" },
      { name: "Jack", email: "jack@example.com" },
      { name: "Kara", email: "kara@example.com" },
      { name: "Leo", email: "leo@example.com" },
      { name: "Mona", email: "mona@example.com" },
      { name: "Nina", email: "nina@example.com" },
      { name: "Owen", email: "owen@example.com" },
      { name: "Paul", email: "paul@example.com" },
      { name: "Quinn", email: "quinn@example.com" },
      { name: "Rita", email: "rita@example.com" },
      { name: "Sam", email: "sam@example.com" },
      { name: "Tina", email: "tina@example.com" },
      { name: "Uma", email: "uma@example.com" },
      { name: "Vera", email: "vera@example.com" },
      { name: "Will", email: "will@example.com" },
      { name: "Xena", email: "xena@example.com" },
      { name: "Yara", email: "yara@example.com" },
      { name: "Zane", email: "zane@example.com" },
    ],
    skipDuplicates: true, // 重複をスキップするオプション。主キーが重複した場合にエラーを防ぐ
  });
  await prisma.posts.createMany({
    data: [
      {
        "title": "Exploring Prisma",
        "content": "Detailed content about exploring Prisma features.",
        "author_id": 2
      },
      {
        "title": "Introduction to GraphQL",
        "content": "An introduction to GraphQL and its benefits.",
        "author_id": 2
      },
      {
        "title": "Understanding JWT",
        "content": "A deep dive into JWT and its usage.",
        "author_id": 3
      },
      {
        "title": "REST vs GraphQL",
        "content": "Comparing REST APIs with GraphQL.",
        "author_id": 3
      },
      {
        "title": "The Rise of TypeScript",
        "content": "Why TypeScript is becoming popular in modern development.",
        "author_id": 1
      },
      {
        "title": "Testing in Node.js",
        "content": "Best practices for testing Node.js applications.",
        "author_id": 2
      },
      {
        "title": "Performance Optimization",
        "content": "Techniques to optimize the performance of your applications.",
        "author_id": 3
      },
      {
        "title": "Async/Await in JavaScript",
        "content": "Understanding async/await syntax and how to use it effectively.",
        "author_id": 1
      }
    ],
  });
  console.log('Seeding completed');
}

main()
   .catch(e => {
      throw e;
   })
   .finally(async () => {
      await prisma.$disconnect();
   });
```
</details>
<br>

※Docker container 再起動時に毎回データが追加されるので、 ~~`seed.js` を空にしたり `db_data` を初期化したりで、対応するべきです。~~ データベースに既存のデータがあるかどうかを確認してからシーディングを行う。

`seed.js` 追記
```js:/prisma/seed.js
async function main() {
  // ユーザーが存在するか確認
  const userCount = await prisma.users.count();
  if (userCount === 0) {
    // ユーザーが存在しない場合のみデータを追加
    await prisma.users.createMany({ ... });
    console.log('Seeding completed');
  } else {
    console.log('Seed data already present');
  }
}
```

***

## reference
* [prisma を勉強したときのまとめ(個人備忘録)](https://qiita.com/takekota/items/42f6a9066de60da93161)
* [過去の資料 1](https://github.com/brftb/AndroidApplicationTicketCertification/blob/main/docker/docker-compose.yml)
* [過去の資料 2](https://github.com/brftb/AndroidApplicationTicketCertification/blob/main/docker/node/routes/seller/event.js)

* [Prismaの導入とメリットを考える](https://qiita.com/am_765/items/5e42bd5f87b296f61fbc)
* [Node.jsのORMであるPrismaとTypeORMを比較する](https://zenn.dev/yuichi_m/articles/9844486dd42eb3)
* [Prisma ORM vs TypeORM | Prisma Documentation](https://www.prisma.io/docs/orm/more/comparisons/prisma-and-typeorm)
* [prisma チートシート (CLI) ](https://qiita.com/ryskBonn92/items/c45e22ce5f37d82ec8de)
* [【個人開発】最新のNext.js+NextAuth.js+prisma+microCMSでECサイト作ってみた【フルスタックアプリケーション】](https://qiita.com/mamimami0709/items/7ce5e26afea1fded0747)
* [Docker環境でPrisma Studioが開けない問題](https://qiita.com/Ryoyu1117/items/75fa73d30ffcd9180fc0)
* [Docker Compose で依存先のコンテナが「ちゃんと」起動しているかチェックするひとつの方法](https://zenn.dev/daifukuninja/articles/35c8c9812391f7)
* [【入門】Prismaを初めて使うときに知りたいことまとめ - らくらくエンジニア](https://rakuraku-engineer.com/posts/prisma-introduction/)

# おわりに
他の Prisma 記事です

https://qiita.com/takekota/items/42f6a9066de60da93161

https://qiita.com/takekota/items/e01b48096435fc01cae7