👶🏻 Омоложение WordPress с помощью GraphQL
WordPress — это унаследованная CMS: созданная более 17 лет назад, она наполнена PHP-кодом, который при новой возможности был бы написан иначе.
GraphQL — это современный интерфейс для доступа к данным. Обратите внимание на слово «интерфейс»: ему не важно, как реализована базовая система данных, — важно лишь то, как данные предоставляются.
Что происходит, когда мы объединяем эти два инструмента? Как следует проектировать интерфейс GraphQL для доступа к данным WordPress?
Существует несколько очевидных стратегий:
-
Уважать традицию и создать маппинг, сохраняющий модель данных WordPress как есть, включая накопленный за годы технический долг
-
Устранить технический долг, предоставив интерфейс, открывающий данные в абстрактном виде, не обязательно привязанном к WordPress
Оба подхода имеют преимущества и недостатки, и ни один из них не является единственно верным. Это просто вопрос приоритетов: одно поведение предпочитается другому.
Для плагина Gato GraphQL я выбрал второй подход — попытался создать схему GraphQL, которая, хотя и основана на WordPress и работает для WordPress, не привязана к нему (например, за счёт устранения непоследовательных названий и связей).
Результат таков, что GraphQL омолаживает WordPress: хотя WordPress по-прежнему остаётся нашей базовой CMS со своим унаследованным PHP-кодом, его слой данных можно создать заново — на основе здравого смысла, а не традиции. Слой данных возвращается из подросткового возраста обратно в детство.

Результатом является схема GraphQL, представляющая модель данных WordPress и также поддерживающая nested mutations.
Давайте разберём, как это было реализовано.
Модель данных WordPress
WordPress имеет следующие сущности:
- posts
- страницы
- custom posts
- медиаэлементы
- пользователи
- роли пользователей
- tags
- категории
- комментарии
- блоки
- мета-свойства
- прочее (опции, плагины, темы и т.д.)
Эти сущности могут образовывать иерархию. Например, post, страница и медиаэлементы — все они являются custom post types, а tags и категории — таксономиями.
Вот диаграмма базы данных 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
publishedAt: Date!
}Вот поля (среди многих других) для User:
type User {
id: ID!
name: String
email: String!
}Мы также создаём соответствующие связи — это поля, возвращающие другую сущность (а не скаляр, такой как число или строка). Например, мы представляем, что post имеет автора, а пользователь владеет posts:
type Post {
author: User!
}
type User {
posts: [Post]
}Поля и связи также могут принимать аргументы. Например, мы разрешаем форматировать Post.date и задаём для User.posts возможность поиска записей и ограничения их количества:
type Post {
date(format: String): Date!
}
type User {
posts(limit: Int, search: String): [Post]
}Мы продолжаем делать это для всех сущностей модели данных WordPress. По завершении мы получим схему GraphQL для WordPress, видимую с помощью клиента Voyager (доступного как «Interactive Schema» в меню плагина):

