個人的にMongoDB(ドキュメント指向DB)とGraphQLの相性がいいなと思うのはいわゆる「DBのテーブル構造をそのままGraphQLのスキーマにするべきではない」という設計上の面倒ポイントをある程度軽減できるところです。GraphQLのスキーマ設計におけるアンチパターンはそのままドキュメントのスキーマ設計アンチパターンであったりするので、ドキュメントのスキーマを正しく(というかGraphQLで接続することを前提として)設計できていればそのままGraphQLのスキーマに流用してしまってもまあよかろうという考え方ですね。
さて、graphql-composeというツールキットがあります。これはGraphQLのスキーマをSDLではなくJSで書くというコンセプト(コードファースト)のものなのですが、プラグインとしてmongooseのModelオブジェクトからGraphQLスキーマを生成する機能が提供されています。ついでにCRUD系のシンプルなリゾルバも勝手に作ってくれるので、キチンと使えばコーディング量がめちゃくちゃ減って大助かりです。
しかしその「キチンと使う」が中々厄介なところで、具体的に言うと型付けが面倒なんですよね。これはもう様々な事情でしょうがないことではあるのですが…。
そこで当記事ではこれらgraphql-compose、graphql-compose-mongoose、(前提として)mongooseをTypeScriptで適切に扱う方法を解説していきます。基本的には
- これ → TypeScript Support - mongoose と
- これ → An full example using TypeScript with mongoose - graphql-compose-mongoose
をベースに話を進めるため、未読の方はぜひご一読ください。
執筆時のバージョン
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インターフェースを満たしているかどうかを確認しないため
(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>()
(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
},
}),
})
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をカスタマイズするくだりがあってそこがまた色々ややこしかったりするのですが、それについては追々書き足していこうと思います。