CLOVER🍀

That was when it all began.

Node.js × TypeScriptのORM、PrismaをMySQLで試す

これは、なにをしたくて書いたもの?

Node.js+TypeScript環境でのORMはどれを使ったらいいのかな?ということで。

このあたりみたいです。

情報を見ていると、Node.js環境ではSequelizeが有名だと思いますが、TypeScriptで使う場合はTypeORMかPrismaを選ぶようです。

今回は、Prismaを使ってみたいと思います。

Prisma

Prismaは、次世代のNode.jsとTypeScriptのORMと謳っています。

Prisma - Next-generation Node.js and TypeScript ORM for Databases

サポートしているデータベースは、以下です。

Prismaとはなにか?というのは、こちらに書かれています。

What is Prisma? (Overview) | Prisma Docs

主に以下の3つで構成されているみたいです。

また、プレビューですがPrisma Data Platformというものもあり、こちらは文字通りプラットフォームサービスの
ようで、上記とはまた性格が異なりそうです。

Prismaのいずれのツールも、Prisma schemaというものが起点になるようです。

Prisma schema (Reference) | Prisma Docs

Prisma schemaには、以下が定義されます。

このPrisma schemaの存在が、他のORMと大きく異なるものみたいです。たとえば、TypeORMではデータモデルの定義にTypeScriptの
デコレーターを使用します。

Prisma自身のドキュメントからですが、なぜPrismaが良いのか?というのはこちらを見るとよいみたいです。

Why Prisma? Comparison with SQL query builders & ORMs | Prisma Docs

Should you use Prisma as a Node.js/TypeScript ORM? | Prisma Docs

Prisma自身による、他のORMとの比較はこちらに記載されています。

Comparing Prisma to other ORMs and ODMs. | Prisma Docs

Best 11 ORMs for Node.js, Query Builders & Database Libraries in 2021

今回は、まずはPrisma Clientを中心に見ていこうかなと思います。

Prisma Client

Prisma Clientは、自動生成された、型安全なクエリービルダーです。

Prisma Client - Auto-generated query builder for your data

Go向けのものもありますが、もうメンテナンスされないようですね。

Prisma Client Go will not be Officially Maintained any more · Issue #707 · prisma/prisma-client-go · GitHub

機能は?というと、ドキュメントの項目を見た方が早そうですが。

f:id:Kazuhira:20220117221744p:plain

Prisma Client (Reference) | Prisma Docs

まずは、こちらのスクラッチで始めるGetting Startedで試していきたいと思います。

Start from scratch with relational databases (15 min) | Prisma Docs

なおQuick Startは、GitHubリポジトリをベースに進めていくようです。

Quickstart: Getting started with TypeScript & SQLite | Prisma Docs

GitHub - prisma/quickstart: 🏁 Starter templates for the 5min Quickstart in the Prisma docs.

環境

今回の環境は、こちらです。

$ node --version
v16.13.2


$ npm --version
8.1.2

データベースはMySQLを使用します。

バージョンは8.0.27で、172.17.0.2で動作しているものとします。

ちなみに、依存関係については最終的にはこうなりました。

  "devDependencies": {
    "@types/jest": "^27.4.0",
    "@types/node": "^16.11.20",
    "esbuild": "^0.14.11",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.4.7",
    "prettier": "2.5.1",
    "prisma": "^3.8.1",
    "ts-node": "^10.4.0",
    "typescript": "^4.5.4"
  },
  "dependencies": {
    "@prisma/client": "^3.8.1"
  }

セットアップ

まずは、こちらの手順に従ってプロジェクトをセットアップしていきましょう。

Start from scratch with relational databases (15 min) | Prisma Docs

$ npm init -y
$ npm i -D prisma typescript ts-node @types/node@v16

ここでインストールしたprismaというパッケージは、Prisma CLIですね。

Prisma CLI | Prisma Docs

$ npx prisma --version
prisma                  : 3.8.1
@prisma/client          : Not found
Current platform        : debian-openssl-1.1.x
Query Engine (Node-API) : libquery-engine 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/libquery_engine-debian-openssl-1.1.x.so.node)
Migration Engine        : migration-engine-cli 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/migration-engine-debian-openssl-1.1.x)
Introspection Engine    : introspection-core 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/introspection-engine-debian-openssl-1.1.x)
Format Binary           : prisma-fmt 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/prisma-fmt-debian-openssl-1.1.x)
Default Engines Hash    : 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f
Studio                  : 0.452.0

tsconfig.jsonは、このように設定。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

