🏛️

【Graphql】Pothosでスキーマ定義&動作確認

に公開

はじめに

仕事ではGraphqlのスキーマ定義にtypegraphql-prismaを使用しています。
typegraphql-prismaはgeneratorによる自動生成が便利ですが、Prisma v6以降に対応していないようです。
代替手段を探しているとPothosというライブラリを見つけたので、軽くスキーマ定義から動作確認まで行いました。
※Pothosの使い方紹介がメインなのでPrismaやGraphql周りの説明は省いています

環境

ライブラリ バージョン
@apollo/server ^5.0.0
@pothos/core ^4.10.0
@pothos/plugin-prisma ^4.14.1
@prisma/adapter-pg ^7.0.0
@prisma/client ^6.18.0
apollo-server ^3.13.0
graphql ^16.12.0
graphql-scalars ^1.25.0
prisma ^6.18.0
tsx ^4.20.6

https://pothos-graphql.dev/docs/plugins/prisma

Pothosのインストール

npm install --save @pothos/plugin-prisma

schema.prismaの設定

schema.prismaにpothosのgeneratorを定義します。
prisma generateを実行するとoutputで指定したパスに型定義ファイルが出力されます。
clientOutputにはprisma-clientの出力先を指定します。
prisma-clientの出力先はpothosのoutputファイルを基準として指定しなければならないことに注意してください。

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

generator pothos {
  provider = "prisma-pothos-types"
  clientOutput = "./prisma"
  output = "../src/generated/pothos-prisma-types.ts"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Genre {
  id            Int       @id @default(autoincrement())
  name          String    @unique
  description   String    

  createdAt     DateTime  @map("created_at") @default(now())
  updatedAt     DateTime?  @map("updated_at")
}

Pothosを使ったスキーマ定義

定義しなければならない概念は以下の4つです。

  • Object
  • InputType
  • Resolver
  • Service

フォルダ構成は以下の通りです。

/
├─ src
│  ├─ prisma
│  ├─ schema
│  │  ├─ common
│  │  │  ├─ SortOrder.ts
│  │  │  └─ StringFilter.ts
│  │  ├─ inputs
│  │  │  ├─ GenreAddInput.ts
│  │  │  ├─ GenreOrderByInput.ts
│  │  │  └─ GenreWhereInput.ts
│  │  ├─ objects
│  │  │  └─ Genre.ts
│  │  ├─ resolvers
│  │  │  └─ GenreResolver.ts
│  │  ├─ services
│  │  │  └─ GenreService.ts
│  │  ├─ builder.ts
│  │  └─ index.ts
│  └─ server.ts
├─ package.json
└─ ・・・

Common

SortOrder.ts
import { builder } from '../builder'

export const SortOrder = builder.enumType('SortOrder', {
  values: ['asc', 'desc'] as const,
})
StringFilter.ts
import { builder } from '../builder'

export const StringFilter = builder.inputType('StringFilter', {
  fields: (t) => ({
    contains: t.string(),
    startsWith: t.string(),
    endsWith: t.string(),
  }),
})

Object

prismaで定義したmodelをここで定義します。この記述はgraphqlのtypeに相当します。

Genre.ts
import { builder } from '../builder'

builder.prismaObject('Genre', {
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    description: t.exposeString('description', {
      nullable: true,
    }),
  }),
})

InputType

graphqlのinputに相当する設定を行います。

GenreAddInput.ts
import { builder } from '../builder'

export const GenreAddInput = builder.inputType('GenreAddInput', {
  fields: (t) => ({
    name: t.string({ required: true }),
    description: t.string({ required: false }),
  }),
})
GenreOrderByInput.ts
import { builder } from '../builder'

export const GenreOrderByInput = builder.inputType('GenreOrderByInput', {
  fields: (t) => ({
    name: t.field({ type: 'SortOrder', required: false }),
    id: t.field({ type: 'SortOrder', required: false }),
  }),
})
GenreWhereInput.ts
import { builder } from '../builder'
import { StringFilter } from '../common/StringFilter'

export const GenreWhereInput = builder.inputType('GenreWhereInput', {
  fields: (t) => ({
    name: t.field({ type: StringFilter, required: false }),
  }),
})

Resolver

GenreResolver.ts
import { builder } from '../builder'
import { GenreService } from '../services/GenreService'
import { GenreAddInput } from '../inputs/GenreAddInput'
import { GenreWhereInput } from '../inputs/GenreWhereInput'
import { GenreOrderByInput } from '../inputs/GenreOrderByInput'

builder.queryFields((t) => ({
  findManyGenres: t.prismaField({
    type: ['Genre'],
    args: {
      where: t.arg({ type: GenreWhereInput, required: false }),
      skip: t.arg.int({ required: false }),
      take: t.arg.int({ required: false }),
      orderBy: t.arg({ type: GenreOrderByInput, required: false }),
    },
    resolve: async (query, _root, args, ctx) => {
      const service = new GenreService(ctx.client)
      return service.findMany({
        ...query,
        where: args.where,
        skip: args.skip,
        take: args.take,
        orderBy: args.orderBy,
      })
    },
  }),
}))

builder.mutationFields((t) => ({
  addGenre: t.prismaField({
    type: 'Genre',
    args: {
      data: t.arg({ type: GenreAddInput, required: true }),
    },
    resolve: async (query, _root, args, ctx) => {
      const service = new GenreService(ctx.client)
      return service.addOneGenre({
        data: args.data,
      })
    },
  }),
}))

Service

GenreService.ts
import { PrismaClient } from '../../generated/prisma/client.js'

export class GenreService {
  constructor(private client: PrismaClient) {}

  async findMany(args: {
    where?: { name?: string }
    skip?: number
    take?: number
    orderBy?: any
  }) {
    return this.client.genre.findMany(args)
  }

  async addOneGenre(condition: {
    data: { name: string; description?: string }
  }) {
    return this.client.genre.create({
      data: {
        name: condition.data.name,
        description: condition.data.description ?? '',
      },
    })
  }
}

builder.ts

builder.ts
import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import type PrismaTypes from '@pothos/plugin-prisma/generated'
import { PrismaClient } from '../generated/prisma/client.js'

const prisma = new PrismaClient()

export const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes
  Context: {
    prisma: PrismaClient
  }
}>({
  plugins: [PrismaPlugin],
  prisma: {
    client: prisma,
  },
});

index.ts

index.ts
import { builder } from './builder.js'

builder.queryType({})
builder.mutationType({})

import './common/SortOrder.js'
import './objects/Genre.js'
import './resolvers/GenreResolver.js'

export const schema = builder.toSchema()

動作確認

サーバ起動

server.ts
import 'dotenv/config';
import "reflect-metadata";

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { PrismaClient } from "./generated/prisma/client.js";
import { schema as pothosSchema } from "./schema/index.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const client = new PrismaClient();

// Pothosスキーマ作成
const schema = pothosSchema;

const server = new ApolloServer({
    schema,
});

const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
    context: async ({ req }) => ({
        ...req,
        client
    })
});

console.log(`🚀  Server ready at: ${url}`)
npx tsx ./src/local.ts

プレイグラウンドでクエリ実行

おわりに

typegraphqlはデコレーションで設定しますが、pothosではビルダーバターンを使い関数で設定するという違いがありそうです。
既にデータクラスが用意されているプロジェクトではデコレーションをつけるだけのtypegraphqlの方が早く導入できそうです。それに比べpothosは手書きの手間はかかるものの、Prismaの型定義をそのまま参照しているので型安全性が高そうです。
まだ理解が浅いので詳しい違いを今後記事にしていけたらと思います。

Discussion