📐

PlanetScaleとPrismaの組み合わせ方

2023/06/20に公開

無料枠のあるデータベースとしてPlanetScaleを使うことになったので、Prismaとの組み合わせ方を調べてみました。

PlanetScaleとは

PlanetScaleは、MySQL互換のサーバーレスデータベースです。PlanetScaleの特徴的な機能には、ダウンタイムなしのマイグレーションや、ブランチ機能があります。

https://planetscale.com/

PlanetScaleでPrismaを使う場合に考慮すること

PlanetScaleでPrismaを使う場合に考慮することは、主に次の2点です。

  • PlanetScaleは外部キー制約をサポートしていないので、Prismaが外部キーを使わないように設定する
  • PlanetScaleのブランチ機能と、Prismaのマイグレーション機能の組み合わせ方

Prismaが外部キーを使わないように設定する

Prismaで外部キー制約をエミュレートする

Prismaでは、モデル間にリレーションを設定すると、整合性を保証するために外部キー制約が付与されます。PlanetScaleは外部キー制約をサポートしていないため、そのままマイグレーションを実行するとエラーになってしまいます。

そのため、PlanetScaleでPrismaを使う場合は、外部キー制約を作成せずにエミュレートするモード(relationMode = "prisma")を使います。

schema.prisma
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

外部キー制約をエミュレートするというのは、参照整合性を保証してくれる・参照アクションを実行してくれるということです。

Prisma schemaでリレーションを設定した場合のデフォルトの挙動は、ON DELETE RESTRICTON UPDATE CASCADEです。つまり、外部キーで参照されているレコードを削除することはできず、外部キーの値は参照先の変更に追従します。

// UserはPostをもつ
const user = await prisma.user.create({
  data: {
    email: "test@example.com",
    posts: {
      createMany: {
        data: [
          { title: "PlanetScaleのブランチ機能について" },
          { title: "Prisma Migrateについて" },
        ],
      },
    },
  },
});

// UserはPostに参照されているので削除できない
try {
  await prisma.user.delete({
    where: { id: user.id },
  });
} catch (e) {
  // The change you are trying to make would violate the required relation 'PostToUser' between the `Post` and `User` models.
  console.error(e);
}

利用する機会はあまりないかもしれませんが、削除時の参照アクションをCASCADEにして同時に削除することもできます。

外部キー制約の代わりにインデックスを付与してクエリを最適化する

MySQLでは、外部キー制約を設定すると外部キーにインデックスが付与されます。つまり、外部キー制約を設定しない場合は、代わりにインデックスを追加する必要があります。

なぜ外部キーにインデックスが必要かというと、インデックスがないとリレーションの取得がテーブルスキャンになってしまうからです。

次のコードで考えてみましょう。

const userWithPosts = await prisma.user.findUniqueOrThrow({
  where: { id: 1 },
  include: { posts: true },
})

このコードを実行した場合に発行されるSQLは、select ... from User where id = ?select ... from Post where authorId = ?の2つです。後者のSQLは、Post.authorIdにインデックスがなければテーブルスキャンになってしまいます。

幸い、外部キーに対してインデックスを設定していないとエディタ上でワーニングが出るため、設定し忘れることはないはずです。

この場合は、次のようにauthorIdにインデックスを追加すると解決できます。

中間テーブルの場合

多対多を表現するために中間テーブルを使っている場合は、複合ユニークインデックスを設定することがあると思います。

複合インデックスは最初のキーに対するインデックスの代わりになるので、残りのキーにインデックスを追加する必要があります。

model Following {
  id         Int  @id @default(autoincrement())
  followerId Int
  follower   User @relation(fields: [followerId], references: [id], name: "follower")
  followeeId Int
  followee   User @relation(fields: [followeeId], references: [id], name: "followee")

  @@unique([followerId, followeeId])
  // 残りのキーにインデックスを追加する
  @@unique([followeeId])
}
主キーが外部キーの場合

サブタイプをテーブル継承で表現している箇所で、主キーを外部キーにしているところがありました。

主キーにはインデックスが付与されますが、PrismaのLanguage serverが認識してくれなかったため、余分にインデックスを追加しました。

model User {
  id      Int      @id @default(autoincrement())
  email   String   @unique
  student Student?
  teacher Teacher?
}

model Student {
  userId         Int    @id
  user           User   @relation(fields: [userId], references: [id])
  studentSpecifc String

  // インデックスを追加
  @@index([userId])
}

model Teacher {
  userId          Int    @id
  user            User   @relation(fields: [userId], references: [id])
  teacherSpecific String

  // インデックスを追加
  @@index([userId])
}

PlanetScaleのブランチ機能について

Branching — PlanetScale Documentation

PlanetScaleとPrisma Migrateの組み合わせ方の前に、PlanetScaleのブランチ機能について説明します。

PlanetScaleのブランチは、独立したデータベースのインスタンスです。つまり、あるブランチのスキーマやデータを変更しても、他のブランチに影響を与えることはありません。

PlanetScaleのブランチには、開発用(Development branch)と本番用(Production branch)の2種類あります。本番用のブランチは、高トラフィックを想定してレプリカが用意されていることや、Safe migrationsを有効化できるという違いがあります。

Safe migrations — PlanetScale Documentation

