Open35

Prismaメモ

RerrahRerrah

ORM (Object-Relational Mapping) ツールのPrismaを使う.

https://www.prisma.io

RerrahRerrah

mysqlモジュールやmongodbモジュールを使ってもデータベースの操作はできるが,クエリを文字列として直接書かないと行けなかったりする.

Mongoを使うならMongooseを使ったら良さそうだけど,今回はデータベースをMySQLに以降してみたくなったのでパスした.

https://mongoosejs.com

最近はDrizzleと比較されるらしい.こちらはスキーマをTypeScriptで定義できる.クエリをSQLに近い形で書くので,Prismaでは難しい痒いところに手が届くみたい.SQLにもっと詳しくなったらこっちを使ってみるのもアリかも.

https://orm.drizzle.team

RerrahRerrah

ORM

データベースのデータをクライアント側でオブジェクトのように扱うための技法.素のSQL文を叩いてデータベースのCRUD操作をするのに比べて,オブジェクトのメソッドを通して操作するのでオブジェクト指向プログラミングのコードと親和性が高い.

PrismaはTypeScriptネイティブのORMツールで,型安全でありながらデータベースを操作できる.

RerrahRerrah

Prismaの構成

Prismaは以下3つのツールで構成される.

  • Prisma Client: SQL文を自動で作成してデータベースとのやり取りを行うクエリビルダー
  • Prisma Migrate: スキーマの変更をデータベースに反映(マイグレーション)を行うツール
  • Prisma Studio: データベースの中身を確認・編集できるGUIアプリ

特に上2つはよく使いそう.

RerrahRerrah

似た名前のパッケージがチュートリアルに2つ登場してきて混乱した.それぞれの違いは以下の通り.

prisma

Prisma ORMの機能を提供するアプリ.npx prismaで実行することにより,クライアントの作成やマイグレーション,クエリ実行を行う.

@prisma/client

TSのコード中でPrisma ORMの機能を利用してクエリ実行するためのライブラリ.

RerrahRerrah

開発コンテナー

Dockerコンテナー内で開発を行う.nodeの適当なイメージをプルしてコンテナーを作る.

Bunの公式Dockerイメージから作ったコンテナだと,prismaのコマンドが応答なしになった.どうもバグらしい.
ここはおとなしくnodeイメージを使いましょう.

https://tech.natsuneko.blog/entry/2024/07/07/220533

RerrahRerrah

prismaをインストールする.

yarn add -D -E prisma
RerrahRerrah

@prisma/clientをインストールする.

yarn add -D -E @prisma/client
RerrahRerrah

セットアップ (MongoDB🍊)

MongoDBサーバーを別コンテナーで起動させた.Prismaで接続できるようにする.
今回は新しいデータベースを作成する.

公式チュートリアルの手順をとりあえずなぞる.

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/mongodb-typescript-mongodb

RerrahRerrah

prismaのinitコマンドでセットアップのためのファイルを生成させる.

npx prisma init

コマンド出力の"Next steps:"は,既にあるデータベースと接続するための手順なので今回は無視する.

RerrahRerrah

データベースのURLの設定

.envファイルにDATABASE_URLが定義されるので,MongoDBのデータベースのアドレスを設定する.

.env
DATABASE_URL="mongodb://username:password@localhost:27017/mydb"
RerrahRerrah

スキーマの定義

prisma/schema.prismaファイルが生成されているので,ファイルを開いてdatasourceproviderを修正する.

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

また,データベースのコレクション(テーブル)のスキーマとなるmodelを定義する.
スキーマはDSLで記述する.DSLのリファレンスを参考にする.

RerrahRerrah

prisma generateコマンドを実行して,定義したスキーマをもとにしたクエリビルダーであるクライアントを生成する.

npx prisma generate
RerrahRerrah

dockerでMongoDBを動かすにはレプリカセットを自前で設定しないといけない.

レプリカセットはデータベースのレプリケーションのために用意するサーバーのセットのこと.レプリケーションはリアルタイムでデータベースのレプリカ(複製)を作成し,サーバーの冗長性を持たせることでシステム障害が生じた際にフェイルオーバーする.

https://www.mongodb.com/ja-jp/docs/manual/replication/

MongoDB Atlasを使えば簡単に設定できる.一度テストでアカウントを作ったからセットアップできるけど,今回は個人データを使うので外部クラウドサーバーにデータを渡したくない.

RerrahRerrah

スキーマの書き方

https://www.prisma.io/docs/orm/reference/prisma-schema-reference#model

  • modelのフィールドにフィールド名,型,属性の順番で記載する.
  • 型名はデータベースによって異なるので注意
  • 型には配列やオプショナル型も設定できる
RerrahRerrah

属性

@id, @@id

主キーを示す.テーブルのフィールドで一つのみ設定できる.テーブル内で値が一意かつ非nullとなる.
@@idは引数に複数のフィールドの配列をとって複合主キーを表す.

