30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Prisma に入門して API サーバを作ってみる

Prisma は、Node.js の ORM。
この記事では、導入方法、基本的な使い方について説明したのち、Prisma を使って簡単な API サーバを作ってみる。

Node.js のバージョンは16.13.2、MySQLのバージョンは8.0.28という環境で、動作確認している。

使用している npm ライブラリのバージョンは以下の通り。

  • @prisma/client@3.11.0
  • @types/node@16.11.26
  • prisma@3.11.0
  • ts-node-dev@1.1.8
  • typescript@4.6.2

MySQL のインストール

ORM である Prisma を試すためには、まずデータベースをセットアップしないといけない。今回は MySQL を使うことにする。
この記事では Homebrew を使ってインストールしているが、他の方法でももちろん問題ない。

% brew install mysql

バージョンを確認できればインストール成功。

% mysql --version
mysql  Ver 8.0.28 for macos11.6 on x86_64 (Homebrew)

brew services start mysqlで起動、brew services stop mysqlで終了。
以降、MySQL が起動しているという前提で話を進めていく。

以下のコマンドを打つといくつか質問されるので、root ユーザーのパスワード設定以外は全て No にする。Enter を押していけば No になるはず。

% mysql_secure_installation

以下のコマンドを打つと root ユーザーのパスワードを求められるので、先程設定したパスワードを入力すると、ログインできる。

% mysql --user=root --password

データベースの準備

MySQL にログインしたら、Prisma で操作するデータベースを用意する。
今回はprisma_dbという名前にしたが、特に制限はない。

mysql> CREATE DATABASE prisma_db;

SHOW DATABASES;で確認してprisma_dbが存在すれば成功。

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| prisma_db          |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

USE prisma_db;とすると、以降はprisma_dbに対して操作できるようになる。

SHOW TABLES;を実行すると、prisma_dbにはまだテーブルがないことを確認できる。

mysql> SHOW TABLES;
Empty set (0.00 sec)

最後に、このデータベースのポート番号を確認する。Prisma の設定時に必要になる。

mysql> show variables like 'port';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| port          | 3306  |
+---------------+-------+
1 row in set (0.00 sec)

Prisma プロジェクトを作成する

まずは必要なライブラリをインストールする。

% yarn init -y
% yarn add -D typescript ts-node-dev prisma

TypeScript で開発していくので、以下のtsconfig.jsonを作る。

{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext", "DOM"],
    "esModuleInterop": true
  }
}

以下のコマンドを実行すると、いくつかのファイルが生成される。

% yarn run prisma init

まず.env
このファイルでDATABASE_URLという環境変数が定義されているので、これを自身の環境に合わせて書き換える。
この記事の内容に沿って環境を構築した場合、以下のようになるはず。YOURPASSWORDの部分だけ、先程設定した root ユーザーのパスワードに書き換えればよい。

DATABASE_URL="mysql://root:YOURPASSWORD@localhost:3306/prisma_db"

そして、prisma/schema.prismaという名前のスキーマファイルも生成される。
以下の内容になっているはずなので、postgresqlの部分をmysqlに書き換える。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

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

モデルを定義する

環境構築が終わったのでここから実際に開発を進めていくが、まずはモデルを定義する。

先程編集したスキーマファイルに、モデルの定義を書き足していく。
スキーマファイルはPrisma Schema Language(PSL)という構文で書いていく。
また、.prismaファイルは、% yarn run prisma formatを実行することでフォーマットできる。

早速、Userというモデルを定義してみる。

model User {
  id      Int     @id @default(autoincrement())
  name    String
}

そして、以下のコマンドを実行する。

% yarn run prisma migrate dev --name init

そうすると様々な処理が行われるが、まず、@prisma/clientがまだインストールされていなかった場合はインストールされる。package.jsonに追記されていることを確認できる。
それから、prisma/migrationsディレクトリに、マイグレーションファイルが作成される。
そしてそのマイグレーションファイルをデータベースに対して実行する。

prisma_dbの中身を見てみると、Userテーブルが作られているのが分かる。

mysql> SHOW TABLES;
+---------------------+
| Tables_in_prisma_db |
+---------------------+
| _prisma_migrations  |
| User                |
+---------------------+

