空雲 Blog

Eye catchPrismaのスキーマをからPothosのGraphQLオペレーションを全自動で生成する

publication: 2023/09/03
update:2024/02/23

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はこれまで使ったことがなかったので、開発は完全にゼロからでした。

基本的な使い方

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";
6
7/**
8 * Create a new schema builder instance
9 */
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}
4
5datasource db {
6 provider = "postgresql"
7 url = env("DATABASE_URL")
8}
9
10model User {
11 id String @id @default(uuid())
12 email String @unique
13 name String @default("User")
14 posts Post[]
15 roles Role[] @default([USER])
16 createdAt DateTime @default(now())
17 updatedAt DateTime @updatedAt
18}
19
20model 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 @updatedAt
30 publishedAt DateTime @default(now())
31}
32
33model Category {
34 id String @id @default(uuid())
35 name String
36 posts Post[]
37 createdAt DateTime @default(now())
38 updatedAt DateTime @updatedAt
39}
40
41enum Role {
42 ADMIN
43 USER
44}

出力結果

以下のようにPrismaのモデルに対応したQueryとMutationが作成されます。プラグインを追加するだけで、モデルに対応したオペレーションの全機能が動きます。

ラクチン!

{"width":"1802px","height":"1637px"}
{"width":"1805px","height":"1885px"}

作成可能なクエリの例

{"width":"1448px","height":"688px"}

クエリの引数としてfilter,orderBy,limit,offsetの指定が可能です。また、リレーション先に対しても同様の指定が可能です。
これらの機能を手動で作った場合、リレーション先を含めるとかなり複雑になります。

以下、クエリのサンプルです。リレーションのリレーションまでは書いていませんが、どこまでもたどれます。リレーションに関しては件数取得機能もフィールド上に追加されるので、limitやoffsetと組み合わせてページングも行えます。

クエリに関しては手動で書かなければならないのですが、テンプレート的なものを自動生成しようかと思案中です。