MongoDBではString型かつ@map("_id") @db.ObjectIdをつける必要がある.

@map

そのフィールドをデータベースでは引数で指定した名前のフィールドとして保持する.

@default

デフォルト値を設定する.値はスカラー以外にも関数で生成した値を設定できる.

MondoDBだと自動でidを生成してくれる@default(auto())などが使える.

@unique, @@unique

ユニークキーを設定する.テーブル内で値が一意となる.
@@uniqueは複合ユニークキーを設定する.

RerrahRerrah

@@index

引数にフィールドの配列を取り,そのフィールドのインデックスを作成する.

インデックスはデータベースを効率よく検索するため,フィールド値を何らかのデータ構造で表現したもの.
代表的なインデックスの種類にはB木,B+木,ビットマップ,ハッシュなどがある.

https://zenn.dev/suzuki_hoge/books/2022-12-database-index-9520da88d02c4f

RerrahRerrah

@relation

リレーションフィールドを定義する.引数にリレーションスカラーフィールドと対応するモデルのフィールドを指定する.

対応するモデルのフィールドは@id@uniqueでなければならない.

RerrahRerrah

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
}
RerrahRerrah

埋め込みデータ (Composite Type)

MongoDBを使っているときは埋め込みデータを使える.

Composite typeは1対1の関係にあるドキュメントをそれぞれ別のコレクションで管理するのではなく,ドキュメントのフィールドとしてドキュメントを埋め込むことになる.

https://www.mongodb.com/ja-jp/docs/rapid/data-modeling/concepts/embedding-vs-references/#std-label-embedding-vs-references

クエリでフィールドから直接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
}

https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-composite-types

RerrahRerrah

リレーション定義

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?
}

https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/one-to-one-relations

RerrahRerrah

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[]
}

https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/one-to-many-relations

RerrahRerrah

多対多関係

多対多関係は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が自動的にデータベースを構築するので,簡潔に表現できる暗黙的な多対多関係表現の使用が推奨される.
中間テーブルに情報を追加したいときなどは明示的な多対多関係表現を使う.

https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/many-to-many-relations

RerrahRerrah

同じモデルに対する複数のリレーションの設定

モデル内の異なるフィールドが同じモデルに対してそれぞれリレーションを持つ場合,リレーションに名前を付けてその関係を明確にする必要がある.

// 参照元モデル
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")
}

https://www.prisma.io/docs/orm/prisma-schema/data-model/relations#disambiguating-relations

RerrahRerrah

セットアップ (PostgreSQL)

結局今回のデータはスキーマで設定できるあたり,RDBで管理する方があってる気がするので,PostgreSQLでやってみる.

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgresql

RerrahRerrah

シェルで実行:

yarn add -D -E prisma
npx prisma init

.envを更新:

.env
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/mydb?schema=public"

スキーマを更新:

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

適当にモデル定義を書く.

RerrahRerrah

マイグレーションを実行する.

npx prisma migrate dev --name init

このコマンドでPrismaはシャドーDBと呼ばれる一時的なDBを作成し,マイグレーションの履歴によってシャドーDB上に作成されるスキーマとschema.prismaで定義している最新のスキーマを比較し,新たなマイグレーション履歴のファイルを作成する.
また,自動でprisma generateを実行し,クエリビルダーであるクライアントも作成する.

マイグレーションの履歴は./prisma/migrations以下に出力される.フォルダーを確認すると,マイグレーションの履歴ごとにSQLファイルが生成されているのが分かる.

RerrahRerrah

ここで@prisma/clientがインストールされていないとprisma generateは実行されないので,手動で行う.

yarn add -D -E @prisma/client
npx prisma generate
RerrahRerrah

クエリ

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();
  });

初回接続時は何もデータがないので,このコードでは空の配列が標準出力される.

RerrahRerrah

createはその引数dataのオブジェクト内でリレーションデータが存在するとき,そのデータのプロパティにcreateプロパティを持つオブジェクトを渡すことで入れ子状にデータの作成が行える.

  await prisma.user.create({
    data: {
      name: "Hoge",
      email: "hoge@fuga.com",
      // 入れ子で作成
      posts: {
        create: {
          title: "Kon'nichiwa!",
        },
      },
      // 入れ子で作成
      profile: {
        create: {
          bio: "Nemui.",
        },
      },
    },
  });
RerrahRerrah

findUniquefindManyなどでレコードを取得しても,リレーションデータに関しては取得できない.
このときはオプションで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,
        },
      },
    },
  });

https://www.prisma.io/docs/orm/prisma-client/queries/select-fields
https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries

RerrahRerrah

一つ前の投稿の「リレーションデータの情報を取得する」というのはいわゆる内部結合 (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に頼らないのがいいのかも...