このように、Prisma のモデルはデータベースのテーブルに対応している。

DESCで調べてみると、モデルの定義通りに作成されている。

mysql> DESC User;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(191) | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

この時点ではレコードはまだ存在しない。

mysql> SELECT * FROM User;
Empty set (0.00 sec)

Prisma クライアントを使った CRUD 操作

Prisma クライアントを使ってコードを書いていくことで、データベースに対する操作を行うことができる。
ここでは、User テーブルに対して簡単な CRUD 操作を行っていく。

最初に、動作確認用の雛形となるコードを用意する。
src/index.tsという名前で以下のファイルを作成する。

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  // ここにクエリを書いていく
}

main()
  .catch((e) => {
    throw e;
  })
  .finally(async () => {
    // データベースとのコネクションを切る
    await prisma.$disconnect();
  });

あとはmain関数のなかに具体的な処理を書き、% yarn run ts-node-dev src/index.tsで実行すればいい。

レコードの作成

レコードを作成するにはcreateメソッドを使う。createの返り値は、作成されたUserのオブジェクト。

async function main() {
  const user = await prisma.user.create({
    data: {
      name: "Alice",
    },
  });
  console.log(user); // { id: 1, name: 'Alice' }
}

% yarn run ts-node-dev src/index.tsを実行したあとにデータベースで検索を行うと、レコードが作られていることがわかる。

mysql> SELECT * FROM User;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

dataに渡すフィールドがモデルの定義と一致していない場合、型エラーとなる。

// 以下は全て型エラー
async function main() {
  await prisma.user.create({
    data: {
      name: 1, // name の型が正しくない
    },
  });
  await prisma.user.create({
    data: {}, // name が渡されていない
  });
  await prisma.user.create({
    data: {
      name: 'Alice',
      bar: 'buz', // 不要なフィールド bar を渡している
    },
  });
}

実は、% yarn run prisma migrate dev --name initを実行した際にnode_modules内に型定義ファイルが自動生成されており、それを使って型チェックが行われている。

// node_modules/.prisma/client/index.d.ts から抜粋

/**
 * Model User
 * 
 */
export type User = {
  id: number
  name: string
}

idは auto_increment なので明示的に渡さなくてもよいのだが、もちろん渡してもよい。

async function main() {
  const user = await prisma.user.create({
    data: {
      id: 2,
      name: "Bob",
    },
  });
  console.log(user); // { id: 2, name: 'Bob' }
}

但し、既に存在しているidを渡してしまうと、プライマリキーとしての制約が守られていないので、実行時エラーになる。

async function main() {
  try {
    await prisma.user.create({
      data: {
        id: 2,
        name: "Carol",
      },
    });
  } catch (e) {
    // PrismaClientKnownRequestError
    // Unique constraint failed on the constraint: `PRIMARY`
    console.log(e);
  }
}

一度に複数のレコードを作成するときは、createManyを使う。

async function main() {
  const users = await prisma.user.createMany({
    data: [
      {
        name: "Carol",
      },
      {
        name: "Dave",
      },
    ],
  });
  console.log(users); // { count: 2 }
}
mysql> SELECT * FROM User;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
|  2 | Bob   |
|  3 | Carol |
|  4 | Dave  |
+----+-------+
4 rows in set (0.00 sec)

レコードの取得

レコードの取得を行うためのメソッドとしてここでは、findManyfindUniquefindFirstを紹介する。

まずfindMany。これは、複数のレコードを取得するメソッド。
条件を何も指定しなかった場合、全てのフィールドを持った全レコードが返ってくる。

async function main() {
  const allUsers = await prisma.user.findMany();
  console.log(allUsers);
  // [
  //   { id: 1, name: 'Alice' },
  //   { id: 2, name: 'Bob' },
  //   { id: 3, name: 'Carol' },
  //   { id: 4, name: 'Dave' }
  // ]
}

フィールドを指定するには、selectを使う。

async function main() {
  const users = await prisma.user.findMany({
    select: {
      name: true,
    },
  });
  console.log(users);
  // [
  //   { name: 'Alice' },
  //   { name: 'Bob' },
  //   { name: 'Carol' },
  //   { name: 'Dave' }
  // ]
}

