💭

GraphQLのCustom Scalarsを使って再帰的なデータ構造を扱ってみる

2022/02/13に公開

サマリ

  • GraphQLでは再帰的なデータ構造を定義することができるが、再帰的なクエリを書くことはできない
  • カスタムスカラーを定義すると、再起的なデータ構造を扱うことができる

※ただし、クライアント側で型を再定義する必要がある。型の二重定義にならないように工夫が必要。
※どうしても再帰的なデータ構造を扱いたい時のみお使いください。

GraphQLで再帰的なデータ構造を扱えるか

GraphQLでは再起的なデータ構造を定義することが可能です。

type Category {
  id: ID!
  name: String!
  children: [Category!]!
}

type Query {
  categories(): [Category!]!
}

しかし、再帰的なクエリを上手く書くことはできません。
再帰を扱うためにfragmentを使って書きたいところですが、以下のようなエラーとなります。

fragment CategoryFields on Category {
  id
  name
  children {
    ...CategoryFields
    # Cannot spread fragment "CategoryFields" within itself.GraphQL: Validation
  }
}

# ネストしたクエリを自前で書くことはできる
query fetchCategories() {
  categories {
    id
    name
    children {
      id
      name
      children {
        id
        name
      }
    }
  }
}

参考
https://hashinteractive.com/blog/graphql-recursive-query-with-fragments/

カスタムスカラーを用いる

GraphQLではデフォルトで Int String などの型が定義されていますが、これらの型は自由に拡張することが可能です。これを用いて、再起的なデータ構造をもつ型をカスタムスカラーとして定義してみましょう。

ここでは、以下の構成でのサンプルコードを紹介します。

  • 言語:TypeScript
  • サーバーサイド:Nexus(Apollo Server)
  • フロントエンド:Apollo Client(Graphql Codegen)

Nexus(Apollo Server)

公式ドキュメントはこちら。
https://nexusjs.org/docs/api/scalar-type
https://www.apollographql.com/docs/apollo-server/schema/custom-scalars/

公式ドキュメントだけだとやや分かりづらかったので、以下にコメント付きのサンプルコードを示します。

import { Kind } from 'graphql'
import { scalarType } from 'nexus'

export type Category = {
  id: string
  name: string
  children: Category[]
}

export const CategoryScalar = scalarType({
  name: 'Category', // スカラー型の名前
  asNexusMethod: 'category', // nexusで扱う際の名前。t.category()のメソッドが定義される。
  description: '', // 説明
  sourceType: { // ここで対応するTypeScriptの型を定義
    module: __filename, // TypeScriptの型が定義されているファイルの絶対パスを指定。ここでは、このファイル自身とする
    export: `Category`, // TypeScriptの型の名前(4行目でexportしているCategory型を指定)
  },
  serialize(value) { // レスポンスをserialize
    // validationして、valueがCategory型かどうかをチェック(省略)
    // 必要があればvalueを整形してから返す
    return value
  },
  parseValue(value) { // リクエストをdeserialize
    // validationして、valueがCategory型かどうかをチェック(省略)
    // 必要があればvalueを整形してから返す
    return value
  },
  parseLiteral(ast) {
    // parseLiteralがどう使われているのかよくわからなかった… :pray:
    const { value } = ast

    return value
  },
})

nexusの場合、これをmakeSchemaのtypesで指定すればOKです。

export const schema = makeSchema({
  types: [Query, Mutation, ...types, CategoryScalar],
  // ...

これでCategory型を定義することができました。

Graphql Codegen (Apollo Client)

クライアント側でGraphQL Code Generatorを使っている場合、サーバー側の定義が終わった時点でクライアントにも Category 型が定義されますが、any型となってしまうためこのままでは使えません。

正しく型を定義するために、 codegen.yml に以下を追記します。

  • scalarsCategory を追加
  • addプラグインをインストールし、生成されるファイルにimport文を追記する
  • src/graphql/types/Category.ts で型を再定義する。
overwrite: true
schema: 'http://localhost:3000/graphql'
documents: 'src/graphql/**/*.graphql'
generates:
  src/graphql/generated.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'
      - 'typescript-react-apollo'
      - 'add'
    config:
      withComponent: false
      withHooks: true
      withHOC: false
      scalars:
        Category: Category
      content:
        - "import { Category } from './types/Category';"

このままだと src/graphql/types/Category.ts 型を再定義する必要があり、サーバーサイドでの型定義と二重定義になってしまい微妙です。
サーバーサイドの型定義ファイルを直接参照できるようなプロジェクト構造でない限り根本的な解決策はありませんが、自分の場合は graphql-codegen の実行時にサーバーサイドのリポジトリからファイルを cp するようなスクリプトを書いて一旦の解決としました -> 後々モノレポ化して解決しました👏。

※ここでは最低限の設定を紹介しましたが、より堅牢な書き方もできます。以下を参考にどうぞ。
https://zenn.dev/izumin/articles/ffc84c1b4310be

理想的な解決策ではありませんが、どうしても再帰的なデータ構造を扱いたくなった際に検討してみてください。

Discussion