7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

MongoDB(mongoose)のスキーマを元にGraphQLのリゾルバとスキーマを生成する with TypeScript

Posted at

個人的にMongoDB(ドキュメント指向DB)とGraphQLの相性がいいなと思うのはいわゆる「DBのテーブル構造をそのままGraphQLのスキーマにするべきではない」という設計上の面倒ポイントをある程度軽減できるところです。GraphQLのスキーマ設計におけるアンチパターンはそのままドキュメントのスキーマ設計アンチパターンであったりするので、ドキュメントのスキーマを正しく(というかGraphQLで接続することを前提として)設計できていればそのままGraphQLのスキーマに流用してしまってもまあよかろうという考え方ですね。

さて、graphql-composeというツールキットがあります。これはGraphQLのスキーマをSDLではなくJSで書くというコンセプト(コードファースト)のものなのですが、プラグインとしてmongooseのModelオブジェクトからGraphQLスキーマを生成する機能が提供されています。ついでにCRUD系のシンプルなリゾルバも勝手に作ってくれるので、キチンと使えばコーディング量がめちゃくちゃ減って大助かりです。

しかしその「キチンと使う」が中々厄介なところで、具体的に言うと型付けが面倒なんですよね。これはもう様々な事情でしょうがないことではあるのですが…。

そこで当記事ではこれらgraphql-compose、graphql-compose-mongoose、(前提として)mongooseをTypeScriptで適切に扱う方法を解説していきます。基本的には

をベースに話を進めるため、未読の方はぜひご一読ください。

執筆時のバージョン

node: 16.14.2

typescript: 4.6.4
ts-node: 10.7.0

mongoose: 6.3.2
graphql: 16.4.0
graphql-compose: 9.0.8
graphql-compose-mongoose: 9.7.1

当記事の目標

  • mongooseのSchemaおよびModelに適切な型をつける
  • ModelをもとにシンプルなリゾルバおよびGraphQLSchemaオブジェクトを生成する
  • Modelにつけた型をもとにリゾルバを自作する
  • 生成されたGraphQLSchemaオブジェクトを.graphqlファイルに書き出す

解説

Step1: SchemaおよびModel(mongoose)

interface Character {
  name: string
  class: Class
  level: number
}

const Class = [
  "Hero",
  "Bandit",
  "Astrologer",
  "Warrior",
  "Prisoner",
  "Confessor",
  "Wretch",
  "Vagabond",
  "Prophet",
  "Samurai",
] as const
type Class = typeof Class[number]

ベースとなる型を用意します。enumはenumとして定義すると次の手順で面倒が起こるのでこのように定義してください。
参考: さようなら、TypeScript enum

次にこの型を元にSchemaを作ります。公式ドキュメントではこのように

const characterSchema = new Schema<Character & Document>({
  name: {
    type: String,
    required: true,
  }
  ...
})

直接Schemaのコンストラクタにオブジェクトを渡す方式を取っていますが、この方法はスキーマがCharacterインターフェースを満たしているかどうかを確認しないため
image.png
(classとlevel属性が欠落しているにも関わらずエラーが出ない)

このように一度SchemaDefinition型のオブジェクトを作成してからnew Schema()することをおすすめします。

import { SchemaDefinition, Schema, Document} from "mongoose"

const characterSchemaDefinition: Required<SchemaDefinition<Character>> = {
  name: {
    type: String,
    required: true,
  },
  class: {
    type: String,
    required: true,
    enum: Class,
  },
  level: {
    type: Number,
    required: true,
    min: 1,
    max: 713,
  },
}

type ICharacter = Character & Document

// オプションはお好みで
const characterSchema = new Schema<ICharacter>(characterSchemaDefinition, {
  versionKey: false,
  timestamps: true,
})

最後に定義したSchemaからModelを作っておしまいです。

import { Model, models } from "mongoose"

const characterModel: Model<ICharacter> =
  models.Character || model("Character", characterSchema, "characters")

なおドキュメント通り

const characterModel = model("Character", characterSchema, "characters")

このように書くとホットリロード時にモデルを再定義しようとして怒られてしまうので注意が必要です。

Step2: 組み込みのリゾルバおよびGraphQLSchema

前述の通りgraphql-compose-mongooseには基本的な機能を持つリゾルバをModelの定義から生成してくれる機能があります。ページネーションとかあるのが個人的一押しポイント。
用意されているリゾルバの一覧はこちらに記載があります。

import { schemaComposer } from "graphql-compose"
import { composeMongoose, ObjectTypeComposerWithMongooseResolvers } from "graphql-compose-mongoose"