レコードの絞り込みにはwhereを使う。
以下はcontainsを使って、「nameフィールドにoが含まれているレコード」を取得している。

async function main() {
  const users = await prisma.user.findMany({
    where: {
      name: {
        contains: "o",
      },
    },
  });
  console.log(users); // [ { id: 2, name: 'Bob' }, { id: 3, name: 'Carol' } ]
}

該当するレコードが存在しないときは空の配列を返す。

async function main() {
  const users = await prisma.user.findMany({
    where: {
      name: {
        contains: "z",
      },
    },
  });
  console.log(users); // []
}

contains以外にも様々な検索条件がある。詳しくは公式ドキュメントを参照。
https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#filter-conditions-and-operators

ORANDを使うこともできる。

// 「name が Al から始まる」 or 「id が 3 以上」
async function main() {
  const users = await prisma.user.findMany({
    where: {
      OR: [
        {
          name: {
            startsWith: "Al",
          },
        },
        {
          id: {
            gte: 3,
          },
        },
      ],
    },
  });
  console.log(users);
  // [
  //   { id: 1, name: 'Alice' },
  //   { id: 3, name: 'Carol' },
  //   { id: 4, name: 'Dave' }
  // ]
}
// 「name に o を含む」 and 「id が 3 以上」
async function main() {
  const users = await prisma.user.findMany({
    where: {
      AND: [
        {
          name: {
            contains: "o",
          },
        },
        {
          id: {
            gte: 3,
          },
        },
      ],
    },
  });
  console.log(users); // [ { id: 3, name: 'Carol' } ]
}

orderByを使って並べ替えを行うこともできる。

async function main() {
  const allUsers = await prisma.user.findMany({
    orderBy: {
      id: "desc",
    },
  });
  console.log(allUsers);
  // [
  //   { id: 4, name: 'Dave' },
  //   { id: 3, name: 'Carol' },
  //   { id: 2, name: 'Bob' },
  //   { id: 1, name: 'Alice' }
  // ]
}

findUniqueを使うと、ユニークな識別子を使って、該当するレコードを取得できる。
該当するレコードが存在しない場合はnullが返ってくる。

async function main() {
  let user = await prisma.user.findUnique({
    where: {
      id: 1,
    },
  });
  console.log(user); // { id: 1, name: 'Alice' }

  user = await prisma.user.findUnique({
    where: {
      id: 99,
    },
  });
  console.log(user); // null
}

ユニークではないフィールドを使おうとすると型エラーになる。

async function main() {
  const user = await prisma.user.findUnique({
    where: {
      name: "Alice", // 型エラー
    },
  });
}

findFirstは、最初に見つかったレコードを返す。

async function main() {
  const user = await prisma.user.findFirst({
    where: {
      name: {
        contains: "o",
      },
    },
  });
  console.log(user); // { id: 2, name: 'Bob' }
}
// orderBy で降順にしているので、{ id: 3, name: 'Carol' } が返ってくる
async function main() {
  const user = await prisma.user.findFirst({
    where: {
      name: {
        contains: "o",
      },
    },
    orderBy: {
      id: "desc",
    },
  });
  console.log(user); // { id: 3, name: 'Carol' }
}

レコードのアップデート

レコードのアップデートには、updateメソッドを使う。

async function main() {
  const user = await prisma.user.update({
    where: {
      id: 4,
    },
    data: {
      name: "David",
    },
  });
  console.log(user); // { id: 4, name: 'David' }
}

存在しないレコードをアップデートしようとすると、実行時エラーになってしまう。

// id が 99 のレコードの name をアップデートしようとしているが、該当するレコードは存在しないため、実行時エラーになる
async function main() {
  const user = await prisma.user.update({ // 実行時エラー
    where: {
      id: 99,
    },
    data: {
      name: "David",
    },
  });
  console.log(user);
}

レコードの削除

レコードの削除にはdeleteメソッドを使う。

async function main() {
  const user = await prisma.user.delete({
    where: {
      id: 4,
    },
  });
  console.log(user); // { id: 4, name: 'David' }
}

