概念、想法、策略
概念、想法、策略插件如何将WordPress数据模型映射到GraphQL模式

插件如何将WordPress数据模型映射到GraphQL模式

这是 Gato GraphQL 将 WordPress 数据模型映射到对应 GraphQL 模式的方式。

WordPress 数据模型

WordPress 包含以下实体:

  • posts
  • pages
  • custom posts
  • 媒体元素
  • 用户
  • 用户角色
  • 标签
  • 分类
  • 评论
  • 元属性
  • 其他(选项、插件、主题等)

这些实体可以具有层级关系。例如,post、page 和媒体元素都是自定义文章类型,而标签和分类都是分类法。

以下是 WordPress 数据库图,展示了所有实体的数据存储方式:

WordPress数据库图

映射是数据库图的精确副本吗?

在将 WordPress 数据库映射为 GraphQL 模式时,上述图是否会被一比一地还原?

不,并非如此。数据库图是实际的实现,而 GraphQL 是从客户端访问数据的接口。两者相关,但可以有所不同。GraphQL 不关心数据库:它不以 SQL 命令思考,也不知道存在名为 wp_postswp_users 的数据库表。

因此,在为 WordPress 创建 GraphQL 模式时,无需过多关注数据库图。更进一步,我们还可以生成一个修复 WordPress 数据模型部分技术债务的 GraphQL 模式。

将WordPress数据模型映射为GraphQL模式

让我们来进行映射。首先,尽可能将原始实体映射为类型。从 WordPress 数据模型的实体列表中,为 GraphQL 模式生成以下类型:

  • Post
  • Page
  • Media
  • User
  • UserRole
  • PostTag
  • PostCategory
  • Comment

然后,为每个类型添加所有预期字段。为了表示模式,可以使用 SDL(模式定义语言)。(这仅用于文档目的;插件本身不使用 SDL 来编码模式:全部使用 PHP 代码。)

以下是 Post 的字段(众多字段中的一部分):

type Post {
  id: ID!
  title: String
  content: String
  excerpt: String
  date: Date!
}

以下是 User 的字段(众多字段中的一部分):

type User {
  id: ID!
  name: String
  email: String!
}

我们还创建对应的连接,即返回另一个实体(而非标量,如数字或字符串)的字段。例如,表示文章有作者,以及用户拥有文章:

type Post {
  author: User!
}
 
type User {
  posts: [Post]
}

字段和连接也可以接受参数。例如,使 Post.dateStr 可被格式化,以及使 User.posts 能够过滤条目、限制数量并进行排序:

type Post {
  dateStr(format: String): Date!
}
 
type User {
  posts(
    filter: RootPostsFilterInput
    pagination: PostPaginationInput
    sort: CustomPostSortInput
  ): [Post!]!
}
 
input RootPostsFilterInput {
  authorIDs: [ID!]
  authorSlug: String
  categoryIDs: [ID!]
  dateQuery: [DateQueryInput!]
  excludeAuthorIDs: [ID!]
  excludeIDs: [ID!]
  hasPassword: Boolean = false
  ids: [ID!]
  isSticky: Boolean
  metaQuery: [CustomPostMetaQueryInput!]
  password: String
  search: String
  status: [FilterCustomPostStatusEnum!]
  tagIDs: [ID!]
  tagSlugs: [String!]
}
 
input PostPaginationInput {
  limit: Int
  offset: Int
}
 
input CustomPostSortInput {
  by: CustomPostOrderByEnum
  order: OrderEnum
}
 
# ...

我们对 WordPress 数据模型中的所有实体继续执行此操作。完成后,将得到 WordPress 的 GraphQL 模式,可通过 Voyager 客户端(在插件菜单中作为"Interactive Schema"提供)查看:

WordPress的GraphQL模式

该模式与 WordPress 数据库图有相似之处,但也存在一些差异。让我们来分析它们。

无实体的操作映射为Root字段

