PrismaのスキーマをからPothosのGraphQLオペレーションを全自動で生成する
GraphQL Nexus と Pothos GraphQL
GraphQLでAPIを構築する場合、GraphQL Nexusを使うことが多いと思います。しかし、Nexusの開発が停滞しているようで、別のツールに切り替える必要があります。Pothosはその候補の一つです。
Nexusではnexus-prismaというパッケージを使ってTypeScriptとPrismaを統合できました。PothosでもPrismaとの連携は可能ですが、nexus-prismaほど便利ではありません。Pothosのドキュメントにはいくつかのサンプルがありますが、プラグインとして簡単に導入できるものではありません。
prisma-generator-pothos-codegenというパッケージもありますが、私が試したときはprisma@5に対応していませんでした。その後、対応がされたようですが、すでにPothosを使ってコードを書いていたので、そのまま続けることにしました。
そして、よく使う操作を自動化するプラグインを作成しました。
作成したもの
開発したPothosのジェネレータを紹介します。Pothosはこれまで使ったことがなかったので、開発は完全にゼロからでした。
npmパッケージ
pothos-prisma-generatorサンプルアプリ
Next.js
NestJS
Render.comにデプロイしました
https://nest-pothos.onrender.com
ブログシステム
Nexusで作っていたブログシステムをPothos+今回作ったジェネレータに置き換えました
基本的な使い方
PothosのBuilder作成
まず、pluginsにPothosPrismaGeneratorを設定します。このプラグインは、PrismaスキーマからGraphQLスキーマを生成するために必要です。また、plugin-prismaとplugin-prisma-utilsも併用します。これらのプラグインは、PrismaクライアントとGraphQLリゾルバを連携させるために必要です。
Pothosの設定はこれだけで完了です。次に、Apollo ServerなどのGraphQLサーバーにBuilderで作ったスキーマを投入します。以下のコードは、TypeScriptで書かれた例です。
1import SchemaBuilder from "@pothos/core";2import PrismaPlugin from "@pothos/plugin-prisma";3import PrismaUtils from "@pothos/plugin-prisma-utils";4import PothosPrismaGenerator from "pothos-prisma-generator";5import { Context, prisma } from "./context";67/**8 * Create a new schema builder instance9 */10export const builder = new SchemaBuilder<{11 Context: Context;12}>({13 plugins: [14 PrismaPlugin,15 PrismaUtils,16 PothosPrismaGenerator,17 ],18 prisma: {19 client: prisma,20 },21});
Prismaのスキーマ作成
ここでは、サンプル用に適当なPrismaのスキーマを用意してみましょう。以下のコードは、ユーザー、投稿、カテゴリーという3つのモデルと、ユーザーの役割を表す列挙型を定義しています。各モデルには、idや名前などのフィールドや、他のモデルとの関係を示すリレーションがあります。
このスキーマを保存したら、prisma generateコマンドで@prisma/clientを生成します。これで、GraphQLのオペレーションが使えるようになりました。プラグインはこのときprismaが作成するDMMFを参照します。
1generator client {2 provider = "prisma-client-js"3}45datasource db {6 provider = "postgresql"7 url = env("DATABASE_URL")8}910model User {11 id String @id @default(uuid())12 email String @unique13 name String @default("User")14 posts Post[]15 roles Role[] @default([USER])16 createdAt DateTime @default(now())17 updatedAt DateTime @updatedAt18}1920model Post {21 id String @id @default(uuid())22 published Boolean @default(false)23 title String @default("New Post")24 content String @default("")25 author User? @relation(fields: [authorId], references: [id])26 authorId String?27 categories Category[]28 createdAt DateTime @default(now())29 updatedAt DateTime @updatedAt30 publishedAt DateTime @default(now())31}3233model Category {34 id String @id @default(uuid())35 name String36 posts Post[]37 createdAt DateTime @default(now())38 updatedAt DateTime @updatedAt39}4041enum Role {42 ADMIN43 USER44}
出力結果
以下のようにPrismaのモデルに対応したQueryとMutationが作成されます。プラグインを追加するだけで、モデルに対応したオペレーションの全機能が動きます。
ラクチン!
作成可能なクエリの例
クエリの引数としてfilter,orderBy,limit,offsetの指定が可能です。また、リレーション先に対しても同様の指定が可能です。
これらの機能を手動で作った場合、リレーション先を含めるとかなり複雑になります。
以下、クエリのサンプルです。リレーションのリレーションまでは書いていませんが、どこまでもたどれます。リレーションに関しては件数取得機能もフィールド上に追加されるので、limitやoffsetと組み合わせてページングも行えます。
クエリに関しては手動で書かなければならないのですが、テンプレート的なものを自動生成しようかと思案中です。
1fragment user on User {2 id3 email4 name5 roles6 createdAt7 updatedAt8}910fragment category on Category {11 id12 name13 createdAt14 updatedAt15}1617fragment post on Post {18 id19 published20 title21 content22 authorId23 updatedAt24 publishedAt25}2627query CountUser($filter: UserFilter) {28 countUser(filter: $filter)29}3031query CountPost($filter: PostFilter) {32 countPost(filter: $filter)33}3435query CountCategory($filter: CategoryFilter) {36 countCategory(filter: $filter)37}3839query FindUniqueUser(40 $filter: UserUniqueFilter!41 $postFilter: PostFilter42 $postOrderBy: [PostOrderBy!]43 $postLimit: Int44 $postOffset: Int45) {46 findUniqueUser(filter: $filter) {47 ...user48 posts(49 filter: $postFilter50 orderBy: $postOrderBy51 limit: $postLimit52 offset: $postOffset53 ) {54 ...post55 }56 postsCount(filter: $postFilter)57 }58}5960query FindUniquePost(61 $filter: PostUniqueFilter!62 $categoryFilter: CategoryFilter63 $categoryOrderBy: [CategoryOrderBy!]64 $categoryLimit: Int65 $categoryOffset: Int66) {67 findUniquePost(filter: $filter) {68 ...post69 author {70 ...user71 }72 categories(73 filter: $categoryFilter74 orderBy: $categoryOrderBy75 limit: $categoryLimit76 offset: $categoryOffset77 ) {78 ...category79 }80 categoriesCount(filter: $categoryFilter)81 }82}8384query FindUniqueCategory(85 $filter: CategoryUniqueFilter!86 $postFilter: PostFilter87 $postOrderBy: [PostOrderBy!]88 $postLimit: Int89 $postOffset: Int90) {91 findUniqueCategory(filter: $filter) {92 ...category93 posts(94 filter: $postFilter95 orderBy: $postOrderBy96 limit: $postLimit97 offset: $postOffset98 ) {99 ...post100 }101 postsCount(filter: $postFilter)102 }103}104105query FindFirstUser(106 $filter: UserFilter107 $orderBy: [UserOrderBy!]108 $postFilter: PostFilter109 $postOrderBy: [PostOrderBy!]110 $postLimit: Int111 $postOffset: Int112) {113 findFirstUser(filter: $filter, orderBy: $orderBy) {114 ...user115 posts(116 filter: $postFilter117 orderBy: $postOrderBy118 limit: $postLimit119 offset: $postOffset120 ) {121 ...post122 }123 postsCount(filter: $postFilter)124 }125}126127query FindFirstPost(128 $filter: PostFilter129 $orderBy: [PostOrderBy!]130 $categoryFilter: CategoryFilter131 $categoryOrderBy: [CategoryOrderBy!]132 $categoryLimit: Int133 $categoryOffset: Int134) {135 findFirstPost(filter: $filter, orderBy: $orderBy) {136 ...post137 author {138 ...user139 }140 categories(141 filter: $categoryFilter142 orderBy: $categoryOrderBy143 limit: $categoryLimit144 offset: $categoryOffset145 ) {146 ...category147 }148 categoriesCount(filter: $categoryFilter)149 }150}151152query FindFirstCategory(153 $filter: CategoryFilter154 $orderBy: [CategoryOrderBy!]155 $postFilter: PostFilter156 $postOrderBy: [PostOrderBy!]157 $postLimit: Int158 $postOffset: Int159) {160 findFirstCategory(filter: $filter, orderBy: $orderBy) {161 ...category162 posts(163 filter: $postFilter164 orderBy: $postOrderBy165 limit: $postLimit166 offset: $postOffset167 ) {168 ...post169 }170 postsCount(filter: $postFilter)171 }172}173174query FindManyUser(175 $filter: UserFilter176 $orderBy: [UserOrderBy!]177 $limit: Int178 $offset: Int179 $postFilter: PostFilter180 $postOrderBy: [PostOrderBy!]181 $postLimit: Int182 $postOffset: Int183) {184 findManyUser(185 filter: $filter186 orderBy: $orderBy187 limit: $limit188 offset: $offset189 ) {190 ...user191 posts(192 filter: $postFilter193 orderBy: $postOrderBy194 limit: $postLimit195 offset: $postOffset196 ) {197 ...post198 }199 postsCount(filter: $postFilter)200 }201}202203query FindManyPost(204 $filter: PostFilter205 $limit: Int206 $offset: Int207 $orderBy: [PostOrderBy!]208 $categoryFilter: CategoryFilter209 $categoryOrderBy: [CategoryOrderBy!]210 $categoryLimit: Int211 $categoryOffset: Int212) {213 findManyPost(214 filter: $filter215 orderBy: $orderBy216 limit: $limit217 offset: $offset218 ) {219 ...post220 author {221 ...user222 }223 categories(224 filter: $categoryFilter225 orderBy: $categoryOrderBy226 limit: $categoryLimit227 offset: $categoryOffset228 ) {229 ...category230 }231 categoriesCount(filter: $categoryFilter)232 }233}234235query FindManyCategory(236 $filter: CategoryFilter237 $orderBy: [CategoryOrderBy!]238 $limit: Int239 $offset: Int240 $postFilter: PostFilter241 $postOrderBy: [PostOrderBy!]242 $postLimit: Int243 $postOffset: Int244) {245 findManyCategory(246 filter: $filter247 orderBy: $orderBy248 limit: $limit249 offset: $offset250 ) {251 ...category252 posts(253 filter: $postFilter254 orderBy: $postOrderBy255 limit: $postLimit256 offset: $postOffset257 ) {258 ...post259 }260 postsCount(filter: $postFilter)261 }262}263264mutation CreateOneUser($input: UserCreateInput!) {265 createOneUser(input: $input) {266 ...user267 }268}269270mutation CreateOnePost($input: PostCreateInput!) {271 createOnePost(input: $input) {272 ...post273 }274}275276mutation CreateOneCategory($input: CategoryCreateInput!) {277 createOneCategory(input: $input) {278 ...category279 }280}281282mutation CreateManyUser($input: [UserCreateInput!]!) {283 createManyUser(input: $input)284}285286mutation CreateManyPost($input: [PostCreateInput!]!) {287 createManyPost(input: $input)288}289290mutation CreateManyCategory($input: [CategoryCreateInput!]!) {291 createManyCategory(input: $input)292}293294mutation UpdateOneUser($where: UserUniqueFilter!, $data: UserUpdateInput!) {295 updateOneUser(where: $where, data: $data) {296 ...user297 }298}299300mutation UpdateOnePost($where: PostUniqueFilter!, $data: PostUpdateInput!) {301 updateOnePost(where: $where, data: $data) {302 ...post303 }304}305306mutation UpdateOneCategory(307 $where: CategoryUniqueFilter!308 $data: CategoryUpdateInput!309) {310 updateOneCategory(where: $where, data: $data) {311 ...category312 }313}314315mutation UpdateManyUser($where: UserFilter!, $data: UserUpdateInput!) {316 updateManyUser(where: $where, data: $data)317}318319mutation UpdateManyPost($where: PostFilter!, $data: PostUpdateInput!) {320 updateManyPost(where: $where, data: $data)321}322323mutation UpdateManyCategory(324 $where: CategoryFilter!325 $data: CategoryUpdateInput!326) {327 updateManyCategory(where: $where, data: $data)328}329330mutation DeleteOneUser($where: UserUniqueFilter!) {331 deleteOneUser(where: $where) {332 ...user333 }334}335336mutation DeleteOnePost($where: PostUniqueFilter!) {337 deleteOnePost(where: $where) {338 ...post339 }340}341342mutation DeleteOneCategory($where: CategoryUniqueFilter!) {343 deleteOneCategory(where: $where) {344 ...category345 }346}347348mutation DeleteManyUser($where: UserFilter!) {349 deleteManyUser(where: $where)350}351352mutation DeleteManyPost($where: PostFilter!) {353 deleteManyPost(where: $where)354}355356mutation DeleteManyCategory($where: CategoryFilter!) {357 deleteManyCategory(where: $where)358}359
使い方詳細
権限制御
初期状態だとQueryからMutationまで全て動いてしまいます。このままでは使い物になりません。
とりあえずMutationにアクセス制御をかけてみます。
まずはBuilderにauthority設定を追加し、権限を返す機能を追加します。
1import SchemaBuilder from "@pothos/core";2import PrismaPlugin from "@pothos/plugin-prisma";3import PrismaUtils from "@pothos/plugin-prisma-utils";4import PothosPrismaGenerator from "pothos-prisma-generator";5import { Context, prisma } from "./context";67/**8 * Create a new schema builder instance9 */10export const builder = new SchemaBuilder<{11 Context: Context;12}>({13 plugins: [PrismaPlugin, PrismaUtils, PothosPrismaGenerator, ScopeAuthPlugin],14 prisma: {15 client: prisma,16 },17 pothosPrismaGenerator: {18 // Set the following permissions19 /// @pothos-generator any {authority:["ROLE"]}20 authority: ({ context }) => context.user?.roles ?? [],21 },22});
USER権限をもったユーザのみmutation系の命令を使えるようにするため、
/// @pothos-generator executable {include:["mutation"],authority:["USER"]}
をPrismaの各モデルに設定し、prisma generateを実行します。
1generator client {2 provider = "prisma-client-js"3}45datasource db {6 provider = "postgresql"7 url = env("DATABASE_URL")8}910/// @pothos-generator executable {include:["mutation"],authority:["USER"]}11model User {12 id String @id @default(uuid())13 email String @unique14 name String @default("User")15 posts Post[]16 createdAt DateTime @default(now())17 updatedAt DateTime @updatedAt18}1920/// @pothos-generator executable {include:["mutation"],authority:["USER"]}21model Post {22 id String @id @default(uuid())23 published Boolean @default(false)24 title String @default("New Post")25 content String @default("")26 author User? @relation(fields: [authorId], references: [id])27 authorId String?28 categories Category[]29 createdAt DateTime @default(now())30 updatedAt DateTime @updatedAt31 publishedAt DateTime @default(now())32}3334/// @pothos-generator executable {include:["mutation"],authority:["USER"]}35model Category {36 id String @id @default(uuid())37 name String38 posts Post[]39 createdAt DateTime @default(now())40 updatedAt DateTime @updatedAt41}
これでcreateOneUserなどを実行する際は、認証を通していないと動作しなくなります。
書き込みデータに介入
例えばPostに書き込んだユーザをPrisma側のデータに与えたい場合、以下のように書きます。
1export const builder = new SchemaBuilder<{2 Context: Context;3}>({4 plugins: [PrismaPlugin, PrismaUtils, PothosPrismaGenerator],5 prisma: {6 client: prisma,7 },8 pothosPrismaGenerator: {9 // Replace the following directives10 // /// @pothos-generator input {data:{author:{connect:{id:"%%USER%%"}}}}11 replace: { "%%USER%%": ({ context }) => context.user?.id },12 },13});14
1/// @pothos-generator input-data {data:{author:{connect:{id:"%%USER%%"}}}}2model Post {3 id String @id @default(uuid())4 published Boolean @default(false)5 title String @default("New Post")6 content String @default("")7 author User? @relation(fields: [authorId], references: [id])8 authorId String?9 categories Category[]10 createdAt DateTime @default(now())11 updatedAt DateTime @updatedAt12 publishedAt DateTime @default(now())13}
これで書き込んだ人のユーザ情報がauthorに入ります。
権限による出力データの制御
認証されていないユーザにはpublished:falseのデータを見せたくない場合、以下のように書きます。
1export const builder = new SchemaBuilder<{2 Context: Context;3}>({4 plugins: [PrismaPlugin, PrismaUtils, PothosPrismaGenerator],5 prisma: {6 client: prisma,7 },8 pothosPrismaGenerator: {9 authority: ({ context }) => context.user?.roles ?? [],10 },11});
1/// @pothos-generator where {include:["query"],where:{},authority:["USER"]}2/// @pothos-generator where {include:["query"],where:{published:true}}3model Post {4 id String @id @default(uuid())5 published Boolean @default(false)6 title String @default("New Post")7 content String @default("")8 author User? @relation(fields: [authorId], references: [id])9 authorId String?10 categories Category[]11 createdAt DateTime @default(now())12 updatedAt DateTime @updatedAt13 publishedAt DateTime @default(now())14}
whereに介入して、権限によって出力条件を変えています。これで見せたくないデータを隠すことが出来ます。
まとめ
このパッケージは、Prismaを使ってGraphQLのAPIを簡単に作成できるようにするものです。主な機能は以下のとおりです。
Prismaのスキーマに基づいて、CRUD操作やフィルタリング、ページネーションなどのリゾルバを自動生成します。
リレーションやネストされたオブジェクトに対応した入力型や出力型を自動生成します。
認証や認可などのカスタムロジックを追加するためのBuilderパターンを提供します。
詳細な使い方や設定方法は、パッケージのREADMEをご覧ください。このパッケージは、Prismaのスキーマがファーストになるというコンセプトで作られています。つまり、GraphQLのスキーマではなく、Prismaのスキーマに従ってAPIが生成されます。また、実行時にリゾルバや型を動的に生成するため、コードの出力はありません。
このように、人間が書くコードを最小限にすることで、バグを減らすことができます。もちろん、ジェネレータ自体にバグがある場合は別ですが。
以上が、このパッケージの概要です。GraphQLとPrismaを使ってAPI開発をしたい方は、ぜひお試しください。