存在しないレコードを削除しようとすると、実行時エラーになってしまう。

// id が 99 のレコードを削除しようとしているが、該当するレコードは存在しないため、実行時エラーになる
async function main() {
  const user = await prisma.user.delete({ // 実行時エラー
    where: {
      id: 99,
    },
  });
  console.log(user);
}

マイグレーション

モデルの追加・変更を行う場合は、スキーマファイルを更新して、再びprisma migrateを行えばいい。
今回はUserモデルにisAdminフィールドを足すことにする。

model User {
  id      Int     @id @default(autoincrement())
  name    String
  isAdmin Boolean
}

しかしこの状態で% yarn run prisma migrate dev --name add-is-adminを実行すると、エラーになってしまう

Error:
⚠️ We found changes that cannot be executed:

  • Step 0 Added the required column `isAdmin` to the `User` table without a default value. There are 3 rows in this table, it is not possible to execute this step.

UserにはisAdminが必須になるように変更を行おうとしたが、既にUserテーブルにはレコードが 3 つあり、それらは当然isAdminを持っていないため、エラーになってしまう。
対応方法はいくつかあるが、今回はデフォルト値を設定して解決することにする。

スキーマファイルを編集し、isAdminにデフォルト値を設定する。

model User {
  id      Int     @id @default(autoincrement())
  name    String
  isAdmin Boolean @default(false)
}

再度% yarn run prisma migrate dev --name add-is-adminを実行すると、今度は上手くいく。

データベースを調べてみると、新しいモデルの定義が反映されていることを確認できる。

mysql> DESC User;
+---------+--------------+------+-----+---------+----------------+
| Field   | Type         | Null | Key | Default | Extra          |
+---------+--------------+------+-----+---------+----------------+
| id      | int          | NO   | PRI | NULL    | auto_increment |
| name    | varchar(191) | NO   |     | NULL    |                |
| isAdmin | tinyint(1)   | NO   |     | 0       |                |
+---------+--------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

mysql> SELECT * FROM User;
+----+-------+---------+
| id | name  | isAdmin |
+----+-------+---------+
|  1 | Alice |       0 |
|  2 | Bob   |       0 |
|  3 | Carol |       0 |
+----+-------+---------+
3 rows in set (0.00 sec)

node_modules/.prisma/client/index.d.tsの型定義も更新されているので、引き続き型の恩恵を受けながら開発することができる。

/**
 * Model User
 * 
 */
export type User = {
  id: number
  name: string
  isAdmin: boolean
}

API サーバの構築

最後に実践として、Prisma を使った簡単な API サーバを構築してみる。

仕様は以下のようにする。

  • /userGETメソッドでリクエストすると、全てのUserが返ってくる
  • /user/{ID}GETメソッドでリクエストすると、idフィールドがIDUserが返ってくる
  • /userPOSTメソッドでリクエストすると、Userが新規作成され、そのUserが返ってくる

まず、Node.js の型定義ファイルをインストールする。

% yarn add -D @types/node@16

次に、src/db.tsという名前のファイルを作成する。このファイルでは、Prisma クライアントでデータベース操作を行う。

// src/db.ts
import { PrismaClient, User } from "@prisma/client";

const prisma = new PrismaClient();

export async function getUser(id: number): Promise<User | null> {
  return await prisma.user.findUnique({
    where: {
      id,
    },
  });
}

export async function getAllUsers(): Promise<User[]> {
  return await prisma.user.findMany();
}

export async function createUser({ name }: { name: string }): Promise<User> {
  return await prisma.user.create({
    data: {
      name,
    },
  });
}

次に、src/index.tsでサーバについて記述していくのだが、まずは「全てのUserの取得」にのみ、対応させる。

import http from "http";

import { getAllUsers } from "./db";

function resNotFound(res: http.ServerResponse) {
  res.writeHead(404, {
    "Content-Type": "application/json",
  });
  res.write(JSON.stringify({ message: "Not Found" }) + "\n");
  res.end();
}

function resData(res: http.ServerResponse, data: unknown) {
  res.writeHead(200, {
    "Content-Type": "application/json",
  });
  res.write(JSON.stringify(data) + "\n");
  res.end();
}

