2022-08-30
現在仕事で、ORM として Prisma を用いて開発を行なっています。型安全に DB との通信を取り回せる観点ではとても助かっている一方で、ハマりどころや「生 SQL を書きてえ!」という衝動に駆られることもあったりするので、今見えている難しいポイント、ちょっとしたトラップをまとめられたらと思います。
ORM を何も用いずに RDBMS を用いる場合、DB 設計をする際に ER 図などを作成してテーブル間のリレーションを考えながらモデリングを行なっていけば OK です。それぞれのテーブルを結合してクエリしたい場合には、ER 図を見ながらテーブル同士を JOIN してクエリを作成していけば OK です。最も王道な方法であるため、慣れている方も多いかと思います。
しかし、Prisma を介して RDBMS を利用する場合には、テーブル間を繋ぐためには Relation をテーブル間に作成する必要があります。Relation を持たせたテーブル同士でしか Prisma のクエリを用いて JOIN することができないためです。Relation を持っていないテーブル同士を結合したい場合には、raw_sql の機能を使って String でクエリを書くこともできるのですが、こうすると型安全であるという Prisma の恩恵を受けることができずせっかく Prisma を導入した意義が半減してしまいます。
この Relation の設計が慣れないと少し難しく、Relation で親子関係を作成した際に required のフィールドを key として指定した場合には親がない状態で子を作成できないことが強制されるなど、RDBMS を素の状態で利用する場合には自分で制御可能な部分が勝手に指定されてしまったりします。もちろん、ランタイムエラーを最小限に抑えるための工夫ではあるのですが、良くも悪くも制約がついた状態で開発を進めることになります。ただ JOIN したいだけなのに、そのためだけに Relation を作成する必要がある ⇒DB のマイグレーションも必要で面倒臭い。。。という状況に何回も直面しました。また、アプリケーション開発の初期段階などで頻繁にスキーマが変更になる場合には、Prisma が提供する制約をかいくぐるために migration ファイルを手で書き換えるハックが必要になったりと、どうしても余計な手間&リスクを取る必要が発生してしまったりしました。
PostgreSQL や MySQL では UPSERT コマンドはないものの、 ON CONFLICT
やON DUPLICATE KEY
などで UPSERT に相当する操作を行うことができます。また、ORM の場合 UPSERT コマンドを持っていることも多いでしょう。
Prisma も御多分に洩れず UPSERT が用意されており、ドキュメントにも「既存のレコードを更新もしくは新規にレコードを作成します」とまさに UPSERT の説明があります。
upsert
updates an existing or creates a new database record.
しかし、Prisma の内部的には RDBMS における UPSERT に相当するクエリを発行しているわけではなさそうで、レースコンディションが発生してしまいます。(参考 Issue: https://github.com/prisma/prisma/issues/3242)
例として以下の schema 定義と upsert のコードを考えてみます。
// schema.prisma
model user{
id Int @id @default(autoincrement())
user_id String @unique
name String
}
const client = new PrismaClient();
async function upsertUser(userId: string, name: string): Promise<void> {
try {
await client.user.upsert({
where: {
user_id: userId,
},
create: {
user_id: userId,
name: name,
},
udpate: {
name: name,
},
});
} catch (err) {
console.error(err);
}
}
await Promise.all([
upsertUser("user-1", "foga"),
upsertUser("user-2", "hoge"),
upsertUser("user-2", "hoge"),
upsertUser("user-1", "foga"),
]);
これを複数回実行すると、エラーになることがあるはずです。
{"message":"ERROR: \"prisma error occurred, prisma error code: P2002\"","severity":"ERROR","stack":"Error: \nInvalid `prisma.user.upsert()` invocation:\n\n\n Unique constraint failed on the field: `user_id`\n at Object.request (/Users/my_dir/node_modules/@prisma/client/runtime/index.js:39809:15)\n at DatasourceClient._request (/Users/my_dir/node_modules/@prisma/client/runtime/index.js:40637:18)\n at UserDatasource.upsertUser (/Users/my_dir/webpack:/hogehoge)","timestamp":"2022-08-25T00:20:52.201Z"}
{"message":"ERROR: \"prisma error occurred, prisma error code: P2002\"","severity":"ERROR","stack":"Error: \nInvalid `prisma.user.upsert()` invocation:\n\n\n Unique constraint failed on the field: `user_id`\n at Object.request (/Users/my_dir/node_modules/@prisma/client/runtime/index.js:39809:15)\n at DatasourceClient._request (/Users/my_dir/node_modules/@prisma/client/runtime/index.js:40637:18)\n at UserDatasource.upsertUser (/Users/my_dir/webpack:/hogehoge)","timestamp":"2022-08-25T00:20:52.219Z"}
2 年前に issue が開かれていますが、まだ解決していないのでしばらく開発には時間がかかりそうです。他の issue では、マイルストーン 2.19 に乗っていたことがあるとの記載もあったので、何らかの事情で先送りにされているのでしょうか。
とにかくまだ Prisma 自体では解決していない問題なので、実装側で吸収する必要があります。今のところエラーハンドリングを行い、エラーをキャッチした場合には再実行する以外方法がなさそうです。
async function upsertUser(
userId: string,
name: string,
retryCounter = 3
): Promise<void> {
try {
await client.user.upsert({
where: {
user_id: userId,
},
create: {
user_id: userId,
name: name,
},
udpate: {
name: name,
},
});
} catch (err) {
if (retryCounter > 0) {
retryCounter--;
upsertUser(userId, name, retryCounter);
}
console.error(err);
}
}
これは migration を複数回に分けて実行すれば解決する問題なので、そこまで困るものではないですが、数回 migration をリトライしてしまったので残しておきます。(参考 Issue: https://github.com/prisma/prisma/issues/8424)
以下のように、新しく追加した enum の値を default value として設定しようとすると、新しい enum の値は使う前にコミットされている必要がある。ということでエラーになります。
// Before
enum place {
INDOOR
}
model activity {
...
place place @default(INDOOR)
...
}
// After
enum place {
INDOOR
OUTDOOR
}
model activity {
...
place place @default(OUTDOOR)
...
}
Error: P3018
A migration failed to apply. New migrations cannot be applied before the error is recovered from. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve
Migration name: 20220825080309_add_new_place
Database error code: 55P04
Database error:
ERROR: unsafe use of new value "OUTDOOR" of enum type "place"
HINT: New enum values must be committed before they can be used.
DbError { severity: "ERROR", parsed_severity: Some(Error), code: SqlState("55P04"), message: "unsafe use of new value \"OUTDOOR\" of enum type \"place\"", detail: None, hint: Some("New enum values must be committed before they can be used."), position: None, where_: None, schema: None, table: None, column: None, datatype: None, constraint: None, file: Some("enum.c"), line: Some(103), routine: Some("check_safe_enum_use") }
解決するためには、まずは enum だけを追加した状態で migration を一度行い、次に前の工程で追加した enum を default value として設定した状態で migration を行えば OK です。
// Before
enum place {
INDOOR
}
model activity {
...
place place @default(INDOOR)
...
}
// After(1回目のmigrationを行う)
enum place {
INDOOR
OUTDOOR
}
model activity {
...
place place @default(INDOOR)
...
}
// After(2回目のmigrationを行う)
enum place {
INDOOR
OUTDOOR
}
model activity {
...
place place @default(OUTDOOR)
...
}
特に手間ではないのですが、migration ファイルが2つ作られることになるので、あまりにも繰り返すと migration ファイルが大量に生成されてカオスになってきます。
Prisma を利用する際には、 const client = new PrismaClient()
のように PrismaClient をインスタンス化してやる必要があります。ただ、クエリを発行する度に至る所でインスタンス化を行なっていると、警告が出るようになってしまいます。(参考 Issue: https://github.com/prisma/prisma/discussions/4399)
warn(prisma-client) Already 10 Prisma Clients are actively running.
警告が出ているだけならまだ問題にはなっていませんが、このままだと複数の PrismaClient が各自で DB との connection pool を張っている状態になってしまうので、度が過ぎると DB がメモリ不足となって落ちてしまいます。この問題を回避するために、まずは Issue 内でも書かれているように一度 PrismaClient のインスタンス化をおこなったら、アプリケーションの各所でそれを引き回してやるようにする必要があります。
export class Client extends PrismaClient {
private static _instance: PrismaClient;
private constructor() {
super();
}
static getInstance() {
if (!Client._instance) {
QueryService._instance = new QueryService();
}
return QueryService._instance;
}
}
今まで const client = new PrismaClient()
していた箇所では、 const client = Client.getInstance()
としてやるようにすれば、毎回新たにインスタンス化を行うことなく、一つのインスタンスを引き回すことができるようになります。
ちなみに、コネクションプールの管理については、アプリケーションや使っているマシンのスペックによっても最適な値が変わってくるので、https://www.prisma.io/docs/guides/performance-and-optimization/connection-management を参考にして頂くと良いかと思います。
以下のようなスキーマ、レコードがあった場合、例えば AAA
という文字列で検索をかけて、 AAA-1000
AAA-2000
だけをヒットさせたかったのですが、それは Prisma ではできません。
// schema.prisma
model product {
parts String[]
}
// product.parts sample record
parts: ['AAA-1000', 'AAA-2000', 'ABC-1000', 'DEF-1000']
実現したい場合、戻り値に対して、filter 関数をかけてクエリ外でフィルターするしか今のところ方法はなさそうです。
WIP
WIP