WordPress 数据库图表示数据的存储方式,因此没有"起点"。而 GraphQL 是用于检索数据的接口,因此必须有一个执行 Query 的初始阶段。

这个初始阶段就是 Root 类型,更准确地说是 QueryRootMutationRoot 类型(分别用于处理 queries 和 mutations)。

在这两个类型中,我们映射所有不依赖于实体的操作,例如执行 get_posts()get_users()wp_signon() 时:

type QueryRoot {
  posts: [Post]!
  users: [User]!
}
 
type MutationRoot {
  loginUser(
    usernameOrEmail: String!,
    password: String!
  ): User
}

字段不需要与其所代表的操作具有相同的名称或签名。例如,调用字段 loginUser 可能比 signOn 更为合适。

分组模式元素

我们可以应用增强措施来简化模式并使其更易用。例如,字段可以通过一个 input 对象接收所有参数,该对象可在多个字段之间复用,并使模式更易于可视化:

type MutationRoot {
  loginUser(input: LoginUserByInput!): User
}
 
input LoginUserByInput {
    usernameOrEmail: String!,
    password: String!
}

此外,mutation 的响应可以是一个"payload"对象,除了返回受影响的对象外,还可以包含操作状态和错误消息:

type MutationRoot {
  loginUser(input: LoginUserByInput!): RootLoginUserMutationPayload!
}
 
type RootLoginUserMutationPayload {
  errors: [RootLoginUserMutationErrorPayloadUnion!]
  status: OperationStatusEnum!
  user: User
  userID: ID
}
 
union RootLoginUserMutationErrorPayloadUnion = GenericErrorPayload
  | InvalidUserEmailErrorPayload
  | InvalidUsernameErrorPayload
  | PasswordIsIncorrectErrorPayload
  | UserIsLoggedInErrorPayload

所有mutation都位于MutationRoot下

有些操作依赖于特定实体,例如 wp_update_post(),它作用于某篇文章。GraphQL 模式中对应的 mutation 必须添加到 MutationRoot 类型,因为这是 GraphQL 的工作方式。

该操作的映射如下:

type MutationRoot {
  updatePost(input: RootUpdatePostFilterInput!): PostUpdateMutationPayload!
}
 
input RootUpdatePostFilterInput {
  categoryIDs: [ID!]
  content: String
  featuredImageID: ID
  id: ID!
  status: CustomPostStatusEnum
  tags: [String!]
  title: String
}

该插件还支持嵌套 mutations,作为可选功能提供(因为这不是标准 GraphQL 行为)。这样,mutations 也可以添加到任何类型下,而不仅限于 MutationRoot。在这种情况下,我们得到:

type Post {
  update(input: PostUpdateFilterInput!): PostUpdateMutationPayload!
}
 
input PostUpdateFilterInput {
  categoryIDs: [ID!]
  content: String
  featuredImageID: ID
  status: CustomPostStatusEnum
  tags: [String!]
  title: String
}

请注意 RootUpdatePostFilterInputPostUpdateFilterInput 之间的区别(即根级 mutations 与嵌套 mutations 之间的区别):前者有必填属性 id 以指示要修改哪篇文章,而后者则不需要该属性。

处理custom posts

GraphQL 中没有类型继承。因此,我们无法定义 CustomPost 类型,并声明 PostPage 继承自它。

GraphQL 提供了两种资源来弥补这一不足:接口和联合类型。

对于第一种资源,我们在模式中创建一个 CustomPost 接口,声明 custom post 所期望的所有字段,并定义 PostPageGenericCustomPost(表示已安装的主题和插件定义的所有 custom post 类型)来实现该接口:

interface CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Post implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Page implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type GenericCustomPost implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}

对于第二种资源,我们在模式中创建一个 CustomPostUnion 类型,返回所有 custom post 类型:

union CustomPostUnion = Post | Page | GenericCustomPost

并在适当的情况下让字段返回此类型:

