😀

【GraphQL】ApolloServer v4 × PrismaでSubscription実装時に関連するデータを取得

2023/07/06に公開

はじめに

今回はGraphQLApollo Server v4によるDBでリレーションが組んであるデータをリアルタイムで取得する方法(Subscription)を実装していきます。

前提

Subscriptionについてわからない人は、公式や以下の記事を参考にしてください。

実装

今回実装する機能としては、投稿に対して、投票を行う機能を実装していきます。(いいね機能と概念は同じです)

スキーマ定義

注意点として、今回はコード全体ではなく、関連する箇所をピックアップするような形で記載していきます。

:::note info
Prismaのスキーマ
:::

schema.prisma
model Link {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  description String
  url         String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      Int
  votes       Vote[]
}

model User {
  id       Int    @id @default(autoincrement())
  name     String
  email    String @unique
  password String
  links    Link[]
  votes    Vote[]
}

model Vote {
  id     Int  @id @default(autoincrement())
  link   Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
  linkId Int
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int

  @@unique([linkId, userId])
}

上記がDBのスキーマ(テーブル)定義になります。
投票はVoteモデルで管理します。このモデルは親にUserLinkのモデルを持つので、そのようにリレーションを組んでいます。

:::note info
GraphQLスキーマ
:::

schema.graphql
type Mutation {
  vote(linkId: ID!): Vote
}

type Subscription {
  newVote: NewVote
}

type Link {
  id: ID!
  description: String!
  url: String!
  user: User!
  votes: [Vote!]!
}

type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

type Vote {
  id: ID!
  link: Link!
  user: User!
}

type NewVote {
  id: ID
  link: Link
  user: User
}

GraphQLのスキーマでも同様に、テーブルの関連付けをするようにしています。

やりたいこととしては以下になります。

  • 投票をするとVoteテーブルにデータが格納される
  • 投票したらリアルタイムで投票時のデータを取得する

そのため、Mutationには投票のMutationを指定し、Subscriptionには投票時に取得するデータを指定しています。

ここで、「SubscriptionのnewVoteにはVote型を指定したらいいのでは?」と思われた方に向けて解説すると、Subscriptionの型をVoteにすると、Voteのidしか取得できず、関連するUserやLinkを取得しようとすると、nullが返却され、エラーとなります。
(実際に動作確認してみました)

そのため少し面倒ですが、NewVoteで型付しています。
意外とこの型付は忘れがちなので、要注意です。

カスタムミューテーション

ここでは、リレーションを組んだデータを取得するための処理を実装します。
例えば、linkに紐づくuserを取得するなどの処理です。

:::note info
Userミューテーション
:::

User.ts
import type { Context } from '@/types/Context'

export const links = (parent: { id: number }, __: unknown, context: Context) =>
  context.prisma.user
    .findUnique({
      where: { id: parent.id },
    })
    .links()

Userに紐づくlinkを配列で取得します。
parentは親という意味なので、親テーブルのidつまりLinksテーブルの親テーブルのidということになるので、Userテーブルのidで検索するということになります。(ややこしいですが、そういうことです)

スキーマ定義でいうと、Userのlinksに当たります。

schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

:::note info
Linkミューテーション
:::

Link.ts
import type { Context } from '@/types/Context'

export const user = (parent: { id: number }, __: unknown, context: Context) => {
  return context.prisma.link
    .findUnique({
      where: { id: parent.id },
    })
    .user()
}

export const votes = (parent: { id: number }, __: unknown, context: Context) =>
  context.prisma.link
    .findUnique({
      where: { id: parent.id },
    })
    .votes()

Linksでも同じように、関連するデータを取得できるようにしています。

スキーマ定義でいうと、Linkのuservotesがこれに当たります。

schema.graphql
type Link {
  id: ID!
  description: String!
  url: String!
  user: User!
  votes: [Vote!]!
}

:::note info
Voteミューテーション
:::

Vote.ts
import type { Context } from '@/types/Context'

export const link = (parent: { id: number }, __: unknown, context: Context) =>
  context.prisma.vote.findUnique({ where: { id: parent.id } }).link()

export const voteUser = (
  parent: { id: number },
  __: unknown,
  context: Context
) => context.prisma.vote.findUnique({ where: { id: parent.id } }).user()

Voteの同様です。1:Nの関連付けがある子テーブルなので、各親テーブルのidでデータを取得するようにしています。

スキーマ定義でいうとVoteのlinkuserがこれに当たります。

schema.graphql
type Vote {
  id: ID!
  link: Link!
  user: User!
}

投票機能の実装

以下のミューテーションとサブスクリプションの処理詳細を実装していきます。

schema.graphql
type Mutation {
  vote(linkId: ID!): Vote
}

type Subscription {
  newVote: NewVote
}

:::note info
処理詳細
:::

Mutation.ts
export const vote = async (
  _: unknown,
  args: { linkId: string },
  context: Context
) => {
  const userId = context.userId as number

  const vote = await context.prisma.vote.findUnique({
    where: {
      linkId_userId: {
        linkId: Number(args.linkId),
        userId: userId,
      },
    },
  })

  if (vote) {
    throw new Error(`Link is already voted: ${args.linkId}`)
  }

  const newVote = await context.prisma.vote.create({
    data: {
      user: { connect: { id: userId } },
      link: { connect: { id: Number(args.linkId) } },
    },
    include: {
      link: true,
      user: true,
    },
  })

  context.pubsub.publish('NEW_VOTE', newVote)

  return newVote
}

