Prisma の外部ライブラリを活用してできること
1. はじめに
株式会社ソニックムーブでバックエンドエンジニアやってます、mito1111 です、よろしくです。
今回は弊社が所属するクラウドワークスグループのアドベントカレンダーのクリスマス特別企画ということで、
現在案件で使用している Prisma という TypeScript ORM の外部ライブラリについて紹介します。
自分が所属する部署では、バックエンドは PHP (Laravel) を使って開発をしてきたこともあり、Eloquent がメインの ORM でした。
今回はサーバーレス案件ということで、部署で初めて TypeScript (Express) をメインに開発を進める必要がありました。
技術選定の中で Prisma を使うと、意外と痒いところに手が届きそうな感じがしたので、自分なりに勉強してきました。
今回の内容はその一部を抜粋したものになります。
一応念押ししますが、Prisma の基本的な使い方を紹介する記事ではないのでご注意ください。
Prisma の公式ドキュメントはこれです。
正直クリスマスなのに何やってんだ感はありますが、興味があれば見ていっていただけると幸いです。
ちなみに筆者のクリスマスの予定は Zero です。誠に遺憾です。怒りの全てをアドカレに注ぎ込みます。
2. 概要紹介
今回紹介する内容は ↓ の通りです。
主に Prisma の外部ライブラリを活用してできることをメインにまとめていきます。
-
- ER 図書きたくない: prisma-dbml-generator
-
- 論理削除ができるようになりたい: prisma-extension-soft-delete
-
- RDS のリードレプリカ対応をしたい: @prisma/extension-read-replicas
-
- DB カラムを暗号化したい: prisma-field-encryption
-
- Prisma で Laravel みたいに Factory を使いたい: fishery
-
- vitest で DB テストをやるために: vitest-environment-vprisma
3. 前提条件
Prisma がメインの記事ではあるので、それ以外に細かく言及するつもりはないですが、一応環境情報を記載しておきます。
環境構築方法とか詳細な設定とかは機会があれば別の記事にするかもです。
3-1. アプリケーションについて
アプリケーションバージョン情報
名前 | バージョン | 用途 | 備考 |
---|---|---|---|
TypeScript | 5.7.2 |
アプリケーション主要言語 | |
Hono | 4.6.12 |
アプリケーションフレームワーク | なんとなく使ってる |
Bun | 1.1.38 |
アプリケーションランタイム | 主に API サーバーの起動で使用 |
PNPM | 9.14.4 |
パッケージ管理 | |
Prisma | 5.22.0 |
TypeScript ORM | 最新版を使わないのは外部ライブラリとの依存関係関連のため 今回は MySQL に対して使用する。 |
3-2. それ以外の環境情報
環境情報
名前 | バージョン | 用途 | 備考 |
---|---|---|---|
ローカルマシン | MacBookPro M1 13inch 2020 |
メモリ 16GB
|
|
Docker | 27.2.0 |
DB コンテナ (MySQL) 構築に必要 | |
docker-compose | 2.29.2 |
↑ に同じ | |
asdf | 0.14.1 |
Node.js ランタイムバージョン管理 |
3-3. アプリケーションディレクトリ構成
説明のため一部端折ってるとこがあります。プロジェクトルートディレクトリは/path/to/backend
と表記してます。
ディレクトリ構成
. /path/to/backend
├── .env.local
├── .env.testing
├── .tool-versions
├── docker/
│ ├── compose.yml
│ ├── Makefile
│ └── mysql/
│ ├── docker-entrypoint-initdb.d/
│ │ └── init.sql
│ └── my.cnf
├── package.json
├── pnpm-lock.yaml
├── prisma/ (PrismaスキーマやマイグレーションSQLを格納)
│ ├── dbml/
│ │ ├── formatter.py
│ │ ├── output.dbml
│ │ └── readme.md
│ ├── migrations/
│ └── schema.prisma
├── src/ (アプリケーションソース、一部省略)
│ ├── @types/
│ ├── controllers/
│ ├── domains/
│ │ ├── presenters/
│ │ ├── requests/
│ │ └── usecases/
│ ├── index.ts
│ ├── infrastructures/
│ │ ├── clients/
│ │ │ ├── encryptions/
│ │ │ │ ├── index.ts
│ │ │ │ └── User.ts
│ │ │ ├── prisma.ts
│ │ │ └── vprisma.ts
│ │ ├── entities/
│ │ │ ├── childrens.ts
│ │ │ ├── mothers.ts
│ │ │ ├── pagination.ts
│ │ │ └── users.ts
│ │ ├── factories/
│ │ │ ├── childrens.ts
│ │ │ └── mothers.ts
│ │ ├── repositories/
│ │ │ ├── childrens.impl.ts
│ │ │ ├── childrens.ts
│ │ │ ├── dtos/
│ │ │ │ ├── mothers.dto.ts
│ │ │ │ └── users.dto.ts
│ │ │ ├── mothers.impl.ts
│ │ │ ├── mothers.ts
│ │ │ ├── users.impl.ts
│ │ │ └── users.ts
│ │ └── seeders/
│ ├── middlewares/
│ ├── services/
│ ├── tests/
│ │ └── units/
│ │ └── infrastructures/
│ │ └── repositories/
│ │ ├── mothers.test.ts
│ │ └── users.test.ts
│ ├── utils/
│ └── viewcomposers/
├── swagger/
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.ts
4. Prisma とは
紹介するとしてもまず Prisma ってなんだよって人がいるかもです。マジすんませんでした。なので軽く Prisma がなんなのか説明します。
Prisma はドキュメントのIntroductionにも書かれているように、「Node.js ランタイム・TypeScript バックエンドで機能するように開発された ORM (DB にアプリケーションからクラスを経由してアクセスできるもん)」であり、主にサーバーレスアプリケーションで利用することを目的としてます。
Prisma Introduction - Why Prisma ORM? (抜粋)
TLDR
Prisma ORM's main goal is to make application developers more productive when working with databases. Here are a few examples of how Prisma ORM achieves this:
・Thinking in objects instead of mapping relational data
・Queries not classes to avoid complex model objects
・Single source of truth for database and application models
・Healthy constraints that prevent common pitfalls and anti-patterns
・An abstraction that makes the right thing easy ("pit of success")
・Type-safe database queries that can be validated at compile time
・Less boilerplate so developers can focus on the important parts of their app
・Auto-completion in code editors instead of needing to look up documentation
引用元: https://www.prisma.io/docs/orm/overview/introduction/why-prisma
基本的には DB を操作するバックエンド開発者の開発体験を向上させるため、DB モデルをオブジェクトとして利用できたり、型を厳密に設定できたりします。↑の引用によると、それをなすためにPrismaでは次のような機能を提供しているとのことです。
・データをオブジェクトとして利用する (クラスとデータをマッピングしなくて済む)。
・クラスではなくクエリを利用する (DBモデルが複雑になっても使いやすいように)。
・DBとアプリケーション層を繋ぐただ一つのリソースとなる (DB-アプリ間の疎通はPrismaだけで行える)。
・制約が多い (アンチパターンの抑止・セキュリティ向上)。
・開発者がベストプラクティスな開発を行うような指針となる抽象化ができる (Pit of Success: 成功の落とし穴っていう開発哲学らしい)。
・型安全 (コンパイルとかするときに型が違ったらエラーになって欲しい、TypeScriptだし)。
・よく使う処理やメソッドが少ない (それだけまたはそれを組み合わせて使えばやりたいことは大体できる、開発者が主要なロジック実装に注力できるように)。
・ドキュメントを調べる手間を省略できるコードエディタ (おそらくPrisma StudioというGUIのこと)
ちなみにどういうプロジェクトで利用できるかという話だと、REST API や GraphQL などを使うサーバーレスアプリケーションが最適らしいです。
開発にフォーカスすると、SQL クエリの利用・DB モデリング・マイグレーション・シーダーなど、ORM として利用できる機能が一通り揃っています。
つまりサーバーレス開発ならすげえ便利ということです。
5. Prisma 外部ライブラリでできること
ここから本格的なライブラリの紹介をしていきます。
prisma-dbml-generator
5-1. ER 図書きたくない:基本設計において DB モデリングは必須です。でもめんどくさいです。
なんでマイグレーション用の SQL を書きながら、MySQL workbentch や Draw.io で構成図まで描かなければいけないのか。
バックエンド七不思議の一つだと思ってます (流石に誇張)。
できることなら DB モデリング・ER 図作成・マイグレーションを一つのschema.prisma
で完結させたい欲があります。それをできるようにしたのがprisma-dbml-generator
です。
具体的に何ができるのかというと、DB モデリング・マイグレーションで使用するschema.prisma
に Markdown などをコメントとして書き込んで DBML というドキュメントを自動生成するものです。
勘のいい人はお気づきだと思いますが、↑ の処理だけだとファイルを生成するだけで可視化はできないです。そこでdbdocsを使います。これは端的に DBML をブラウザで可視化するものです。
つまりprisma-dbml-generator
とdbdocs
を併用することで、ER 図作成時間を筆者のクリスマスの予定同様Zeroにすることが可能です。
今回 ER 図を出力するschema.prisma
は以下の通りです。一部あとで紹介する用の内容が入ってますがとりあえず気にしないでください。
schema.prisma
のgenerator dbml
で生成する DBML ドキュメントの情報を指定できます。今回は/path/to/backend/prisma/dbml/schema.dbml
で出力します。
///
の部分で ER 図や DB ドキュメントに記載したいコメントを記載できて、逆に//
を使うと普通のコメント行として認識されます。
ちなみに筆者はchildrenがchildの複数形であることを知ってます。sつけたかっただけです。義務教育の敵じゃないです。
schema.prisma
generator client {
provider = "prisma-client-js"
}
generator dbml {
provider = "prisma-dbml-generator"
output = "./dbml"
projectDatabaseType = "MySQL"
outputName = "schema.dbml"
projectNote = "適当なサンプルDBMLドキュメント"
projectName = "SampleDBMLProject"
}
generator fieldEncryptionMigrations {
provider = "prisma-field-encryption"
output = "../src/infrastructures/clients/encryptions"
concurrently = true
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
/// ## 概要
/// - __母親テーブル__
model Mother {
/// 母親のID
id Int @id @default(autoincrement()) @map("id")
/// 母親の名前
name String @unique @db.VarChar(255) @map("name")
/// 年齢
age Int @map("age")
/// 登録日時 (YYYY-MM-DD HH:mm:ss)
createdAt DateTime @default(now()) @map("created_at")
/// 更新日時 (YYYY-MM-DD HH:mm:ss)
updatedAt DateTime @updatedAt @default(now()) @map("updated_at")
/// 論理削除日時日時 (YYYY-MM-DD HH:mm:ss)
deletedAt DateTime? @map("deleted_at")
// リレーションテーブル
/// mothers:children = 1:多
childrens Children[]
@@map("mothers")
}
/// ## 概要
/// - __子供テーブル__
model Children {
/// 子供のID
id Int @id @default(autoincrement()) @map("id")
/// mothers.id (1:多)
motherId Int @map("mother_id")
/// 子供の名前
name String @db.VarChar(255) @map("name")
/// 性別 (1: 男性、2: 女性)
kind Int @map("kind")
/// 年齢
age Int @map("age")
/// 登録日時 (YYYY-MM-DD HH:mm:ss)
createdAt DateTime @default(now()) @map("created_at")
/// 更新日時 (YYYY-MM-DD HH:mm:ss)
updatedAt DateTime @updatedAt @default(now()) @map("updated_at")
/// 論理削除日時日時 (YYYY-MM-DD HH:mm:ss)
deletedAt DateTime? @map("deleted_at")
// リレーションテーブル
/// mother:children = 1:多数
mother Mother @relation(fields: [motherId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@map("childrens")
// 外部キーのインデックス
@@index([motherId], map: "childrens_motherId_fkey")
}
/// ## 概要
/// - __暗号化検証用テーブル__
/// - ユーザー情報を暗号化してみる
model User {
/// ユーザーID
id Int @id @default(autoincrement()) @map("id")
/// ユーザー名 (暗号化)
name String @unique @db.VarChar(512) @map("name") /// @encrypted?mode=strict
nameHash String? @unique @map("name_hash") /// @encryption:hash("name")?algorithm=sha512&inputEncoding=base64&outputEncoding=base64
/// メールアドレス (暗号化)
email String @unique @db.VarChar(512) @map("email") /// @encrypted?mode=strict
emailHash String? @unique @map("email_hash") /// @encryption:hash("email")?algorithm=sha512&inputEncoding=base64&outputEncoding=base64
/// パスワード (暗号化)
password String @db.VarChar(512) @map("password") /// @encrypted?mode=strict
passwordHash String? @unique @map("password_hash") /// @encryption:hash("password")?algorithm=sha512&inputEncoding=base64&outputEncoding=base64
/// 登録日時 (YYYY-MM-DD HH:mm:ss)
createdAt DateTime @default(now()) @map("created_at")
/// 更新日時 (YYYY-MM-DD HH:mm:ss)
updatedAt DateTime @updatedAt @default(now()) @map("updated_at")
/// 論理削除日時日時 (YYYY-MM-DD HH:mm:ss)
deletedAt DateTime? @map("deleted_at")
@@map("users")
}
次に依存関係の解決を行います。
依存解決
pnpm add -D prisma-dbml-generator dbdocs
依存解決したら普通に prisma でマイグレーション処理を行います。
このままコマンドを打ってもエラーになるので、env-cmd
を用いて.env.local
にあるDATABASE_URL
を参照するよう設計してます。
マイグレーション処理
# package.jsonのマイグレーションスクリプト
pnpm migrate:local (意味: env-cmd -f .env.local pnpm dlx prisma migrate dev)
「じゃああとはdbdocs
コマンドで表示だね!」ってそうは問屋が卸さないというのがエンジニアあるあるです。
このまま表示もできるんですが、場合によってはdbdocs
コマンド実行時にエラーが発生するケースがあります。
例えば、/// @encrypted
とかが書いてある部分は Prisma 外部ライブラリ由来なので、prisma-dbml-generator
ではコンパイルできないというのがオチです。
その場合は出力したschema.dbml
のエラー箇所を手動で直すか、↓ のようなスクリプトをラップして整形処理を行う必要があります。
これはよく Markdown の Note の部分が変になるので、それを修正するようにしたスクリプトです。
フォーマッタースクリプト
import re
import os
import sys
input_file_path = os.path.join(os.getcwd(), 'prisma/dbml/schema.dbml')
output_file_path = os.path.join(os.getcwd(), 'prisma/dbml/output.dbml')
match_pattern = r"Note:\s*'([^']+)'"
replace_pattern = r"Note: '''\n\1\n'''"
try:
# schema.dbmlがなく、output.dbmlが存在すれば終了
if not os.path.exists(input_file_path) and os.path.exists(output_file_path):
print("[Success] 処理を終了します。")
sys.exit(0)
# schema.dbmlを読み込む
with open(input_file_path, 'r', encoding='utf-8') as f:
input_text = f.read()
# 正規表現を適用
output_text = re.sub(match_pattern, replace_pattern, input_text, flags=re.MULTILINE)
# output.dbmlに書き込む
with open(output_file_path, 'w', encoding='utf-8') as f:
f.write(output_text)
# 元のファイルが存在すれば削除
if os.path.exists(input_file_path):
os.remove(input_file_path)
except Exception as e:
raise e
まあそんなこんなで修正した DBML ファイルoutput.dbml
が ↓ になります。
output.dbml
//// ------------------------------------------------------
//// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
//// ------------------------------------------------------
Project "SampleDBMLProject" {
database_type: 'MySQL'
Note: '適当なサンプルDBMLドキュメント'
}
Table mothers {
id Int [pk, increment, note: '母親のID']
name String [unique, not null, note: '母親の名前']
age Int [not null, note: '年齢']
createdAt DateTime [default: `now()`, not null, note: '登録日時 (YYYY-MM-DD HH:mm:ss)']
updatedAt DateTime [default: `now()`, not null, note: '更新日時 (YYYY-MM-DD HH:mm:ss)']
deletedAt DateTime [note: '論理削除日時日時 (YYYY-MM-DD HH:mm:ss)']
childrens childrens [not null, note: 'mothers:children = 1:多']
Note: '## 概要
- __母親テーブル__'
}
Table childrens {
id Int [pk, increment, note: '子供のID']
motherId Int [not null, note: 'mothers.id (1:多)']
name String [not null, note: '子供の名前']
kind Int [not null, note: '性別 (1: 男性、2: 女性)']
age Int [not null, note: '年齢']
createdAt DateTime [default: `now()`, not null, note: '登録日時 (YYYY-MM-DD HH:mm:ss)']
updatedAt DateTime [default: `now()`, not null, note: '更新日時 (YYYY-MM-DD HH:mm:ss)']
deletedAt DateTime [note: '論理削除日時日時 (YYYY-MM-DD HH:mm:ss)']
mother mothers [not null, note: 'mother:children = 1:多数']
Note: '## 概要
- __子供テーブル__'
}
Table users {
id Int [pk, increment, note: 'ユーザーID']
name String [unique, not null, note: 'ユーザー名 (暗号化)']
nameHash String [unique, note: '']
email String [unique, not null, note: 'メールアドレス (暗号化)']
emailHash String [unique, note: '']
password String [not null, note: 'パスワード (暗号化)']
passwordHash String [unique, note: '']
createdAt DateTime [default: `now()`, not null, note: '登録日時 (YYYY-MM-DD HH:mm:ss)']
updatedAt DateTime [default: `now()`, not null, note: '更新日時 (YYYY-MM-DD HH:mm:ss)']
deletedAt DateTime [note: '論理削除日時日時 (YYYY-MM-DD HH:mm:ss)']
Note: '## 概要
- __暗号化検証用テーブル__
- ユーザー情報を暗号化してみる'
}
Ref: childrens.motherId > mothers.id [delete: Cascade]
最後にこれの ER 図を出力してみます。
dbdocs
コマンドを使用するのですが、以下のdbdocs login
を使って登録を行う必要があります。単純に出力するだけならフリープラン (無料)で問題ないです。
dbdocs の使い方
# dbdocsへの会員登録 (ブラウザが起動して、webページで登録)
pnpm dlx dbdocs login
# DBMLファイルをER図に変換
pnpm dlx dbdocs build ./prisma/dbml/output.dbml
ER図
dbdocsでのER図表示
↑ の ER 図ではカラムの説明やリレーションも正しく記載されているので、非常に見やすいです。
DBドキュメント (wiki)
dbdocsでのDBドキュメント (wiki) 表示
///
に記載した Markdown 項目を参照して wiki ページも自動生成してくれます。より詳細に書くとドキュメントとしての意味合いがより強くなる気がします。
この DB ドキュメントは public となっており、発行した URL がわかる人は誰でもアクセスができてしまいます。
なのでパスワードをかけることで private にすることもできます (パスワードは好きなように設定でき、プロジェクト名はschema.prisma
のgenerator dbml
で設定した値です)。
dbdocs プロジェクト保護
# dbdocsプロジェクトをprivateに変更する
dbdocs password -s <DBMLプロジェクトにかけるパスワード> -p <プロジェクト名>
prisma-extension-soft-delete
5-2. 論理削除ができるようになりたい:結論から言うと、デフォルト状態では Prisma は物理削除しかできません。何言ってんだ?って思うかもですが、本当です。
論理削除をやるには、Prisma クエリのdelete
/deleteMany
メソッドの処理をdeletedAt
カラムへのDate
データ更新処理に変えてやる必要があります。
ただそんなめんどい処理はやりたくないので、prisma-extension-soft-delete
を使って自動で物理削除を論理削除に変更してもらいます。依存解決は↓です。
依存解決
pnpm add prisma-extension-soft-delete
ここからはアプリケーション実装が絡んでくるので、所々ソースコードが出てきます。
まず先に Prisma クライアントについて ↓ に実装を紹介します。
prisma.ts
import { Prisma, PrismaClient } from "@prisma/client";
import { createSoftDeleteExtension } from "prisma-extension-soft-delete";
import { readReplicas } from "@prisma/extension-read-replicas";
import { EnvGlobal } from "../../@types/globals/env";
import { fieldEncryptionMiddleware } from "prisma-field-encryption";
import { migrate } from "./encryptions";
// Prismaの設定 (ベースクライアント)
// RDSでDB proxyを使う場合はピン留めという現象があるので、クライアントをdisconnectせずに使い回すようにする
const client = new PrismaClient({
log: ["local"].includes(EnvGlobal.APP_ENV) ? ["query"] : undefined,
// トランザクション設定
transactionOptions: {
maxWait: 10000,
timeout: 20000,
// RDSのMySQLではデフォルトでrepeatable readが使われる
isolationLevel: Prisma.TransactionIsolationLevel.ReadUncommitted,
},
});
// 非推奨のuseメソッドでミドルウェアを使用しないとfindUniqueで復号化されない
client.$use(fieldEncryptionMiddleware({ dmmf: Prisma.dmmf }));
/**
* Prismaクライアントに拡張機能を追加
*/
export const customPrismaClient = () => {
const newClient = client
.$extends(
// ソフトデリート設定 (これだとcascade deleteはできない)
createSoftDeleteExtension({
// デフォルト設定
defaultConfig: {
field: "deletedAt",
createValue: (deleted) => {
return deleted ? new Date() : null;
},
allowToOneUpdates: true,
allowCompoundUniqueIndexWhere: true,
},
// ソフトデリートを有効にするDBモデル
models: {
Mother: true,
Children: true,
User: true,
},
})
)
.$extends(
// リードレプリカ設定
readReplicas({
url:
EnvGlobal.APP_ENV === "prd"
? EnvGlobal.DATABASE_URL_RO
: EnvGlobal.DATABASE_URL,
})
);
return newClient;
};
// 暗号化されたテーブルマイグレーション
await migrate(customPrismaClient() as unknown as PrismaClient);
/**
* 拡張したPrismaクライアントの型定義
*/
type CustomPrismaClient = ReturnType<typeof customPrismaClient>;
// 以下を追加
declare global {
var __db__: CustomPrismaClient | undefined;
}
let customPrisma: CustomPrismaClient;
if (["dev", "stg", "prd"].includes(EnvGlobal.APP_ENV)) {
customPrisma = customPrismaClient();
} else {
if (!global.__db__) {
global.__db__ = customPrismaClient();
}
customPrisma = global.__db__;
// サーバーレスでRDSと連携する時は↓ はいらない
// customPrisma.$connect();
}
export { customPrisma };
ざっくり説明すると、「PrismaClient
に外部ライブラリ設定を反映した Prisma クライアントを作成し、それをグローバルで使用できるようにした」感じです。
ちなみにprisma migrate dev
で生成した Prisma クライアントはschema.prisma
で設定してない場合、node_modules
ディレクトリに出力されます。ここからリポジトリなどで使用する各種 DB モデルにアクセスする感じです。将来的に AWS 内でテストすることを想定しているので、それ関連の設定がされてますが一旦忘れてください。
注目すべき点として、customPrismaClient
関数で利用されているcreateSoftDeleteExtension
関数設定です。ここではどの DB に対して論理削除を行うか、論理削除をどのカラムで判定・管理するかを設定できます。今回は全てのテーブルに対して、deletedAt
(MySQL 上 ではdeleted_at
)に日付データを入れることで論理削除を表現してます。この設定をするだけで Prisma クエリ (リポジトリなど) は特にいじらなくても大丈夫です。
ということで実際に論理削除ができるのか検証してみます。今回は DB にmothers
テーブルとchildrens
テーブルを作成し、「一人の母親に複数の子供が属している」というケースでリレーションを実装してます。それぞれのデータは ↓ のような感じです。
mothersテーブル
mothersテーブル
childrensテーブル
childrensテーブル
この状態でmothers.id: 1
のデータを取得すると、↓ のようになります。
API実行
mothers.id: 1
データ取得API実行結果
次にmothers.id: 1
のデータを削除した後に同様にデータを取得します。Prisma クエリではdelete
メソッドを使用してます。
削除・取得API実行
論理削除API実行結果
mothers.id: 1
データ取得API再実行結果
mothers.id
を指定した詳細取得ではデータが取れませんでした。じゃあ全件取得なら?
全件取得API実行
mothers
テーブル全件取得結果 (表示件数10)
findMany
メソッドでも論理削除されているデータは含まれてませんでした。最後に削除後のデータを確認してみます。
論理削除後の`mothers`テーブル
論理削除実行後のmothers
テーブル
mothers.id: 1
データのdeleted_at
に論理削除日時が入ってます。どうやら論理削除はちゃんとできているようです。
@prisma/extension-read-replicas
5-3. RDS のリードレプリカ対応をしたい:RDS でマルチ AZ でインスタンスやクラスターを構築する際に、一部インスタンスを読み取り専用 (リーダーインスタンス) として使用すると思います。
Prisma では公式が推奨しているextension-read-replicas
を使うことで、リーダーインスタンスにへの DB アクセスが可能になります。依存解決は↓です。
依存解決
pnpm add @prisma/extension-read-replicas
実装は Prisma クライアントの実装に書いてある通りで、readReplicas
関数を適用するだけです。
今回の例では、ライターインスタンスへの DB URL をDATABASE_URL
、リーダーインスタンスへの DB URL をDATABASE_URL_RO
と区別しています。それぞれは環境変数として.env.local
で管理しています。
ただそれはクラウド DB を使った場合の話なので、今回はこういう実装をするよ的な感じで思っといてください。基本的にはライブラリ適用後は Prisma がよしなにやってくれる感じで、実装の中でこっちはライター・こっちはリーダーにアクセスしたい場合は明示的に指定することも可能です。
実際現在担当している案件では、同様の実装で Aurora DB クラスターの各種インスタンスと疎通を図っています。
prisma-field-encryption
5-4. DB カラムを暗号化したい:DB テーブルによってはユーザーの個人情報 (名前・メールアドレスなど) を保存する場合があります。その際に適切な暗号化処理を施しておかないと、攻撃者に個人情報をパクられる可能性が高くなります。
ちなみに筆者は今年の夏頃、自分のクレジットカードで生成 AI 関連の架空請求をされ、3 万円パクられました。今年の個人的 No.1 トピックスです。ガチで気をつけたほうがいいです。
というように個人情報は暗号化して保護しておきたいところです。
prisma-field-encryption
では、特定の DB・特定のカラムについて暗号化を行い、取得するときに復号化・保存や更新をするときに暗号化を自動で行います。
ここでschema.prisma
に記載した/// @encrypted
の謎が解明されるわけですが、これらはprisma-field-encryption
で暗号化するカラムを示しており、その部分だけ選択的に暗号化を行います。依存解決は↓です。
依存解決
pnpm add prisma-field-encryption
まずは公式の READMEに則り、暗号化・復号化で使う暗号情報を環境変数で.env.local
に定義します。Prisma クライアント側で設定もできるんですが、Secrets Manager とかで管理した方が安全な気がしているので、今回は一括環境変数管理で行きます。
.env.local
APP_ENV=local
DATABASE_URL=mysql://app:app@localhost:3306/app?connection_limit=5&connect_timeout=0&pool_timeout=30
DATABASE_URL_RO=mysql://app:app@localhost:3306/app?connection_limit=5&connect_timeout=0&pool_timeout=30
PRISMA_FIELD_ENCRYPTION_KEY=<cloakで生成したマスターキー>
PRISMA_FIELD_ENCRYPTION_HASH_SALT=<暗号化ハッシュカラムで使用するソルト>
PRISMA_FIELD_DECRYPTION_KEYS=<キーローテーションで以前使った暗号化マスターキーを復号化キーにできる (,区切りで配列管理できる)>
その後schema.prisma
をprisma migrate dev
することで、generator fieldEncryptionMigrations
に設定された暗号化対象 DB のマイグレーション用クライアントを出力します。
次に Prisma クライアントでPrismaClient
クラスにfieldEncryptionMiddleware
を適用します。
公式ドキュメントでは Prisma のuse
メソッドではなく、extends
メソッドを使うよう記載されてますが、あれは嘘です。findMany
やfindUnique
メソッドを実行した際に復号化されない状態で取得するようになります。
Prisma クライアント側では、クライアント起動時に暗号化されたテーブルをマイグレーションするようにしてます。await migrate(...)
の部分ですが、これは先ほど生成したマイグレーションクライアントの関数で、DB へのアクセス方法 (取得・登録・更新) で暗号化・復号化をコントロールする箇所です。
長い前置きはこれくらいにして、実際に暗号化できているか検証してみます。今回はusers
テーブルを用いてデータを登録すると暗号化されるか、取得する際に復号化されるかを見ていきます。検証用の API は ↓ のようになります。
サンプル API コントローラー
import { Context } from "hono";
import { getEnvs } from "../../@types/globals/env";
import { GetHealthCheck200 } from "../../@types/schemas/response/samples";
import { customPrisma } from "../../infrastructures/clients/prisma";
import { User } from "@prisma/client";
/**
* サンプルコントローラー
*/
export class SamplesController {
/**
* ヘルスチェックコントローラー
*
* @param {Context} c Honoコンテキスト
*
* @returns {Promise<any>}
*/
static async index(c: Context): Promise<any> {
const envs = getEnvs(c);
// ここで新規登録 (DBでは暗号化される)
const data: User = await customPrisma.user.create({
data: {
name: "ジョン万次郎",
email: "j.manjiro@example.com",
password: "testKA00",
},
});
// 登録されたデータを取得して出力 (復号化される)
const output: User | null = await customPrisma.user.findUnique({
where: { id: data.id },
});
console.debug(output);
return c.json(
<GetHealthCheck200>{
envs: {
appEnv: envs.APP_ENV,
databaseUrl: envs.DATABASE_URL,
databaseUrlRo: envs.DATABASE_URL_RO,
},
},
200
);
}
}
初めのusers
テーブルは空の状態にしておきます。その後実行したAPI の結果を ↓ に示します。一枚は登録をした後の DB テーブルで、name/email/password
は暗号化されていますが、もう一方はconsole.debug
の出力で、同じusers.id: 4
のデータは復号化された状態で取得できていることがわかります。
暗号化結果
API実行: データ登録時
API実行: データ取得時
fishery
5-5. Prisma で Laravel みたいに Factory を使いたい:Laravel の Factory クラスのような DB ファクトリーを作成できるライブラリを紹介します。Prisma はデフォルトでシーダー設定はできるんですが、ファクトリーに関しては特に実装されてない感じでした。
案件でも Factory を作成するメリットがなくて使用してないですが、一応こういうのもあるよ的な感じで載せときます。
ファクトリー作成ライブラリは紹介するfishery
以外にもprisma-fabbrica
といった Prisma 専用ファクトリーライブラリもあります。
fishery
の目的は任意の DB スキーマを参照して、その DB モデルに一致したファクトリーを定義できることです。Prisma 以外の ORM でも使用できます。依存解決は↓です。
依存解決
pnpm add -D fishery
mothers
テーブルを使った実装例は ↓ の通りです。
mothers テーブルのファクトリー
import { Mother } from "@prisma/client";
import { Factory } from "fishery";
export const mothersFactory = Factory.define<Mother>(({ sequence }) => {
return <Mother>{
id: sequence,
name: `mother${sequence}`,
age: 20 + sequence,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
childrens: [],
};
});
コールバック関数の引数sequence
はファクトリーを複数回呼び出した際に、その番号をインクリメントして使用できるものです。例えばmothersFactory
を 3 回呼び出すとsequence: 1, 2, 3
の三つのデータを生成できる感じです。
これらファクトリーの主な適用先はテストコードなので、テストで使用した例も紹介します。
ファクトリーテストコード
import { Children, Mother } from "@prisma/client";
import { MotherRepositoryImpl } from "../../../../infrastructures/repositories/mothers.impl";
import { customPrisma } from "../../../../infrastructures/clients/prisma";
import { MotherEntity } from "../../../../infrastructures/entities/mothers";
import { MotherDto } from "../../../../infrastructures/repositories/dtos/mothers.dto";
import { PaginationEntity } from "../../../../infrastructures/entities/pagination";
import { ChildrenEntity } from "../../../../infrastructures/entities/childrens";
import { mothersFactory } from "../../../../infrastructures/factories/mothers";
const repository = new MotherRepositoryImpl();
describe("infrastructures/repositories/mothers.impl.ts", () => {
beforeAll(() => {});
afterAll(() => {});
beforeEach(() => {});
afterEach(() => {});
describe("findById", () => {
test("正常系: ファクトリーサンプル", async () => {
const data = mothersFactory.buildList(10);
expect(data.length).toBe(10)
});
});
});
この例ではbuildList
メソッドを使うことで、複数回ファクトリーを呼び出してデータを生成しています。10 回呼び出したので、データは 10 個生成されていることを期待してる感じです。
vitest-environment-vprisma
5-6. vitest で DB テストをやるために:DB テストを行う際に注意する点として、他の人が書いた DB テストと依存しないようにすることです。例えば A さんが登録処理で 10 個のデータを登録するテストを実装し、B さんが 2 個のデータを登録するテストを実装したとします。
A さんはデータ数 10 でアサーションすればいいですが、B さんは 2 ではなく 12 をアサーションしないといけなくなります。これは二人が同じ DB を永続化して使用していることに起因します。
なのでテストコードを実行するたびに DB は常に空っぽであればそれぞれのテストコードが依存せずに実行できます。
Laravel ではRefreshDatabase
トレイトでテスト実行時に DB を初期化する処理がデフォルトで用意されてましたが、vitest や jest にはありませんでした。
「なんとしてでも欲しい」と思い、ネットの海を彷徨い、目を皿にして調べまくった結果、Prisma/vitest で使用できるライブラリとしてvitest-environment-vprisma
に目をつけました。ここでは実装の都合上dotenv
が依存関係として必要なのでそれもインストールしておきます。
依存解決
pnpm add -D vitest-environment-vprisma dotenv
設定方法としては、まずvitest.config.ts
とtsconfig.json
をいじります。
vitest.config.ts
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import { configDefaults } from "vitest/config";
import dotenv from "dotenv";
export default defineConfig({
test: {
exclude: ["./.build", "./node_modules"],
testTimeout: 10000,
globals: true,
coverage: {
exclude: [
...configDefaults.exclude,
"**/orval.config.ts",
"**/.build/**",
"**/node_modules/**",
],
reporter: ["text", "html", "json"],
},
env: dotenv.config({ path: ".env.testing" }).parsed,
environment: "vprisma",
setupFiles: [
"vitest-environment-vprisma/setup",
"./src/infrastructures/clients/vprisma.ts",
],
environmentOptions: {
vprisma: {
baseEnv: "node",
verboseQuery: false,
disableRollback: false,
},
},
},
});
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"experimentalDecorators": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"vitest/globals", "vitest-environment-vprisma"
],
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"rootDir": ".",
"outDir": "./.build",
"typeRoots": [
"./node_modules",
"./src/@types"
]
},
"include": ["./src/**/*.ts"],
"exclude": [
"./node_modules",
"./.build"
]
}
↑ にコードを貼ったんですが、vitest.config.ts
ではenv
以下を追加します。今回はテスト用の環境変数をロードさせたいので、.env.testing
を dotenv で参照するよう設定してます。setupFiles
の部分でvprisma.ts
というテスト用の Prisma クライアントをあとで自作するので、テストでは常にそのクライアントを起動するようにします。
tsconfig.json
ではtypes
の部分にvitest-environment-vprisma
を設定するだけで大丈夫です。
次に ↑ で言及したvprisma.ts
を実装します。これは既存の Prisma クライアントを vprisma の jest like なクライアントにモックしたクライアントです。同じディレクトリ階層にあるprisma.ts
をモックし、vPrisma.client
をテストでは使用するようにします。
こうすることで Laravel のRefreshDatabase
トレイトのようなテスト DB 初期化が行えます。
vprisma.ts
import { vi } from "vitest";
// DBテストで使用するPrismaクライアントをモックする
vi.mock("./prisma", () => ({
customPrisma: vPrisma.client,
}));
最後に DB テストで動作を検証してみます。テストケースは ↓ のようになります。
DB テストケース
describe("infrastructures/repositories/mothers.impl.ts", () => {
beforeAll(() => {});
afterAll(() => {});
beforeEach(() => {});
afterEach(() => {});
describe("vprismaのテスト", () => {
test("正常系: データが追加できるか (最終的に2個)", async () => {
// データがないことを確認
expect(await customPrisma.mother.count()).toBe(0);
// データを1個生成
await customPrisma.mother.create({ data: { name: "sample1", age: 21 } });
expect(await customPrisma.mother.count()).toBe(1);
// 同じテストケース内でさらに追加する
await customPrisma.mother.create({ data: { name: "sample2", age: 22 } });
expect(await customPrisma.mother.count()).toBe(2);
});
test("正常系: データが初期化されて、依存しないか (最終的に1個)", async () => {
// データがないことを確認
expect(await customPrisma.mother.count()).toBe(0);
// データを1個生成 (nameでユニーク制約があるけど、削除されてるから問題なし)
await customPrisma.mother.create({ data: { name: "sample1", age: 21 } });
expect(await customPrisma.mother.count()).toBe(1);
});
});
});
結果は ↓ で、全てのテストケースがパスしてます。テストケース間での DB 依存がないことがわかり、テストケース実行時に DB が初期化されているとわかります。
DBテスト実行結果
DBテスト実行結果 (一部別のテストが混じってるけど気にしないでください)
6. 注意点
これまで紹介してきたライブラリには使用上いくつか注意点があります。ここではそれをざっと紹介します。それらを踏まえた上で案件業務や開発に適用することを推奨します。
ちなみに筆者はそんなもの全く気にせず使った結果、開発中 5 回ほど阿鼻叫喚しました。いい思い出です。
6-1. prisma-dbml-generator
6-2. prisma-extension-soft-delete
6-3. prisma-field-encryption
6-4. fishery
7. 最後に
以上で紹介したい内容は終わりです。クリスマスなのに長々とお付き合いありがとうございました。
Prisma は他の TypeScript ORM (Sequelize・TypeORM など) と比べても外部ライブラリでできることが非常に多い印象を感じました。
特に DBML や ER 図の自動生成は DB モデリングで明らかに作業時間を削減できるので、今後のサーバーレスアプリ開発でも利用していきたいところです。
ただ ORM はそれぞれ長所・短所があるので、どの ORM を開発に適用するかは開発者の判断に委ねられますし、Prisma が完全に優れたの ORM とは限りません。
最近 Hono では Drizzle を使うのが良さげな感じになっていると思うので、自分も触ってこうかなと思ってます。Prisma 系の外部ライブラリを開発補助的な感じで使うのも個人的にはありかもです。
最後に皆さん良いクリスマス・年末をお過ごしください。それではまた。
Discussion