Эта схема имеет сходства с диаграммой базы данных WordPress, но также и множество отличий. Проанализируем их.
Операции без сущности маппируются как поля Root
Диаграмма базы данных WordPress отображает то, как хранятся данные, поэтому у неё нет «начала». GraphQL же является интерфейсом для получения данных, поэтому должна существовать начальная точка, из которой выполняется запрос.
Этой начальной точкой является тип Root, или, точнее, типы QueryRoot и MutationRoot (для работы с queries и mutations соответственно).
В этих двух типах мы маппируем все операции, не зависящие от сущности, например при выполнении get_posts(), get_users() или wp_signon():
type QueryRoot {
posts: [Post]!
users: [User]!
}
type MutationRoot {
logUserIn(username: String, password: String): User
}Полям не обязательно иметь то же имя или сигнатуру, что и у операции, которую они представляют. Например, поле logUserIn может считаться более подходящим, чем signOn.
Все mutations размещаются под MutationRoot
Существуют операции, которые зависят от сущности, например wp_update_post(), применяемая к определённому post. Соответствующая mutation в схеме GraphQL должна быть добавлена к типу MutationRoot, поскольку именно так работает GraphQL.
Тогда эта операция маппируется следующим образом:
type MutationRoot {
updatePost(input: {
postID: ID!,
newTitle: String,
newContent: String
}): Post
}Этот плагин также поддерживает nested mutations, которые предлагаются как опциональная функция (поскольку это не стандартное поведение GraphQL). Тогда mutations могут добавляться под любым типом, а не только под MutationRoot. В этом случае мы получаем:
type Post {
update(input: {
newTitle: String,
newContent: String
}): Post!
}Работа с custom posts
В GraphQL нет наследования типов. Поэтому мы не можем иметь тип CustomPost и объявить, что Post и Page расширяют его.
GraphQL предлагает два инструмента для компенсации этого ограничения: интерфейсы и union types.
Для первого варианта мы создаём интерфейс CustomPost для схемы, объявляя все поля, ожидаемые от custom post, и определяем типы Post и Page для реализации этого интерфейса:
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!
}Для второго варианта мы создаём тип CustomPostUnion для схемы, возвращающий все custom post types:
union CustomPostUnion = Post | PageИ заставляем поля возвращать этот тип там, где это уместно:
type QueryRoot {
customPost(id: ID): CustomPostUnion
customPosts: [CustomPostUnion]!
}
type User {
customPosts: [CustomPostUnion]
}
type Comment {
customPost: CustomPostUnion!
}Как можно заметить, в схеме GraphQL нам необходимо явно указывать, когда мы работаем с posts, а когда с custom posts, поскольку это разные вещи! Использование этих понятий как взаимозаменяемых — технический долг WordPress, который мы можем устранить.
По этой причине custom post всегда называется CustomPost, а не Post, поле, работающее с custom posts, всегда называется customPosts, а не posts, а аргумент поля, принимающий ID custom post, называется customPostID, а не postID (хотя именно так он называется в маппируемой функции WordPress).
Тогда ожидания всегда чёткие:
- поле
User.customPostsможет возвращать список любых custom post, включая posts и страницы, аUser.postsвозвращает только posts - поле
Root.setFeaturedImageOnCustomPostможет добавлять изображение-обложку к любому custom post, поэтому оно не называетсяsetFeaturedImageOnPost
Не группировать tags (и категории) под одним типом
Почему тип PostTag (и аналогично PostCategory) называется именно так, а не просто Tag?
Потому что при выполнении этого запроса (где product — это CPT) результаты поля tags для posts и products всегда будут разными, непересекающимися:
query {
posts {
tags {
id
name
}
}
products {
tags {
id
name
}
}
}Tags, добавленные к posts, не будут отображаться при получении tags для products, и наоборот (если только product не использует также таксономию post_tag, но тогда он тоже может быть представлен типом PostTag). В WordPress это не является большой проблемой, поскольку эти элементы можно считать разными строками одной таблицы базы данных. Но для GraphQL, который является строго типизированным, это важно.
Поэтому хорошим решением дизайна является разделение этих сущностей под их собственными типами: tags для posts возвращаются под типом PostTag, а если пользовательский плагин реализует собственный CPT продукта, он должен использовать тип ProductTag для своих tags.
Предоставление медиаэлементам собственной идентичности
Медиасущности в WordPress являются custom post types лишь потому, что это было удобно с точки зрения реализации. Однако схема GraphQL может избежать этого технического долга и смоделировать медиаэлементы как отдельную сущность, а не как custom posts.
Это влечёт за собой следующие решения для схемы GraphQL:
- При запросе поля
customPostsмедиаэлементы не будут получены - Тип
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 мы можем рассматривать их как перечисления (вместо строк) и создавать соответствующий тип перечисления. Следуя стандарту 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
}