やっていることとしては以下になります。

  • contextよりuserIdを取得
  • すでに投票されているかどうかを判定
  • 投票する(Voteテーブルにデータを格納)
  • サブスクリプションを送信(publish)する

重要なのは、newVoteのincludeの部分で、これをtrueにすると、登録に関連するlinkuserを取得できます。

また、publishですが、第一引数にトリガー名をとり、第二引数に渡したい値をとります。
つまり、サブスクリプションの受信側で同じトリガー名を指定すれば、newVoteを受け取り、newVoteをリアルタイムで取得できるというわけです。

サブスクリプションの実装

以下のようにして実装します。(ここはコード全体をお見せします)
これは公式に沿った実装になっております。

server.ts
import 'dotenv/config'

import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'
import { loadSchemaSync } from '@graphql-tools/load'
import { addResolversToSchema } from '@graphql-tools/schema'
import { PrismaClient } from '@prisma/client'
import bodyParser from 'body-parser'
import cors from 'cors'
import express from 'express'
import { PubSub } from 'graphql-subscriptions'
import { useServer } from 'graphql-ws/lib/use/ws'
import { createServer } from 'http'
import { join } from 'path'
import { WebSocketServer } from 'ws'

import { user, votes } from './resolvers/Link'
import { vote } from './resolvers/Mutation'
import { links } from './resolvers/User'
import { link, voteUser } from './resolvers/Vote'
import type { Context } from './types/Context'
import type { NewVote } from './types/NewVote'
import { getUserId } from './utils'

const PORT = 4000
const pubsub = new PubSub()

const prisma = new PrismaClient()

const app = express()

const schema = loadSchemaSync(join(__dirname, './schema.graphql'), {
  loaders: [new GraphQLFileLoader()],
})

// リゾルバー関数
const resolvers = {
  Mutation: {
    vote: vote,
  },

  Subscription: {
    newVote: {
      subscribe: () => pubsub.asyncIterator(['NEW_VOTE']),
      resolve: (payload: NewVote) => payload,
    },
  },

  Link: {
    user: user,
    votes: votes,
  },

  User: {
    links: links,
  },

  Vote: {
    link: link,
    user: voteUser,
  },
}

const schemaWithResolvers = addResolversToSchema({ schema, resolvers })

const httpServer = createServer(app)

const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
})

const serverCleanup = useServer({ schema: schemaWithResolvers }, wsServer)

const server = new ApolloServer<Context>({
  schema: schemaWithResolvers,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),

    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose()
          },
        }
      },
    },
  ],
})

;(async () => {
  try {
    await server.start()

    app.use(
      '/graphql',
      cors<cors.CorsRequest>(),
      bodyParser.json(),
      expressMiddleware(server, {
        context: async ({ req }) => ({
          ...req,
          prisma,
          pubsub,
          userId: req && req.headers.authorization ? getUserId(req) : undefined,
        }),
      })
    )

    httpServer.listen(PORT, () => {
      console.log(`🚀 Query endpoint ready at http://localhost:${PORT}/graphql`)
      console.log(
        `🚀 Subscription endpoint ready at ws://localhost:${PORT}/graphql`
      )
    })
  } catch (error) {
    console.error('Error starting server: ', error)
  }
})()

:::note info
型定義
:::
関連する型定義は以下になります。

Context.ts
import type { Prisma, PrismaClient } from '@prisma/client'
import type { DefaultArgs } from '@prisma/client/runtime'
import type { PubSub } from 'graphql-subscriptions'

export type Context = {
  prisma: PrismaClient<
    Prisma.PrismaClientOptions,
    never,
    Prisma.RejectOnNotFound | Prisma.RejectPerOperation | undefined,
    DefaultArgs
  >
  pubsub: PubSub
  userId?: string | number
}
NewVote.ts
import type { Link, User } from '@prisma/client'

export type NewVote = {
  id: number
  link: Link
  user: User
}

:::note info
サブスクリプションの実装部分について解説
:::
以下の部分について解説します。

server.ts
const pubsub = new PubSub()

 Subscription: {
    newVote: {
      subscribe: () => pubsub.asyncIterator(['NEW_VOTE']),
      resolve: (payload: NewVote) => payload,
    },
  }

ここでは、受信側の定義を行います。
PubSubインスタンスのasyncIteratorに送信側のトリガー名を指定してあげると、サブスクリプションを受信します。

subscribeの定義だけでもVoteのデータのみは取得できます。
どういうことかというと、UserやLinkのような関連付けされたデータは取得されず、Voteのidのみ取得できるといことです。

関連付けされたデータまで取得する場合は、resolveとしてpyaloadを返却する必要があります。
ここでNewVote型とすることで、関連付けされたデータも取得できるようになります。

以上で、実装は完了です。

おわりに

改めて、今回の実装を振り返ると、GraphQLのスキーマでも型の定義って重要なんだな〜と思います。
しっかり機械が認識するようにしてあげる、いわば機械側の立場になったつもりでプログラミングすることが重要だと感じました。

どなたかの参考になれば幸いです。

参考文献

Discussion