Prismaメモ
ORM (Object-Relational Mapping) ツールのPrismaを使う.
mysqlモジュールやmongodbモジュールを使ってもデータベースの操作はできるが,クエリを文字列として直接書かないと行けなかったりする.
Mongoを使うならMongooseを使ったら良さそうだけど,今回はデータベースをMySQLに以降してみたくなったのでパスした.
最近はDrizzleと比較されるらしい.こちらはスキーマをTypeScriptで定義できる.クエリをSQLに近い形で書くので,Prismaでは難しい痒いところに手が届くみたい.SQLにもっと詳しくなったらこっちを使ってみるのもアリかも.
ORM
データベースのデータをクライアント側でオブジェクトのように扱うための技法.素のSQL文を叩いてデータベースのCRUD操作をするのに比べて,オブジェクトのメソッドを通して操作するのでオブジェクト指向プログラミングのコードと親和性が高い.
PrismaはTypeScriptネイティブのORMツールで,型安全でありながらデータベースを操作できる.
Prismaの構成
Prismaは以下3つのツールで構成される.
- Prisma Client: SQL文を自動で作成してデータベースとのやり取りを行うクエリビルダー
- Prisma Migrate: スキーマの変更をデータベースに反映(マイグレーション)を行うツール
- Prisma Studio: データベースの中身を確認・編集できるGUIアプリ
特に上2つはよく使いそう.
似た名前のパッケージがチュートリアルに2つ登場してきて混乱した.それぞれの違いは以下の通り.
prisma
Prisma ORMの機能を提供するアプリ.npx prisma
で実行することにより,クライアントの作成やマイグレーション,クエリ実行を行う.
@prisma/client
TSのコード中でPrisma ORMの機能を利用してクエリ実行するためのライブラリ.
開発コンテナー
Dockerコンテナー内で開発を行う.nodeの適当なイメージをプルしてコンテナーを作る.
Bunの公式Dockerイメージから作ったコンテナだと,prismaのコマンドが応答なしになった.どうもバグらしい.
ここはおとなしくnodeイメージを使いましょう.
セットアップ (MongoDB🍊)
MongoDBサーバーを別コンテナーで起動させた.Prismaで接続できるようにする.
今回は新しいデータベースを作成する.
公式チュートリアルの手順をとりあえずなぞる.
prismaのinitコマンドでセットアップのためのファイルを生成させる.
npx prisma init
コマンド出力の"Next steps:"は,既にあるデータベースと接続するための手順なので今回は無視する.
データベースのURLの設定
.envファイルにDATABASE_URL
が定義されるので,MongoDBのデータベースのアドレスを設定する.
DATABASE_URL="mongodb://username:password@localhost:27017/mydb"
スキーマの定義
prisma/schema.prismaファイルが生成されているので,ファイルを開いてdatasource
のprovider
を修正する.
generator client {
provider = "prisma-client-js"
}
datasource db {
- provider = "postgresql"
+ provider = "mongodb"
url = env("DATABASE_URL")
}
また,データベースのコレクション(テーブル)のスキーマとなるmodel
を定義する.
スキーマはDSLで記述する.DSLのリファレンスを参考にする.
prisma generate
コマンドを実行して,定義したスキーマをもとにしたクエリビルダーであるクライアントを生成する.
npx prisma generate
dockerでMongoDBを動かすにはレプリカセットを自前で設定しないといけない.
レプリカセットはデータベースのレプリケーションのために用意するサーバーのセットのこと.レプリケーションはリアルタイムでデータベースのレプリカ(複製)を作成し,サーバーの冗長性を持たせることでシステム障害が生じた際にフェイルオーバーする.
MongoDB Atlasを使えば簡単に設定できる.一度テストでアカウントを作ったからセットアップできるけど,今回は個人データを使うので外部クラウドサーバーにデータを渡したくない.
スキーマの書き方
-
model
のフィールドにフィールド名,型,属性の順番で記載する. - 型名はデータベースによって異なるので注意
- 型には配列やオプショナル型も設定できる
属性
@id
, @@id
主キーを示す.テーブルのフィールドで一つのみ設定できる.テーブル内で値が一意かつ非nullとなる.
@@id
は引数に複数のフィールドの配列をとって複合主キーを表す.
MongoDBではString型かつ@map("_id") @db.ObjectId
をつける必要がある.
@map
そのフィールドをデータベースでは引数で指定した名前のフィールドとして保持する.
@default
デフォルト値を設定する.値はスカラー以外にも関数で生成した値を設定できる.
MondoDBだと自動でidを生成してくれる@default(auto())
などが使える.
@unique
, @@unique
ユニークキーを設定する.テーブル内で値が一意となる.
@@unique
は複合ユニークキーを設定する.
@@index
引数にフィールドの配列を取り,そのフィールドのインデックスを作成する.
インデックスはデータベースを効率よく検索するため,フィールド値を何らかのデータ構造で表現したもの.
代表的なインデックスの種類にはB木,B+木,ビットマップ,ハッシュなどがある.
@relation
リレーションフィールドを定義する.引数にリレーションスカラーフィールドと対応するモデルのフィールドを指定する.
対応するモデルのフィールドは@id
か@unique
でなければならない.
enum
列挙体を定義できる.
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
supportingClub Club @default(VisselKobe)
}
enum Club {
VisselKobe
GambaOsaka
CerezoOsaka
KyotoSanga
FCOsaka
NaraClub
}
埋め込みデータ (Composite Type)
MongoDBを使っているときは埋め込みデータを使える.
Composite typeは1対1の関係にあるドキュメントをそれぞれ別のコレクションで管理するのではなく,ドキュメントのフィールドとしてドキュメントを埋め込むことになる.
クエリでフィールドから直接Composite typeのフィールドを呼び出すことができるのがメリット.その代わりデータサイズが大きくなったり,2つのデータが密接に関係するので独立して柔軟に管理できないのがデメリット.
model Product {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
// 埋め込みデータPhotoの配列
photos Photo[]
}
// 埋め込みデータの定義
type Photo {
width Int
height Int
url String
}
リレーション定義
1対1関係
参照元のモデルに参照先のモデルを表すリレーションフィールドと外部キーとしてふるまうリレーションスカラーフィールドを設定する.参照先のモデルには参照先のモデルのリレーションフィールドを設定する.
// 参照元モデル
model Profile {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーションフィールド
// データベースには実体のないフィールドとなる
// Profile.userIdをUser.idの外部キーとする
user User @relation(fields: [userId], references: [id])
// リレーションスカラーフィールド
// データーベースでは外部キーとなる
userId String @unique @db.ObjectId
}
// 参照先モデル
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーションフィールド
// データベースには実体のないフィールドとなる
// Userから見るとProfileとは紐づかないこともあるのでオプショナル型とする
profile Profile?
}
1対多関係
1対1関係と同様の定義を行う.違いは参照先のモデルの型がオプショナル型ではなく配列型になる.空の配列がリレーションなしの状態を示す.
// 参照元モデル
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーションフィールド
// データベースには実体のないフィールドとなる
// Post.userIdをUser.idの外部キーとする
user User @relation(fields: [userId], references: [id])
// リレーションスカラーフィールド
// データーベースでは外部キーとなる
userId String @db.ObjectId
}
// 参照先モデル
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーションフィールド
// データベースには実体のないフィールドとなる
// 複数のPostと関係があるので配列型にする
profile Post[]
}
多対多関係
多対多関係はRDBとMongoDBで扱いが異なる.
明示的な多対多関係 (Explicit many-to-many relations)
通常は多対多関係になるときは正規化を行って中間テーブルによるリレーション管理を行う.Prismaではこれを明示的な多対多関係と呼んでいる.
// 多モデルその1
model Post {
id Int @id @default(autoincrement())
categories CategoriesOnPosts[]
}
// 多モデルその2
model Category {
id Int @id @default(autoincrement())
posts CategoriesOnPosts[]
}
// 中間モデル
model CategoriesOnPosts {
post Post @relation(fields: [postId], references: [id])
postId Int
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
// 複合主キーを設定
@@id([postId, categoryId])
}
なお,この方法はMongoDBでは定義できない.
暗黙的な多対多関係 (Implicit many-to-many relations)
Prismaでは中間モデルを定義せずに多対多関係を設定することもできる.これを暗黙的な多対多関係と呼んでいる.
// 多モデルその1
model Post {
id Int @id @default(autoincrement())
categories Category[]
}
// 多モデルその2
model Category {
id Int @id @default(autoincrement())
posts Post[]
}
このときスキーマでは中間モデルは定義しないが,データベース上には中間テーブルが自動的に作成される.
MongoDBで多対多関係を表現するにはこちらを使用する.
使い分け
基本的にはPrisma ORMが自動的にデータベースを構築するので,簡潔に表現できる暗黙的な多対多関係表現の使用が推奨される.
中間テーブルに情報を追加したいときなどは明示的な多対多関係表現を使う.
同じモデルに対する複数のリレーションの設定
モデル内の異なるフィールドが同じモデルに対してそれぞれリレーションを持つ場合,リレーションに名前を付けてその関係を明確にする必要がある.
// 参照元モデル
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーション "WrittenPosts"
author User @relation(name: "WrittenPosts", fields: [authorId], references: [id])
authorId String @unique @db.ObjectId
// リレーション "PinnedPost"
pinnedBy User? @relation(name: "PinnedPost", fields: [pinnedById], references: [id])
pinnedById String? @unique @db.ObjectId
}
// 参照先モデル
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
// リレーション "WrittenPosts"
writtenPosts Post[] @relation(name: "WrittenPosts")
// リレーション "PinnedPost"
pinnedPost Post? @relation(name: "PinnedPost")
}
この記事が分かりやすい.
セットアップ (PostgreSQL)
結局今回のデータはスキーマで設定できるあたり,RDBで管理する方があってる気がするので,PostgreSQLでやってみる.
シェルで実行:
yarn add -D -E prisma
npx prisma init
.envを更新:
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/mydb?schema=public"
スキーマを更新:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
適当にモデル定義を書く.
マイグレーションを実行する.
npx prisma migrate dev --name init
このコマンドでPrismaはシャドーDBと呼ばれる一時的なDBを作成し,マイグレーションの履歴によってシャドーDB上に作成されるスキーマとschema.prismaで定義している最新のスキーマを比較し,新たなマイグレーション履歴のファイルを作成する.
また,自動でprisma generate
を実行し,クエリビルダーであるクライアントも作成する.
マイグレーションの履歴は./prisma/migrations以下に出力される.フォルダーを確認すると,マイグレーションの履歴ごとにSQLファイルが生成されているのが分かる.
ここで@prisma/clientがインストールされていないとprisma generate
は実行されないので,手動で行う.
yarn add -D -E @prisma/client
npx prisma generate
ちなみにスキーマ更新をテストで何回か変更して,最後に変更をまとめたいときは,毎回prisma migrate dev
するとファイルが作成されてしまうので,prisma db push
で確認してからにする.
クエリ
PrismaClient
を通してデータベースを操作する.
このインスタンスのプロパティとして,スキーマで定義したモデル(テーブル)にアクセスできる.
接続を終了するときはPrismaClient.$disconnect
を必ず呼び出す.
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// findMany()ですべてのレコードを取得する
const allUsers = await prisma.user.findMany();
console.log(allUsers);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
// 必ず実行する
await prisma.$disconnect();
});
初回接続時は何もデータがないので,このコードでは空の配列が標準出力される.
CRUD操作は以下のチュートリアルが詳しい.
create
はその引数data
のオブジェクト内でリレーションデータが存在するとき,そのデータのプロパティにcreate
プロパティを持つオブジェクトを渡すことで入れ子状にデータの作成が行える.
await prisma.user.create({
data: {
name: "Hoge",
email: "hoge@fuga.com",
// 入れ子で作成
posts: {
create: {
title: "Kon'nichiwa!",
},
},
// 入れ子で作成
profile: {
create: {
bio: "Nemui.",
},
},
},
});
findUnique
やfindMany
などでレコードを取得しても,リレーションデータに関しては取得できない.
このときはオプションでinclude
指定してプロパティを取得するか真偽値またはselect
オプションで指定する.
// リレーションデータのうちpostsの全カラムも取得する
const allUsers = await prisma.user.findMany({
include: { posts: true },
});
// リレーションデータのうちposts.titleのみ取得する
const allUsers2 = await prisma.user.findMany({
include: {
posts: {
select: {
title: true,
},
},
},
});
なお,リレーションデータのカラムとそれ以外のカラムでselect
を組み合せたいときはinclude
ではなくselect
のネストで指定しないといけない.
// user.nameとuser.posts.titleのみを取得する
const allUsers3 = await prisma.user.findMany({
select: {
name: true,
posts: {
select: {
title: true,
},
},
},
});
一つ前の投稿の「リレーションデータの情報を取得する」というのはいわゆる内部結合 (inner join)にあたる.
結合 (Join) の種類
SQLにおける2つのテーブルの結合には列方向の結合と行方向の結合に分けられる.
列方向の結合
内部結合 (Inner Join)
2つのテーブルの指定した列において,双方でデータが一致したレコードのみ抜き出し,列で結合して出力する.
左外部結合 (Left Outer Join)
2つのテーブルの指定した列において,左側のテーブルを基準にその列の値に位置した右のテーブルのレコードを抜き出し,列を結合する.一致した値が右のテーブルにないときは,結合後のレコードの足りないカラムはNULLとなる.
右外部結合 (Right Outer Join)
左外部結合とは逆に,右側のテーブルを基準にして同じ操作を行う.
自然結合 (Natural Join)
列の指定を行わず,2つのテーブルで名前が一致する列を基準にして結合する.内部結合,左右外部結合かはクエリの指定による.
行方向の結合
統合結合 (Union)
2つのテーブルが同じカラムで構成されていることを条件に,双方のレコードの重複を取り除いて結合する.
Union All
Unionの重複を許すときはALLをつける.
どうもPrismaは内部結合のみサポートしている?
PrismaClient
では全ての操作に対応しているわけではないので,細かい操作を行いたいなら$queryRawTyped
で生SQL文を書くか,ORMに頼らないのがいいのかも...