最近バックエンドをTypeScript+Express+Prismaを使って書いているのだが、Prismaの仕様で気になるものに遭遇したので回避策含めて記述する。
PrismaのfindManyとActiveRecordのwhereとの比較
RubyのActiveRecordを使って以下のようなクエリを書くことがあると思う。
例)登録ユーザーを名前で検索するクエリ
name = prams[:name] # ←パラメーターを指定していない場合 nil
User.where(user_name: name)
上記のクエリはパラメーターが指定されていない場合は 「user_nameがnull(nil)のデータをUsersテーブルから検索する」 という結果となる。
同じことをTypeScript + Prismaで書いてみる。
const userName = params.name // undefined または null
const result = prisma.users.findMany({
where: {
userName
}
});
実際にこのクエリを実行すると条件によって処理が分かれる。
prams.nameがnullの場合、ActiveRecordと同じく 「user_nameがnull(nil)のデータをUsersテーブルから検索する」 となる。
しかし、params.nameがundefinedの場合、nameを指定せずUsersテーブルから検索 という結果となる。
つまり、nameがundefinedとなるような実装をしてしまうと、全データが返ってきてしまうという鬼畜仕様になっている。
一般的なデータ参照ならともかく、たとえば毎回ユーザーの所属識別用のIDや、論理削除フラグなど何かしらの条件をWhere句に入れている場合、ユーザーに見えてほしくないデータも返ってきてしまう。
Webサービスを実装するうえではコードレビューとかで気をつければよいしある程度ユースケースが決まっているのでWhere句の指定を忘れるということはほぼないはずだが、ユーザー公開用のAPIを作成するとか、上記の例のように任意項目での自由検索を作るという場合に問題となりやすい。(入力されるであろうクエリの条件や入力値を事前にパターン網羅することが難しいため)
回避策
Ruby(ActiveRecord)慣れしたエンジニアにとって、どう考えてもいつか事故る設計なのでこの仕様の回避策を考えたいところである。
さすがにこの仕様についてはすでにPrismaのGithub Issueで取り上げられている。
Prisma公式ドキュメントにもちゃんと記載がある。
しかし、問題は会議室ではなく現場で起きるのが世の常である。
現場(開発)での回避策については上記のGithub Issueにコメントのある仕組みを使うとよい。
import { PrismaClient } from '.prisma/client';
const prisma = new PrismaClient();
prisma.$use(async (params, next) => {
if (hasUndefinedValue(params.args?.where))
throw new Error(
`Invalid where: ${JSON.stringify(params.args.where)}`
);
return await next(params);
});
export default prisma;
function hasUndefinedValue<T>(obj: T): boolean {
if (typeof obj !== 'object' || obj === null) return false;
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const value = obj[key];
if (value === undefined) return true;
if (typeof value === 'object' && !Array.isArray(value))
if (hasUndefinedValue(value)) return true;
}
return false;
}
しかし、Prismaの最近のバージョンだと $use
は非推奨メソッドとなっているためこれを $extends
で書き換える必要がある。
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient().$extends({
query: {
async $allOperations({ model, operation, args, query }){
if (hasUndefinedValue(args?.where)){
throw new Error(
`Invalid where: ${JSON.stringify(args.where)}`
);
}
return await query(args);
}
}
}) as PrismaClient;
// 以下は参考の実装と同様hasUndefinedValueを定義する
// function hasUndefinedValue<T>(obj: T): xxx
$allOperations
はすべてのPrismaのクエリに対して実行されるので、今回のように findMany
やfindFirst
だけでよいという場合はさらにif文で分岐させる。
async $allOperations({ model, operation, args, query }){
if (operation === 'findFirst' || operation === 'findMany') {
if (hasUndefinedValue(args?.where)){
xxx
}
}
return await query(args);
}
...
as PrismaClientを使っているのは、別のクラスやライブラリがPrismaのこのクライアントを呼び出すときに型エラーとなるのを防ぐためである。
より良い書き方があれば是非ご教示いただきたい。
他の回避策
ActiveRecordでもnullの場合はそもそも検索条件に入れたくないパターンもあるだろう。
そういうときは以下のように記述できる。
# 検索条件として考えられるparamsから必要なキーのみを抽出
search_conditions = params.slice(:user_name, :company_name, :company_role)
# nilの値を持つキーを削除
filtered_conditions = search_conditions.compact
# 残った条件で検索
@users = User.where(filtered_conditions)
同じことをPrisma+TypeScriptで書ければ、特定のユースケースであればわざわざ$extends
を利用せずとも回避可能かもしれない。