Как плагин отображает модель данных WordPress в схему GraphQL
Вот как Gato GraphQL отобразил модель данных WordPress в соответствующую схему GraphQL.
Модель данных WordPress
WordPress имеет следующие сущности:
- posts
- страницы
- custom posts
- медиаэлементы
- пользователи
- роли пользователей
- теги
- категории
- комментарии
- блоки
- мета-свойства
- прочее (параметры, плагины, темы и т.д.)
Эти сущности могут иметь иерархию. Например, post, страница и медиаэлементы являются custom post types, а теги и категории — таксономиями.
Это диаграмма базы данных WordPress, показывающая, как хранятся данные для всех сущностей:

Является ли отображение точной копией диаграммы БД?
При отображении базы данных WordPress в схему GraphQL соблюдается ли приведённая выше диаграмма один к одному?
Нет, не соблюдается. Хотя диаграмма базы данных — это реальная реализация, GraphQL является интерфейсом для доступа к данным со стороны клиента. Эти два аспекта связаны, но могут различаться. GraphQL не заботится о базе данных: он не думает в терминах SQL-команд и не знает, что существуют таблицы wp_posts и wp_users.
Поэтому при создании схемы GraphQL для WordPress не нужно слишком беспокоиться о диаграмме базы данных. Более того, можно создать схему GraphQL, устраняющую часть технического долга модели данных WordPress.
Отображение модели данных WordPress в схему GraphQL
Выполним отображение. Сначала мы по возможности отображаем исходные сущности как типы. Из списка сущностей модели данных WordPress получаем следующие типы для схемы GraphQL:
PostPageMediaUserUserRolePostTagPostCategoryComment
Затем добавляем все ожидаемые поля к каждому типу. Для представления схемы можно использовать SDL, или Schema Definition Language. (Это используется только в документационных целях; сам плагин не использует SDL для кодирования схемы — всё написано на PHP).
Вот поля (среди многих других) для Post:
type Post {
id: ID!
title: String
content: String
excerpt: String
date: Date!
}Вот поля (среди многих других) для User:
type User {
id: ID!
name: String
email: String!
}Мы также создаём соответствующие связи — поля, возвращающие другую сущность (а не скаляр, такой как число или строка). Например, представляем наличие автора у post и принадлежность posts пользователю:
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. Когда закончим, получим схему GraphQL для WordPress, которую можно просмотреть с помощью клиента Voyager (доступен как «Interactive Schema» в меню плагина):