// これもホットリロード時対策です
const characterTC = schemaComposer.has("Character")
  ? (schemaComposer.getOTC("Character") as ObjectTypeComposerWithMongooseResolvers<ICharacter>)
  : composeMongoose(characterModel)

schemaComposer.Query.addFields({
  character: characterTC.mongooseResolvers.findById(),
  characters: characterTC.mongooseResolvers.pagination(),
  charactersCount: characterTC.mongooseResolvers.count(),
})

schemaComposer.Mutation.addFields({
  createCharacter: characterTC.mongooseResolvers.createOne(),
  updateCharacter: characterTC.mongooseResolvers.updateById(),
  removeCharacter: characterTC.mongooseResolvers.removeById(),
})

const schema = schemaComposer.buildSchema()

では一度ここで生成されたGraphQLスキーマの中身を見てみましょう。

import { printSchema } from "graphql"

console.log(printSchema(schema))
type Query {
  character(_id: MongoID!): Character
  characters(
    """Page number for displaying"""
    page: Int

    """"""
    perPage: Int = 20

    """Filter by fields"""
    filter: FilterFindManyCharacterInput
    sort: SortFindManyCharacterInput
  ): CharacterPagination
  charactersCount(
    """Filter by fields"""
    filter: FilterCountCharacterInput
  ): Int
}

type Character {
  name: String!
  class: EnumCharacterClass!
  level: Float!
  _id: MongoID!
  updatedAt: Date
  createdAt: Date
}

enum EnumCharacterClass {
  Hero
  Bandit
  Astrologer
  Warrior
  Prisoner
  Confessor
  Wretch
  Vagabond
  Prophet
  Samurai
}

"""
The `ID` scalar type represents a unique MongoDB identifier in collection. MongoDB by default use 12-byte ObjectId value (https://docs.mongodb.com/manual/reference/bson-types/#objectid). But MongoDB also may accepts string or integer as correct values for _id field.
"""
scalar MongoID

scalar Date

"""List of items with pagination."""
type CharacterPagination {
  """Total object count."""
  count: Int

  """Array of objects."""
  items: [Character!]

  """Information to aid in pagination."""
  pageInfo: PaginationInfo!
}

type PaginationInfo {
  currentPage: Int!
  perPage: Int!
  pageCount: Int
  itemCount: Int
  hasNextPage: Boolean
  hasPreviousPage: Boolean
}

""""""
input FilterFindManyCharacterInput {
  name: String
  class: EnumCharacterClass
  level: Float
  _id: MongoID
  updatedAt: Date
  createdAt: Date

  """List of *indexed* fields that can be filtered via operators."""
  _operators: FilterFindManyCharacterOperatorsInput
  OR: [FilterFindManyCharacterInput!]
  AND: [FilterFindManyCharacterInput!]
}

"""For performance reason this type contains only *indexed* fields."""
input FilterFindManyCharacterOperatorsInput {
  _id: FilterFindManyCharacter_idOperatorsInput
}

input FilterFindManyCharacter_idOperatorsInput {
  gt: MongoID
  gte: MongoID
  lt: MongoID
  lte: MongoID
  ne: MongoID
  in: [MongoID]
  nin: [MongoID]
  exists: Boolean
}

enum SortFindManyCharacterInput {
  _ID_ASC
  _ID_DESC
}

""""""
input FilterCountCharacterInput {
  name: String
  class: EnumCharacterClass
  level: Float
  _id: MongoID
  updatedAt: Date
  createdAt: Date

  """List of *indexed* fields that can be filtered via operators."""
  _operators: FilterCountCharacterOperatorsInput
  OR: [FilterCountCharacterInput!]
  AND: [FilterCountCharacterInput!]
}

"""For performance reason this type contains only *indexed* fields."""
input FilterCountCharacterOperatorsInput {
  _id: FilterCountCharacter_idOperatorsInput
}

input FilterCountCharacter_idOperatorsInput {
  gt: MongoID
  gte: MongoID
  lt: MongoID
  lte: MongoID
  ne: MongoID
  in: [MongoID]
  nin: [MongoID]
  exists: Boolean
}

type Mutation {
  """
  Create one document with mongoose defaults, setters, hooks and validation
  """
  createCharacter(record: CreateOneCharacterInput!): CreateOneCharacterPayload

  """
  Update one document: 1) Retrieve one document by findById. 2) Apply updates to mongoose document. 3) Mongoose applies defaults, setters, hooks and validation. 4) And save it.
  """
  updateCharacter(_id: MongoID!, record: UpdateByIdCharacterInput!): UpdateByIdCharacterPayload

  """
  Remove one document: 1) Retrieve one document and remove with hooks via findByIdAndRemove. 2) Return removed document.
  """
  removeCharacter(_id: MongoID!): RemoveByIdCharacterPayload
}