type QueryRoot {
  customPost(id: ID): CustomPostUnion
  customPosts: [CustomPostUnion]!
}
 
type User {
  customPosts: [CustomPostUnion]
}
 
type Comment {
  customPost: CustomPostUnion!
}

在执行 Query 时,可以根据实际类型(如 Post)或 CustomPost 接口来选择字段:

{  
  customPosts {
    __typename
    ...on CustomPost {
      id
      title
      slug
      status
    }
    ...on Post {
      isSticky
      postFormat
    }
  }
}

可以看出,在 GraphQL 模式中需要明确区分处理的是 posts 还是 custom posts,因为两者并不相同!将这两者互换使用是 WordPress 的技术债务,插件会尽可能地修复这一问题。

因此,custom post 始终被称为 CustomPost 而非 Post,处理 custom posts 的字段始终被称为 customPosts 而非 posts,接收 custom post ID 的字段参数被称为 customPostID 而非 postID(即使在映射的 WordPress 函数中就是这么叫的)。

这样,期望始终清晰明确:

  • 字段 User.customPosts 可以返回任意 custom post 的列表,包括 posts 和 pages,而 User.posts 只返回 posts
  • 字段 Root.setFeaturedImageOnCustomPost 可以为任意 custom post 添加特色图片,这就是为什么它不叫 setFeaturedImageOnPost

不将标签(和分类)归入单一类型

为什么 PostTag 类型(PostCategory 同理)要这样命名,而不是直接叫 Tag

因为在执行以下 Query(其中 product 是 CPT)时,posts 和 products 的 tags 字段结果将始终不同且不重叠:

query {
  posts {
    tags {
      id
      name
    }
  }
  products {
    tags {
      id
      name
    }
  }
}

添加到 posts 的标签在获取 products 的标签时不会显示,反之亦然(除非 product 也使用 post_tag 分类法,但即便如此,也可以用 PostTag 类型表示)。这在 WordPress 中并不是大问题,因为这些条目可以被视为同一数据库表中的不同行。但对于强类型的 GraphQL 来说,这一点很重要。

因此,将这些实体分别保存在各自的类型下是一个良好的设计决策,posts 的标签在 PostTag 类型下返回,如果自定义插件实现了自己的 product CPT,则其标签必须使用 ProductTag 类型。

赋予媒体项独立的身份

WordPress 中的媒体实体是 custom post 类型,仅仅是因为这在实现上比较方便。然而,GraphQL 模式可以避免这一技术债务,将媒体元素建模为独立的实体,而非 custom posts。

这对 GraphQL 模式意味着以下决策:

  • Media 类型不实现 CustomPost 接口,也不会成为 CustomPostUnion 类型的一部分
  • Media 类型没有 custom post 类型所期望的许多字段,例如 excerptdatestatus。相反,它只有媒体元素所期望的字段:
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

识别和映射枚举

在某些情况下,WordPress 使用来自特定集合的固定值。例如,文章的状态只能是 "publish""draft""pending""trash"

在 GraphQL 中,我们可以将这些值视为枚举(而非字符串),并创建对应的枚举类型。遵循 GraphQL 标准,枚举应使用大写字母,如下所示:

enum CUSTOM_POST_STATUS {
  PUBLISH
  DRAFT
  PENDING
  TRASH
}

然而,这样一来,Query 就无法直接用于与 WordPress 交互,因为执行 get_posts( [ "post_status" => "PUBLISH" ] ) 不会生效。

因此,作为折中方案,我们保持这些枚举值为小写:

enum CUSTOM_POST_STATUS {
  publish
  draft
  pending
  trash
}

映射额外类型

块在 WordPress 数据库图中不直接可见,因为它们存储在 wp_posts 中(不存在 wp_blocks 表),但它们仍然是独立的实体。

因此,我们仍可以引入 Block 类型来映射它们:

type Post {
  blocks: [Block]
}
 
type Block {
  type: String!
  attributes: JSONObject
}