Эта схема имеет сходства с диаграммой базы данных WordPress, но также имеет ряд отличий. Разберём их.
Операции без сущности отображаются как поля Root
Диаграмма базы данных WordPress показывает, как хранятся данные, поэтому у неё нет «начала». GraphQL же является интерфейсом для получения данных, поэтому должна существовать начальная точка, из которой выполняется запрос.
Эта начальная точка — тип Root, или, точнее, типы QueryRoot и MutationRoot (для работы с запросами и мутациями соответственно).
В этих двух типах мы отображаем все операции, которые не зависят от конкретной сущности, например при вызове 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!
}Кроме того, ответ мутации может быть объектом «payload», который помимо возврата затронутого объекта может также содержать статус операции и сообщения об ошибках:
type MutationRoot {
loginUser(input: LoginUserByInput!): RootLoginUserMutationPayload!
}
type RootLoginUserMutationPayload {
errors: [RootLoginUserMutationErrorPayloadUnion!]
status: OperationStatusEnum!
user: User
userID: ID
}
union RootLoginUserMutationErrorPayloadUnion = GenericErrorPayload
| InvalidUserEmailErrorPayload
| InvalidUsernameErrorPayload
| PasswordIsIncorrectErrorPayload
| UserIsLoggedInErrorPayloadВсе мутации размещаются под MutationRoot
Существуют операции, которые зависят от конкретной сущности, например wp_update_post(), применяемая к некоторому post. Соответствующая мутация в схеме GraphQL должна быть добавлена к типу MutationRoot, поскольку именно так работает GraphQL.
Эта операция отображается следующим образом:
type MutationRoot {
updatePost(input: RootUpdatePostFilterInput!): PostUpdateMutationPayload!
}
input RootUpdatePostFilterInput {
categoryIDs: [ID!]
content: String
featuredImageID: ID
id: ID!
status: CustomPostStatusEnum
tags: [String!]
title: String
}Плагин также поддерживает вложенные мутации, предлагаемые как opt-in функция (поскольку это не стандартное поведение GraphQL). В таком случае мутации можно добавлять под любой тип, не только под MutationRoot. В этом случае получаем:
type Post {
update(input: PostUpdateFilterInput!): PostUpdateMutationPayload!
}
input PostUpdateFilterInput {
categoryIDs: [ID!]
content: String
featuredImageID: ID
status: CustomPostStatusEnum
tags: [String!]
title: String
}Обратите внимание на различие между inputs RootUpdatePostFilterInput и PostUpdateFilterInput (то есть между мутациями из корня и вложенными мутациями): первый имеет обязательное свойство id для указания, какой post изменить, тогда как второй — нет, так как оно ему не нужно.
Работа с custom posts
В GraphQL нет наследования типов. Поэтому мы не можем иметь тип CustomPost и объявить, что Post и Page его расширяют.
GraphQL предлагает два инструмента для компенсации этого ограничения: интерфейсы и union types.
Для первого мы создаём интерфейс CustomPost в схеме, объявляя все поля, ожидаемые от custom post, и определяем типы Post, Page и GenericCustomPost (для представления всех custom post types, определённых любой установленной темой и плагином), реализующие этот интерфейс:
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 types:
union CustomPostUnion = Post | Page | GenericCustomPostИ задаём полям возврат этого типа там, где это уместно:
type QueryRoot {
customPost(id: ID): CustomPostUnion
customPosts: [CustomPostUnion]!
}
type User {
customPosts: [CustomPostUnion]
}
type Comment {
customPost: CustomPostUnion!
}При выполнении запроса мы можем выбирать поля на основе фактического типа, например 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; аргумент поля, принимающий ID custom post, называется customPostID, а не postID (даже если именно так называется соответствующая функция WordPress).
Тогда ожидание всегда понятно:
- Поле
User.customPostsможет возвращать список любых custom posts, включая posts и страницы, аUser.postsвозвращает только posts - Поле
Root.setFeaturedImageOnCustomPostможет добавлять миниатюру к любому custom post — именно поэтому оно не называетсяsetFeaturedImageOnPost
Отказ от группировки тегов (и категорий) под единым типом
Почему тип PostTag (и то же самое для PostCategory) называется именно так, а не просто Tag?
Потому что при выполнении данного запроса (где продукт является CPT) результаты поля tags для posts и продуктов всегда будут разными, непересекающимися:
query {
posts {
tags {
id
name
}
}
products {
tags {
id
name
}
}
}Теги, добавленные к posts, не будут отображаться при получении тегов для продуктов, и наоборот (если только продукт также не использует таксономию post_tag, но тогда он тоже может быть представлен типом PostTag). Это не является большой проблемой в WordPress, поскольку эти элементы можно считать разными строками одной таблицы базы данных. Но для GraphQL это важно, поскольку GraphQL строго типизирован.
Таким образом, хорошим дизайнерским решением является хранение этих сущностей отдельно, в собственных типах: теги для posts возвращаются под типом PostTag, а если пользовательский плагин реализует собственный CPT продукта, он должен использовать тип ProductTag для своих тегов.
Предоставление медиаэлементам собственной идентичности
Медиасущности в WordPress являются custom post types лишь потому, что это было удобно с точки зрения реализации. Однако схема GraphQL может избежать этого технического долга и смоделировать медиаэлементы как отдельную сущность, а не как custom posts.
Это влечёт следующие решения для схемы GraphQL:
- Тип
Mediaне реализует интерфейсCustomPostи не является частью типаCustomPostUnion - Тип
Mediaне имеет многих полей, ожидаемых от custom post type, таких какexcerpt,dateиstatus. Вместо этого он содержит только поля, ожидаемые от медиаэлемента:
type Media {
id: ID!
src: String!
width: Int
height: Int
}Определение и отображение перечислений
В некоторых ситуациях WordPress использует фиксированные значения из заданного набора. Например, статус post может быть только "publish", "draft", "pending" или "trash".
В GraphQL их можно рассматривать как перечисления (enums) вместо строк и создать соответствующий тип перечисления. Следуя стандарту GraphQL, значения перечислений должны быть записаны в верхнем регистре:
enum CUSTOM_POST_STATUS {
PUBLISH
DRAFT
PENDING
TRASH
}Однако в таком случае запрос нельзя напрямую использовать для взаимодействия с 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
}