type CreateOneCharacterPayload {
  """Document ID"""
  recordId: MongoID

  """Created document"""
  record: Character

  """
  Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
  """
  error: ErrorInterface
}

interface ErrorInterface {
  """Generic error message"""
  message: String
}

""""""
input CreateOneCharacterInput {
  name: String!
  class: EnumCharacterClass!
  level: Float!
  updatedAt: Date
  createdAt: Date
}

type UpdateByIdCharacterPayload {
  """Document ID"""
  recordId: MongoID

  """Updated document"""
  record: Character

  """
  Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
  """
  error: ErrorInterface
}

""""""
input UpdateByIdCharacterInput {
  name: String
  class: EnumCharacterClass
  level: Float
  updatedAt: Date
  createdAt: Date
}

type RemoveByIdCharacterPayload {
  """Document ID"""
  recordId: MongoID

  """Removed document"""
  record: Character

  """
  Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
  """
  error: ErrorInterface
}

バッチリですね。

Step3: 自作のリゾルバ

そうは言ってもどうしてもリゾルバを自作しなくてはならない場合もあると思います。
一般にリゾルバが受け取る引数の中で型を自ら指定する必要があるものは

  • source: 親リゾルバがある場合その戻り値。parentとも
  • args: このクエリの引数
  • context: 全てのリゾルバが共通で用いる値

の3つですが、graphql-composeはこの指定方法が絶妙に分かりづらいです。

まずcontext。コンテキストに型をつける場合はgraphql-composeからschemaComposerではなくSchemaComposerをインポートします。

コンテキストの型は一例
import { SchemaComposer } from "graphql-compose"

interface TContext {
  req: Request
}

const schemaComposer = new SchemaComposer<TContext>()

image.png
(graphql-composeからインポートできるschemaComposerはSchemaComposer<any>型)

そして今作ったschemaComposerのcreateResolverメソッドにsourceとargsの型を渡します。

schemaComposer.Mutation.addFields({
  createRandomCharacter: schemaComposer.createResolver<undefined, { name?: string }>({
    name: "createRandomCharacter",
    type: characterTC,
    args: { name: "String" },
    resolve: async (params): Promise<ICharacter> => {
      const character = new characterModel()
      character.name = params.args.name || Math.random().toString(36).slice(-8)
      character.class = Class[Math.floor(Math.random() * Class.length)]
      character.level = Math.floor(Math.random() * 713 + 1)
      character.save()
      return character
    },
  }),
})

image.png
これでリゾルバが受け取る全ての引数に型がつきました。

Step4: スキーマの書き出し

これはそもそも何でそんなことするの?ってところからなんですが、理由は簡単でGraphQLのエコシステムに全力で乗っかるためです。
世のGraphQLツールは

  • GraphQLスキーマを作るもの
  • GraphQLスキーマを元になんかするもの

に大別されるので、とりあえず.graphqlを作っておく仕組みにしておけば前者と後者の組み合わせを後から自由に入れ替えられるようになります
例えばフロント側が今はvue2だからApolloを使ってるけど将来vue3に移行したらurqlに乗り換えたいな~なんて時はGraphQL Code Generatorのプラグインをtypescript-vue-apolloからtypescript-vue-urqlに変えればいいだけですし、APIはもうGoで作ります!なんていう大胆なリプレイスにも道筋を立てることができます。
なのでどんなツールを使うにせよ作ったスキーマは外部ファイルに書き出しましょう。絶対です。

さて本題ですが、TSで一番簡単なのはインポートしたスキーマをfs.write()するスクリプトをts-nodeに実行してもらう方法かと思います。

// ~/schema.ts
export const schema = schemaComposer.buildSchema()

// ~/sdlgen.ts
import { write } from "fs"
import { printSchema } from "graphql"
import { schema } from "~/schema"

write("Schema.graphql", printSchema(schema))

// package.json
"scripts": {
  "codegen": "ts-node -r tsconfig-paths/register -O {\\\"module\\\":\\\"commonjs\\\"} sdlgen.ts
}

前述のGraphQL Code Generatorでフロント側のコードを生成する場合はこれにお好みのDocument.graphqlとcodegen.ymlを書いてgraphql-codegenするだけですね。

終わりに

誰に型を渡せばいいのか理解した後の開発体験は非常にいいです。GraphQLをやる上で私はあまりSDLを手書きしたくない派なので、そこをほぼほぼスキップできるのは嬉しい。
実際にはStep1のあとにSchemaをカスタマイズするくだりがあってそこがまた色々ややこしかったりするのですが、それについては追々書き足していこうと思います。

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?