1fragment user on User {
2 id
3 email
4 name
5 roles
6 createdAt
7 updatedAt
8}
9
10fragment category on Category {
11 id
12 name
13 createdAt
14 updatedAt
15}
16
17fragment post on Post {
18 id
19 published
20 title
21 content
22 authorId
23 updatedAt
24 publishedAt
25}
26
27query CountUser($filter: UserFilter) {
28 countUser(filter: $filter)
29}
30
31query CountPost($filter: PostFilter) {
32 countPost(filter: $filter)
33}
34
35query CountCategory($filter: CategoryFilter) {
36 countCategory(filter: $filter)
37}
38
39query FindUniqueUser(
40 $filter: UserUniqueFilter!
41 $postFilter: PostFilter
42 $postOrderBy: [PostOrderBy!]
43 $postLimit: Int
44 $postOffset: Int
45) {
46 findUniqueUser(filter: $filter) {
47 ...user
48 posts(
49 filter: $postFilter
50 orderBy: $postOrderBy
51 limit: $postLimit
52 offset: $postOffset
53 ) {
54 ...post
55 }
56 postsCount(filter: $postFilter)
57 }
58}
59
60query FindUniquePost(
61 $filter: PostUniqueFilter!
62 $categoryFilter: CategoryFilter
63 $categoryOrderBy: [CategoryOrderBy!]
64 $categoryLimit: Int
65 $categoryOffset: Int
66) {
67 findUniquePost(filter: $filter) {
68 ...post
69 author {
70 ...user
71 }
72 categories(
73 filter: $categoryFilter
74 orderBy: $categoryOrderBy
75 limit: $categoryLimit
76 offset: $categoryOffset
77 ) {
78 ...category
79 }
80 categoriesCount(filter: $categoryFilter)
81 }
82}
83
84query FindUniqueCategory(
85 $filter: CategoryUniqueFilter!
86 $postFilter: PostFilter
87 $postOrderBy: [PostOrderBy!]
88 $postLimit: Int
89 $postOffset: Int
90) {
91 findUniqueCategory(filter: $filter) {
92 ...category
93 posts(
94 filter: $postFilter
95 orderBy: $postOrderBy
96 limit: $postLimit
97 offset: $postOffset
98 ) {
99 ...post
100 }
101 postsCount(filter: $postFilter)
102 }
103}
104
105query FindFirstUser(
106 $filter: UserFilter
107 $orderBy: [UserOrderBy!]
108 $postFilter: PostFilter
109 $postOrderBy: [PostOrderBy!]
110 $postLimit: Int
111 $postOffset: Int
112) {
113 findFirstUser(filter: $filter, orderBy: $orderBy) {
114 ...user
115 posts(
116 filter: $postFilter
117 orderBy: $postOrderBy
118 limit: $postLimit
119 offset: $postOffset
120 ) {
121 ...post
122 }
123 postsCount(filter: $postFilter)
124 }
125}
126
127query FindFirstPost(
128 $filter: PostFilter
129 $orderBy: [PostOrderBy!]
130 $categoryFilter: CategoryFilter
131 $categoryOrderBy: [CategoryOrderBy!]
132 $categoryLimit: Int
133 $categoryOffset: Int
134) {
135 findFirstPost(filter: $filter, orderBy: $orderBy) {
136 ...post
137 author {
138 ...user
139 }
140 categories(
141 filter: $categoryFilter
142 orderBy: $categoryOrderBy
143 limit: $categoryLimit
144 offset: $categoryOffset
145 ) {
146 ...category
147 }
148 categoriesCount(filter: $categoryFilter)
149 }
150}
151
152query FindFirstCategory(
153 $filter: CategoryFilter
154 $orderBy: [CategoryOrderBy!]
155 $postFilter: PostFilter
156 $postOrderBy: [PostOrderBy!]
157 $postLimit: Int
158 $postOffset: Int
159) {
160 findFirstCategory(filter: $filter, orderBy: $orderBy) {
161 ...category
162 posts(
163 filter: $postFilter
164 orderBy: $postOrderBy
165 limit: $postLimit
166 offset: $postOffset
167 ) {
168 ...post
169 }
170 postsCount(filter: $postFilter)
171 }
172}
173
174query FindManyUser(
175 $filter: UserFilter
176 $orderBy: [UserOrderBy!]
177 $limit: Int
178 $offset: Int
179 $postFilter: PostFilter
180 $postOrderBy: [PostOrderBy!]
181 $postLimit: Int
182 $postOffset: Int
183) {
184 findManyUser(
185 filter: $filter
186 orderBy: $orderBy
187 limit: $limit
188 offset: $offset
189 ) {
190 ...user
191 posts(
192 filter: $postFilter
193 orderBy: $postOrderBy
194 limit: $postLimit
195 offset: $postOffset
196 ) {
197 ...post
198 }
199 postsCount(filter: $postFilter)
200 }
201}
202
203query FindManyPost(
204 $filter: PostFilter
205 $limit: Int
206 $offset: Int
207 $orderBy: [PostOrderBy!]
208 $categoryFilter: CategoryFilter
209 $categoryOrderBy: [CategoryOrderBy!]
210 $categoryLimit: Int
211 $categoryOffset: Int
212) {
213 findManyPost(
214 filter: $filter
215 orderBy: $orderBy
216 limit: $limit
217 offset: $offset
218 ) {
219 ...post
220 author {
221 ...user
222 }
223 categories(
224 filter: $categoryFilter
225 orderBy: $categoryOrderBy
226 limit: $categoryLimit
227 offset: $categoryOffset
228 ) {
229 ...category
230 }
231 categoriesCount(filter: $categoryFilter)
232 }
233}
234
235query FindManyCategory(
236 $filter: CategoryFilter
237 $orderBy: [CategoryOrderBy!]
238 $limit: Int
239 $offset: Int
240 $postFilter: PostFilter
241 $postOrderBy: [PostOrderBy!]
242 $postLimit: Int
243 $postOffset: Int
244) {
245 findManyCategory(
246 filter: $filter
247 orderBy: $orderBy
248 limit: $limit
249 offset: $offset
250 ) {
251 ...category
252 posts(
253 filter: $postFilter
254 orderBy: $postOrderBy
255 limit: $postLimit
256 offset: $postOffset
257 ) {
258 ...post
259 }
260 postsCount(filter: $postFilter)
261 }
262}
263
264mutation CreateOneUser($input: UserCreateInput!) {
265 createOneUser(input: $input) {
266 ...user
267 }
268}
269
270mutation CreateOnePost($input: PostCreateInput!) {
271 createOnePost(input: $input) {
272 ...post
273 }
274}
275
276mutation CreateOneCategory($input: CategoryCreateInput!) {
277 createOneCategory(input: $input) {
278 ...category
279 }
280}
281
282mutation CreateManyUser($input: [UserCreateInput!]!) {
283 createManyUser(input: $input)
284}
285
286mutation CreateManyPost($input: [PostCreateInput!]!) {
287 createManyPost(input: $input)
288}
289
290mutation CreateManyCategory($input: [CategoryCreateInput!]!) {
291 createManyCategory(input: $input)
292}
293
294mutation UpdateOneUser($where: UserUniqueFilter!, $data: UserUpdateInput!) {
295 updateOneUser(where: $where, data: $data) {
296 ...user
297 }
298}
299
300mutation UpdateOnePost($where: PostUniqueFilter!, $data: PostUpdateInput!) {
301 updateOnePost(where: $where, data: $data) {
302 ...post
303 }
304}
305
306mutation UpdateOneCategory(
307 $where: CategoryUniqueFilter!
308 $data: CategoryUpdateInput!
309) {
310 updateOneCategory(where: $where, data: $data) {
311 ...category
312 }
313}
314
315mutation UpdateManyUser($where: UserFilter!, $data: UserUpdateInput!) {
316 updateManyUser(where: $where, data: $data)
317}
318
319mutation UpdateManyPost($where: PostFilter!, $data: PostUpdateInput!) {
320 updateManyPost(where: $where, data: $data)
321}
322
323mutation UpdateManyCategory(
324 $where: CategoryFilter!
325 $data: CategoryUpdateInput!
326) {
327 updateManyCategory(where: $where, data: $data)
328}
329
330mutation DeleteOneUser($where: UserUniqueFilter!) {
331 deleteOneUser(where: $where) {
332 ...user
333 }
334}
335
336mutation DeleteOnePost($where: PostUniqueFilter!) {
337 deleteOnePost(where: $where) {
338 ...post
339 }
340}
341
342mutation DeleteOneCategory($where: CategoryUniqueFilter!) {
343 deleteOneCategory(where: $where) {
344 ...category
345 }
346}
347
348mutation DeleteManyUser($where: UserFilter!) {
349 deleteManyUser(where: $where)
350}
351
352mutation DeleteManyPost($where: PostFilter!) {
353 deleteManyPost(where: $where)
354}
355
356mutation 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";
6
7/**
8 * Create a new schema builder instance
9 */
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 permissions
19 /// @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}
4
5datasource db {
6 provider = "postgresql"
7 url = env("DATABASE_URL")
8}
9
10/// @pothos-generator executable {include:["mutation"],authority:["USER"]}
11model User {
12 id String @id @default(uuid())
13 email String @unique
14 name String @default("User")
15 posts Post[]
16 createdAt DateTime @default(now())
17 updatedAt DateTime @updatedAt
18}
19
20/// @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 @updatedAt
31 publishedAt DateTime @default(now())
32}
33
34/// @pothos-generator executable {include:["mutation"],authority:["USER"]}
35model Category {
36 id String @id @default(uuid())
37 name String
38 posts Post[]
39 createdAt DateTime @default(now())
40 updatedAt DateTime @updatedAt
41}