prisma initを実行してみます。

$ npx prisma init

prisma/schema.prismaというファイルができたようです。

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver or mongodb (Preview).
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

このようなファイルが生成されます。

prisma/schema.prisma

// 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")
}

PostgreSQL向けの設定が出力されています。

schema.prismaを修正する

ドキュメントに沿って、生成されたschema.prismaを修正していきます。

Connect your database | Prisma Docs

まずはproviderを修正。

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

envというのは環境変数を参照する仕組みのようです。

Environment variables | Prisma Docs

OS側の環境変数を参照しますが、.envというファイルからもルックアップできます。このファイルは、実はprisma initの時点でプロジェクトの
ルートディレクトリに生成されています。

.env

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

.envファイルは、いくつか探索するルールがあります。

Environment variables / Using .env files

ただ、.envファイルはバージョン管理システムにコミットしてはならないとされていて、以下の内容の.gitignoreファイルも同時に生成
されています。

.gitignore

node_modules
# Keep environment variables out of version control
.env

では、用意したMySQLに接続できるようにDATABASE_URLを修正します。

.env

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="mysql://appuser:password@172.17.0.2:3306/example"

Connection URLの記述については、こちらを参照。

Connection URLs (Reference) | Prisma Docs

次に、こちらを参照しながらデータモデルを書いていきます。

Prisma schema (Reference) | Prisma Docs

Data model (Reference) | Prisma Docs

Relations (Reference) | Prisma Docs

Names in the underlying database | Prisma Docs

インデックスの定義については、プレビュー機能のようです。

Indexes | Prisma Docs

最終的にできあがったファイルは、こちら。

prisma/schema.prisma

// 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 = "mysql"
  url      = env("DATABASE_URL")
}

model Post {
  id Int @id @default(autoincrement())
  title String @db.VarChar(255)
  url String @unique
  user User @relation(fields: [userId], references: [id])
  userId Int @map("user_id")

  @@map("post")
}
model User {
  id Int @id @default(autoincrement())
  name String  @db.VarChar(30)
  age Int
  posts Post[]

  @@map("user")
}

@の部分は属性で、だいたい意味は予想がつく気はしますが、詳しくはリファレンスに書かれています。

Prisma schema API (Reference) | Prisma Docs

これでPrisma Migrateを使ってマイグレーションします。

$ npx prisma migrate dev --name init

Prisma Migrateのドキュメントは、こちら。

Prisma Migrate | Database, Schema, SQL Migration Tool | Prisma Docs

devというのは開発コマンドのことで、本番環境での利用は想定していません。--nameの後には任意の名前を指定し、マイグレーションの名前に
反映されるようです。--nameの値はユニークである必要はなさそうです。 ※本番環境向けにはdeployというコマンドを使うようです

ちなみに、prisma migrate devの時点でデータベースに接続するようなのですが、いわゆる管理者権限のないユーザーだとエラーに
なりました。グローバルにデータベースを操作できる必要がありそうです。

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "example" at "172.17.0.2:3306"

Error: P3014

Prisma Migrate could not create the shadow database. Please make sure the database user has permission to create databases. Read more about the shadow database (and workarounds) at https://pris.ly/d/migrate-shadow

Original error: Error code: P1010

User `appuser` was denied access on the database `example`

権限設定をしなおして、もう1度実行。

$ npx prisma migrate dev --name init
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "example" at "172.17.0.2:3306"

MySQL database example created at 172.17.0.2:3306

Applying migration `20220118133834_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20220118133834_init/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (3.8.1 | library) to ./node_modules/@prisma/client in 615ms

対象のデータベースが存在しない場合は、作成するようです。

今度はうまくいき、以下のようなファイルが生成されました。

$ tree prisma
prisma
├── migrations
│   ├── 20220118133834_init
│   │   └── migration.sql
│   └── migration_lock.toml
└── schema.prisma

2 directories, 3 files

生成されたSQLファイル。

prisma/migrations/20220118133834_init/migration.sql

-- CreateTable
CREATE TABLE `post` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `title` VARCHAR(255) NOT NULL,
    `url` VARCHAR(191) NOT NULL,
    `user_id` INTEGER NOT NULL,

    UNIQUE INDEX `post_url_key`(`url`),
    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `user` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(30) NOT NULL,
    `age` INTEGER NOT NULL,

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `post` ADD CONSTRAINT `post_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

Collationがutf8mb4_unicode_ci固定になっているのが気になるのですが(使っているサーバーの設定は別のCollation)、これはPrismaでは
変更できなさそうです。

