Концепции, идеи, стратегии
Концепции, идеи, стратегииКак плагин отображает модель данных WordPress в схему GraphQL

Как плагин отображает модель данных WordPress в схему GraphQL

Вот как Gato GraphQL отобразил модель данных WordPress в соответствующую схему GraphQL.

Модель данных WordPress

WordPress имеет следующие сущности:

  • posts
  • страницы
  • custom posts
  • медиаэлементы
  • пользователи
  • роли пользователей
  • теги
  • категории
  • комментарии
  • блоки
  • мета-свойства
  • прочее (параметры, плагины, темы и т.д.)

Эти сущности могут иметь иерархию. Например, post, страница и медиаэлементы являются custom post types, а теги и категории — таксономиями.

Это диаграмма базы данных WordPress, показывающая, как хранятся данные для всех сущностей:

Диаграмма базы данных WordPress

Является ли отображение точной копией диаграммы БД?

При отображении базы данных WordPress в схему GraphQL соблюдается ли приведённая выше диаграмма один к одному?

Нет, не соблюдается. Хотя диаграмма базы данных — это реальная реализация, GraphQL является интерфейсом для доступа к данным со стороны клиента. Эти два аспекта связаны, но могут различаться. GraphQL не заботится о базе данных: он не думает в терминах SQL-команд и не знает, что существуют таблицы wp_posts и wp_users.

Поэтому при создании схемы GraphQL для WordPress не нужно слишком беспокоиться о диаграмме базы данных. Более того, можно создать схему GraphQL, устраняющую часть технического долга модели данных WordPress.

Отображение модели данных WordPress в схему GraphQL

Выполним отображение. Сначала мы по возможности отображаем исходные сущности как типы. Из списка сущностей модели данных WordPress получаем следующие типы для схемы GraphQL:

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

Затем добавляем все ожидаемые поля к каждому типу. Для представления схемы можно использовать 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» в меню плагина):

Схема GraphQL для WordPress

Эта схема имеет сходства с диаграммой базы данных 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
}