これで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 directives
10 // /// @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 @updatedAt
12 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 @updatedAt
13 publishedAt DateTime @default(now())
14}

whereに介入して、権限によって出力条件を変えています。これで見せたくないデータを隠すことが出来ます。

まとめ

このパッケージは、Prismaを使ってGraphQLのAPIを簡単に作成できるようにするものです。主な機能は以下のとおりです。

  • Prismaのスキーマに基づいて、CRUD操作やフィルタリング、ページネーションなどのリゾルバを自動生成します。

  • リレーションやネストされたオブジェクトに対応した入力型や出力型を自動生成します。

  • 認証や認可などのカスタムロジックを追加するためのBuilderパターンを提供します。

詳細な使い方や設定方法は、パッケージのREADMEをご覧ください。このパッケージは、Prismaのスキーマがファーストになるというコンセプトで作られています。つまり、GraphQLのスキーマではなく、Prismaのスキーマに従ってAPIが生成されます。また、実行時にリゾルバや型を動的に生成するため、コードの出力はありません。

このように、人間が書くコードを最小限にすることで、バグを減らすことができます。もちろん、ジェネレータ自体にバグがある場合は別ですが。

以上が、このパッケージの概要です。GraphQLとPrismaを使ってAPI開発をしたい方は、ぜひお試しください。