http
  .createServer(async (req, res) => {
    if (req.url === undefined || !req.url.startsWith("/user")) {
      resNotFound(res);
      return;
    }

    switch (req.method) {
      case "GET": {
        switch (true) {
          case /^\/user$/.test(req.url): {
            const allUsers = await getAllUsers();
            resData(res, { data: allUsers });
            break;
          }
          default: {
            resNotFound(res);
            break;
          }
        }
        break;
      }

      default: {
        res.writeHead(405, {
          "Content-Type": "application/json",
        });
        res.write(JSON.stringify({ message: "Method Not Allowed" }) + "\n");
        res.end;
        break;
      }
    }
  })
  .listen(8080);

% yarn run ts-node-dev src/index.tsでサーバが起動するので、早速リクエストしてみる。

% curl http://localhost:8080/user
{"data":[{"id":1,"name":"Alice","isAdmin":false},{"id":2,"name":"Bob","isAdmin":false},{"id":3,"name":"Carol","isAdmin":false}]}

問題なく全てのUserを取得できた。

次に、idを指定して特定のUserを取得できるようにする。

以下の変更を加えてサーバを起動し直す。

@@ -1,6 +1,6 @@
 import http from "http";

-import { getAllUsers } from "./db";
+import { getAllUsers, getUser } from "./db";

 function resNotFound(res: http.ServerResponse) {
   res.writeHead(404, {
@@ -33,6 +33,12 @@
             resData(res, { data: allUsers });
             break;
           }
+          case /^\/user\/[0-9]+$/.test(req.url): {
+            const id = Number(req.url.slice(6));
+            const user = await getUser(id);
+            resData(res, { data: user });
+            break;
+          }
           default: {
             resNotFound(res);
             break;

そうすると、個別のUserを取得できるようになる。

% curl http://localhost:8080/user/1
{"data":{"id":1,"name":"Alice","isAdmin":false}}
% curl http://localhost:8080/user/2
{"data":{"id":2,"name":"Bob","isAdmin":false}}
% curl http://localhost:8080/user/9
{"data":null}

最後に、Userを作成できるようにする。
以下の変更を加えてサーバを起動し直せばよい。

@@ -1,6 +1,7 @@
 import http from "http";
+import { URLSearchParams } from "url";

-import { getAllUsers, getUser } from "./db";
+import { getAllUsers, getUser, createUser } from "./db";

 function resNotFound(res: http.ServerResponse) {
   res.writeHead(404, {
@@ -10,6 +11,14 @@
   res.end();
 }

+function resBadRequest(res: http.ServerResponse) {
+  res.writeHead(400, {
+    "Content-Type": "application/json",
+  });
+  res.write(JSON.stringify({ message: "Bad Request" }) + "\n");
+  res.end();
+}
+
 function resData(res: http.ServerResponse, data: unknown) {
   res.writeHead(200, {
     "Content-Type": "application/json",
@@ -47,6 +56,33 @@
         break;
       }

+      case "POST": {
+        switch (true) {
+          case /^\/user$/.test(req.url): {
+            let data = "";
+            req.on("data", (chunk) => {
+              data += chunk;
+            });
+            req.on("end", async () => {
+              const params = new URLSearchParams(data);
+              const name = params.get("name");
+              if (name === null) {
+                resBadRequest(res);
+                return;
+              }
+              const user = await createUser({ name });
+              resData(res, { data: user });
+            });
+            break;
+          }
+          default: {
+            resNotFound(res);
+            break;
+          }
+        }
+        break;
+      }
+
       default: {
         res.writeHead(405, {
           "Content-Type": "application/json",

POSTメソッドでUserを作成できるようになっている。

% curl -d name="Dave" http://localhost:8080/user
{"data":{"id":5,"name":"Dave","isAdmin":false}}
% curl http://localhost:8080/user
{"data":[{"id":1,"name":"Alice","isAdmin":false},{"id":2,"name":"Bob","isAdmin":false},{"id":3,"name":"Carol","isAdmin":false},{"id":5,"name":"Dave","isAdmin":false}]}

今回は対応しなかったが、アップデートや削除も同じ要領で実装すればよい。