使用TypeScript根据MongoDB(mongoose)的模式生成GraphQL的解析器和模式

从个人角度来看,我认为MongoDB(面向文档的数据库)和GraphQL的兼容性很好,主要是因为可以在一定程度上减轻所谓的“应将数据库表结构直接转化为GraphQL模式”的设计上的复杂点。在GraphQL模式设计中,反模式与文档模式设计的反模式基本相同,因此,如果能够正确设计文档模式(或者更准确地说是在GraphQL中连接),就可以直接重用到GraphQL模式中,这种想法还算可以接受吧。

现在有一个叫作graphql-compose的工具包。它的概念是使用JavaScript而不是SDL来编写GraphQL模式(代码优先),并提供了一种从mongoose的Model对象中生成GraphQL模式的功能作为插件。此外,它还会自动创建简单的CRUD解析器,因此如果能正确使用它,编码量会大大减少,非常方便。

不过,“好好使用”这一点非常麻烦,具体说来就是类型标记麻烦。这是种种原因造成的情况,不得已而为之呢。

所以在本文中,我们将解释如何在TypeScript中正确使用这些库,包括graphql-compose、graphql-compose-mongoose和mongoose。基本上,

    • これ → 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ファイルに書き出す

解說

步骤1:模式及模型(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]

请准备一个基本类型。由于将枚举定义为枚举会导致麻烦,所以请参考以下步骤进行定义。
参考:再见,TypeScript枚举。

下一个步骤是根据这个模型创建模式。在官方文档中是这样描述的。

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

建议您先创建一个 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创建模型,就这样结束了。

import { Model, models } from "mongoose"

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

依照文件所述。

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

必须注意,在这样的写法下,在热重载时会尝试重新定义模型,可能会引发错误。

步骤2:嵌入式解析器和GraphQL模式

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
}

太棒了,您做得非常出色。

步骤3:自己创建的解析器

我认为有时候必须自己制作解析器,即使如此。
一般来说,在解析器接收的参数中,有些必须自己指定类型。

    • source: 親リゾルバがある場合その戻り値。parentとも

 

    • args: このクエリの引数

 

    context: 全てのリゾルバが共通で用いる値

以下是三个选项,但是graphql-compose对于这种指定方式非常晦涩难懂。

首先是背景。如果给上下文中的类型加上schemaComposer,则应该从graphql-compose中导入SchemaComposer,而不是schemaComposer。

import { SchemaComposer } from "graphql-compose"

interface TContext {
  req: Request
}

const schemaComposer = new SchemaComposer<TContext>()
image.png

然后将source和args的类型传递给我刚创建的schemaComposer的createResolver方法。

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

步骤4:导出模式

為什麼你要做這樣的事情呢?原因很簡單,就是為了全力搭乘GraphQL生態系統的便利性。
现有的GraphQL工具都

    • GraphQLスキーマを作るもの

 

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

由于可以根据需要随意组合,所以可以先创建一个.graphql的机制,以便后期可以自由地互换前者和后者的组合。
例如,前端现在使用vue2,所以使用Apollo,但是如果将来转换到vue3后想转换到urql,只需将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,所以能几乎跳过那一部分真是太好了。
实际上,在第一步之后还要对模式进行自定义,这一部分有时候会有些复杂,但我打算以后再加上这部分的内容。

广告
将在 10 秒后关闭
bannerAds