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

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

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

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

そこで当記事ではこれらgraphql-compose、graphql-compose-mongoose、(前提として)mongooseをTypeScriptで適切に扱う方法を解説していきます。基本的には
- これ → [TypeScript Support - mongoose](https://mongoosejs.com/docs/typescript.html) と
- これ → [An full example using TypeScript with mongoose - graphql-compose-mongoose](https://github.com/graphql-compose/graphql-compose-mongoose/blob/alpha/docs/releases/9.0.0.md#an-full-example-using-typescript-with-mongoose-graphql-compose-mongoose-and-a-new-dataloader-resolver)

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

# 執筆時のバージョン
```
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オブジェクト](https://graphql.org/graphql-js/type/#graphqlschema)を生成する
- Modelにつけた型をもとにリゾルバを自作する
- 生成されたGraphQLSchemaオブジェクトを.graphqlファイルに書き出す

# 解説
## Step1: SchemaおよびModel(mongoose)
```typescript
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**](https://www.kabuku.co.jp/developers/good-bye-typescript-enum)

次にこの型を元にSchemaを作ります。公式ドキュメントではこのように
```typescript
const characterSchema = new Schema<Character & Document>({
  name: {
    type: String,
    required: true,
  }
  ...
})
```
直接Schemaのコンストラクタにオブジェクトを渡す方式を取っていますが、この方法はスキーマがCharacterインターフェースを満たしているかどうかを確認しないため
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/4154f619-fddb-94fb-0e82-04b224730cf3.png)
(classとlevel属性が欠落しているにも関わらずエラーが出ない)

このように一度SchemaDefinition型のオブジェクトを作成してから`new Schema()`することをおすすめします。
```typescript
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を作っておしまいです。
```typescript
import { Model, models } from "mongoose"

const characterModel: Model<ICharacter> =
  models.Character || model("Character", characterSchema, "characters")
```
なおドキュメント通り
```typescript
const characterModel = model("Character", characterSchema, "characters")
```
このように書くとホットリロード時にモデルを再定義しようとして怒られてしまうので注意が必要です。

## Step2: 組み込みのリゾルバおよびGraphQLSchema
前述の通りgraphql-compose-mongooseには基本的な機能を持つリゾルバをModelの定義から生成してくれる機能があります。ページネーションとかあるのが個人的一押しポイント。
用意されているリゾルバの一覧は[**こちら**](https://graphql-compose.github.io/docs/plugins/plugin-mongoose.html#resolvers-customization-options)に記載があります。

```typescript
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スキーマの中身を見てみましょう。

```typescript
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`をインポートします。

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

interface TContext {
  req: Request
}

const schemaComposer = new SchemaComposer<TContext>()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/b365a09b-62ed-e368-5594-0306c870aa6b.png)
(graphql-composeからインポートできるschemaComposerは`SchemaComposer<any>`型)

そして今作ったschemaComposerの`createResolver`メソッドにsourceとargsの型を渡します。
```typescript
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245527/64bc401d-4066-3bbb-df61-17c584711565.png)
これでリゾルバが受け取る全ての引数に型がつきました。

## Step4: スキーマの書き出し
これはそもそも何でそんなことするの？ってところからなんですが、理由は簡単で**GraphQLのエコシステムに全力で乗っかるため**です。
世のGraphQLツールは
- GraphQLスキーマを作るもの
- GraphQLスキーマを元になんかするもの

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

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

```typescript
// ~/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をカスタマイズするくだりがあってそこがまた色々ややこしかったりするのですが、それについては追々書き足していこうと思います。