Is there a way to specify table collation in Prisma schema? · Discussion #4743 · prisma/prisma · GitHub

内部で使っているMariaDBのドライバーのデフォルトが、utf8mb4_unicode_ciであることに起因してそうですね。

GitHub - mariadb-corporation/mariadb-connector-nodejs: MariaDB Connector/Node.js is used to connect applications developed on Node.js to MariaDB and MySQL databases. MariaDB Connector/Node.js is LGPL licensed.

もうひとつのファイルには、Providerの情報が書かれていました。

prisma/migrations/migration_lock.toml

# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

また、node_modules/.prismaディレクトリには、型宣言などが生成され、こちらをプログラムの作成に利用できます。

$ tree node_modules/.prisma
node_modules/.prisma
└── client
    ├── index-browser.js
    ├── index.d.ts
    ├── index.js
    ├── libquery_engine-debian-openssl-1.1.x.so.node
    ├── package.json
    └── schema.prisma

1 directory, 6 files

そして、テーブルもこの時点で作成されています。

mysql> use example;
Database changed
mysql> show tables;
+--------------------+
| Tables_in_example  |
+--------------------+
| _prisma_migrations |
| post               |
| user               |
+--------------------+
3 rows in set (0.00 sec)

_prisma_migrationsというテーブルは、マイグレーションの履歴のようです。

mysql> select * from _prisma_migrations;
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
| id                                   | checksum                                                         | finished_at             | migration_name      | logs | rolled_back_at | started_at              | applied_steps_count |
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
| f9c4ddb8-cc8e-4353-a550-3684530697b0 | 11b11399bfa8bbd1c555a1548b87ea473d8eac9aa23aaee3c12cb6e14fcb3e83 | 2022-01-18 13:38:35.440 | 20220118133834_init | NULL | NULL           | 2022-01-18 13:38:34.679 |                   1 |
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
1 row in set (0.00 sec)

こう書くとPrismaを使うと、新規にデータベースを作る場合でないと使えないのかな?とも思うのですが、既存のプロジェクトに対しても
適用できるようです。そのパターンは、こちらを参照。

Add Prisma to an existing project that uses a relational database (15 min) | Prisma Docs

Prisma Clientを使う

では、生成された型宣言を使ってプログラムを書いていきましょう。ドキュメントは、こちらを足がかりに。

Install Prisma Client | Prisma Docs

Prisma Clientをインストールします。

$ npm i @prisma/client

確認は、テストコードで行うことにします。Jestをインストール。あと、Prettierも。

$ npm i -D jest @types/jest esbuild-jest esbuild
$ npm i -D -E prettier

設定はこんな感じです。

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

.prettierrc.json

{
  "singleQuote": true
}

あとは、テスト用のディレクトリにテストを作成していきます。

$ mkdir test

まずは、宣言部分だけ。

test/prisma.test.ts

import { Prisma, PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// ここに、テストを書く!

あとはGetting Startedと

Querying the database | Prisma Docs

Prisma Clientのドキュメントを見ながらテストコードを書いていきます。

Prisma Client (Reference) | Prisma Docs

データの登録。

CRUD (Reference) | Prisma Docs

test('insert data', async () => {
  const katsuo = await prisma.user.create({
    data: {
      name: '磯野 カツオ',
      age: 11,
      posts: {
        create: [
          {
            title:
              'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
          },
          {
            title: 'JestでTypeScriptのテストを書く',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
          },
          {
            title: 'TypeScriptでExpress',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
          },
        ],
      },
    },
  });

  expect(katsuo.name).toBe('磯野 カツオ');
  expect(katsuo.age).toBe(11);

  const wakame = await prisma.user.create({
    data: {
      name: '磯野 ワカメ',
      age: 9,
    },
  });

  expect(wakame.name).toBe('磯野 ワカメ');
  expect(wakame.age).toBe(9);

  const createdPosts = await prisma.post.createMany({
    data: [
      {
        title: 'Node.jsの管理ツール、nvmをインストールする',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/03/22/223042',
        userId: wakame.id,
      },
      {
        title: 'Node.jsアプリケーションのログ出力に、winstonを使ってみる',
        url: 'https://kazuhira-r.hatenablog.com/entry/2019/05/21/235843',
        userId: wakame.id,
      },
    ],
  });

  expect(createdPosts.count).toBe(2);
});

test('search single data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });
  expect(katsuo?.age).toBe(11);

  const katsuo2 = await prisma.user.findUnique({ where: { id: katsuo?.id } });
  expect(katsuo2).not.toBeNull();
});