Safe migrationsは、スキーマを直接変更することを禁止し、ダウンタイムなしのマイグレーションを行うための機能です。Safe migratiosを有効化した場合は、次のような手順で本番用のブランチのスキーマの変更を行う必要があります。

  1. 本番用のブランチから開発用のブランチを作成する
  2. 開発用のブランチのスキーマに変更を加える
  3. 開発用のブランチから本番用のブランチにデプロイリクエストを作成する
  4. デプロイリクエストをデプロイして、本番用のブランチにスキーマの変更を反映する

スキーマの変更によっては、デプロイリクエストを反映できない場合もあります。例えば本番用のブランチでレコードがあるテーブルに、開発用のブランチでNOT NULLなカラムを追加した場合などです。

また、ALTER TABLEでカラムをリネームすると、DROP COLUMN + ADD COLUMNだと認識されるため(そのカラムがNOT NULLかつ、レコードが存在すれば)デプロイできません。

PlanetScaleのブランチ機能とPrisma Migrateの組み合わせ方

PlanetScaleとPrisma Migrateを組み合わせる方法は、PlanetScaleのブランチ機能を使うかどうかで2種類に分けられます。

PlanetScaleやPrismaのドキュメントでは、ブランチ機能を使う方法が推奨されています。ブランチ機能を使うとダウンタイムなしのマイグレーションが強制される一方で、スキーマの変更に手間がかかるデメリットがあります。

まずはブランチ機能を使う場合のワークフローを説明します。

PlanetScaleのブランチ機能を使う場合

PlanetScaleで、ブランチ機能を使う場合のスキーマ変更のワークフローは次の通りでした。

  1. 本番用のブランチから開発用のブランチを作成する
  2. 開発用のブランチのスキーマに変更を加える
  3. 開発用のブランチから本番用のブランチにデプロイリクエストを作成する
  4. デプロイリクエストをデプロイして、本番用のブランチにスキーマの変更を反映する

Prismaと組み合わせる場合は、2番目の手順をprisma db pushで行います。prisma db pushは、Prismaスキーマに合うようにデータベースのスキーマを変更するコマンドです。

このワークフローを実際に試してみたい場合は、PlanetScaleのドキュメントのハンズオンが分かりやすいと思います。

Prisma with PlanetScale quickstart — PlanetScale Documentation

なぜprisma migrateではなくprisma db pushを使うかというと、prisma migrateがPlanetScaleのブランチ機能とフィットしないからです。具体的には、次の2点の問題があります。

  • ブランチを作成したときに、スキーマはコピーされるがデータはコピーされないため、Prismaのマイグレーションの履歴(_prisma_migrations)と実際のスキーマに齟齬が生まれてしまう。
    • prisma migrate resetを実行するか、履歴テーブルのデータをコピーする必要がある。
  • prisma migrateで作成したマイグレーションファイルと、ブランチをマージしたときに行うスキーマの変更が一致しない可能性がある。
    • 特にprisma migrate --create-onlyでマイグレーションを編集すると問題になりそう。

PlanetScaleのブランチ機能を使わない場合

PlanetScaleのブランチ機能を使わない場合は、次のようなワークフローになります。一般的なデータベースを使う場合と同じ方法かと思います。

  • (mainブランチのSafe migrationsはオフにする)
  • 開発時はローカルのデータベースを対象にprisma migrateを行う
  • mainブランチにprisma migrate deployを実行してスキーマの変更を反映する

prisma db pushを使う方法がPlanetScaleに寄せたワークフローだとすると、この方法はPrismaに寄せたワークフローだといえます。

ブランチ機能を使った場合の工程は、ダウンタイムなしのマイグレーションを行うためには必要ですが、開発中でスキーマの変更が頻繁に行われる場合は手間に感じることがあります。特にカラムのリネームは複数回デプロイする必要があって大変です。

Handling table and column renames — PlanetScale Documentation

ブランチを使わない方法で問題ないのかは気になるところですが、Safe migrationsのドキュメント[1]には

Safe migrations is an optional but highly recommended feature for production branches in PlanetScale. With safe migrations enabled on a branch, you’ll gain a number of additional protections on a branch to enable.(太字は筆者)

とあるため、開発中であれば「なくはない」方法だといえそうです。もちろん、(コストはかかりますが)途中からブランチを使った開発・運用に移行することもできます。

まだ分かっていないこと

  • Vercelのプレビュー環境から、PlanetScaleのブランチに接続する方法。PlanetScaleのパスワードがブランチごとなので、CIでブランチかパスワードを毎回作成する必要があるのかなと思いました。
  • ひとまずブランチ機能を使わないワークフローで開発していますが、実際に運用するとなるとブランチ機能を使った方が良さそうです。その場合はDBを使ったテストをどうするかが課題になりそうです。
  • ブランチを作成するときにデータもコピーする機能[2]がありますが、まだ試せていません。

感想

ブランチ機能は慣れるまでに少し時間がかかりそうですが、良い設計と開発(= ダウンタイムなしのマイグレーション)が強制されるいい機能だと感じました。

カラムをリネームする場合のワークフローはexpand and contract pattern[3]と呼ばれているようなので、こちらも機会があれば調べてみようと思います。

参考

脚注
  1. https://planetscale.com/docs/concepts/safe-migrations ↩︎

  2. https://planetscale.com/docs/concepts/data-branching ↩︎

  3. https://github.com/planetscale/discussion/discussions/175#discussioncomment-5604976 ↩︎

GitHubで編集を提案

Discussion