使うとちょっと驚くのですが、findUniqueは補完候補にユニークなもの(主キーなど)しか現れないようになっています。
すごいですね。

リレーションのあるレコードを後から追加するパターンも行っていますが、新規データを登録する際にconnectすれば関連付けもできるようです。
※今回は意図的にpostを先に作ってしまったのでconnectは行っていませんが、userから作成すればOKです

Relation queries (Concepts) | Prisma Docs

1件検索。

test('search single data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });
  expect(katsuo?.age).toBe(11);

  const katsuo2 = await prisma.user.findUnique({ where: { id: katsuo?.id } });
  expect(katsuo2).not.toBeNull();
});

リレーションのある検索。ソートも入れています。

Relation queries (Concepts) | Prisma Docs

Filtering and sorting (Concepts) | Prisma Docs

test('search related data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
    include: { posts: true },
  });
  expect(katsuo?.age).toBe(11);

  const katsuoIncludePosts = await prisma.user.findUnique({
    where: { id: katsuo?.id },
    include: { posts: { orderBy: { url: 'desc' } } },
  });
  expect(katsuoIncludePosts?.posts).toHaveLength(3);
  expect(katsuoIncludePosts?.posts[0].title).toBe('TypeScriptでExpress');
  expect(katsuoIncludePosts?.posts[0].url).toBe(
    'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345'
  );
});

トランザクションは、xxxManyがバッチ更新的な扱いになるようですが、$transactionを使うことでPromiseをトランザクションとして
まとめることもできるようです。

Transactions and batch queries (Reference) | Prisma Docs

Transactions | Prisma Docs

$transactionを使う場合は、失敗したPromiseがあるとロールバックするようです。

ロールバックするパターン。片方のPromiseは、ユニークキーを重複させるレコードを登録して失敗させます。

test('transaction rollback', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });

  if (katsuo) {
    const currentCount = await prisma.post.count();
    expect(currentCount).toBe(5);

    try {
      const [post1, post2] = await prisma.$transaction([
        prisma.post.create({
          data: {
            title: 'TypeScript+Node.jsで、Echo Server/Clientを書いてみる',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/222927',
            userId: katsuo.id,
          },
        }),
        prisma.post.create({
          data: {
            title: 'TypeScriptでExpress',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
            userId: katsuo.id,
          },
        }),
      ]);

      const rollbackedCount = await prisma.post.count();
      expect(rollbackedCount).toBe(5);
    } catch (e) {
      expect((e as Error).message).toContain(
        'Unique constraint failed on the constraint: `post_url_key`'
      );
    }
  } else {
    throw new Error('katsuo is null');
  }
});

コミットするパターン。

test('transaction commit', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });

  if (katsuo) {
    const currentCount = await prisma.post.count();
    expect(currentCount).toBe(5);

    const [post1, post2] = await prisma.$transaction([
      prisma.post.create({
        data: {
          title: 'TypeScript+Node.jsで、Echo Server/Clientを書いてみる',
          url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/222927',
          userId: katsuo.id,
        },
      }),
      prisma.post.create({
        data: {
          title:
            'TypeScript+Node.jsプロジェクトを自動ビルドする(--watch、nodemon+ts-node)',
          url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/28/221448',
          userId: katsuo.id,
        },
      }),
    ]);

    const committedCount = await prisma.post.count();
    expect(committedCount).toBe(7);
  } else {
    throw new Error('katsuo is null');
  }
});

最後に、データを削除。

test('delete data', async () => {
  const deletedPosts = await prisma.post.deleteMany();
  const deletedUsers = await prisma.user.deleteMany();

  expect(deletedPosts.count).toBe(7);
  expect(deletedUsers.count).toBe(2);
});

とりあえず、こんなところでしょうか。

まとめ

TypeScriptのORMである、Prismaを試してみました。

補完がかなり強力で、型宣言をschema.prismaから生成しているからか、クエリーのパラメータに指定するオブジェクトの内容をほぼ補完できます。 これには驚きました。

一方で、schema.prismaがとっつきにくいというか、ネイティブなSQLからはできなくなっていることもあったりするので、ちょっと微妙な
感じもします…。
ちょっと独自の世界観も強いような印象なのですが、どうなのでしょう。使うのがPrismaだけなら、問題ないのかなぁとも。

最近はPrismaが人気らしいので、Prismaを扱ったら終わりにしようかと思っていたのですが、1度TypeORMも試して感覚を掴んでみようかな?
という気分になりました。