コンセプト、アイデア、戦略
コンセプト、アイデア、戦略プラグインがWordPressデータモデルをGraphQLスキーマにマッピングする方法

プラグインがWordPressデータモデルをGraphQLスキーマにマッピングする方法

これは、Gato GraphQLがWordPressデータモデルを対応するGraphQLスキーマにマッピングした方法です。

WordPressのデータモデル

WordPressには次のエンティティがあります:

  • posts
  • pages
  • custom posts
  • メディア要素
  • ユーザー
  • ユーザーロール
  • タグ
  • カテゴリー
  • コメント
  • ブロック
  • メタプロパティ
  • その他(オプション、プラグイン、テーマなど)

これらのエンティティは階層を持つことができます。たとえば、post、page、メディア要素はいずれもカスタム投稿タイプであり、タグとカテゴリーはどちらもタクソノミーです。

以下はWordPressのデータベースダイアグラムで、すべてのエンティティのデータがどのように格納されているかを示しています:

WordPressのデータベースダイアグラム

マッピングはDBダイアグラムの完全な複製ですか?

WordPressデータベースをGraphQLスキーマにマッピングする際、上記と同じダイアグラムが1対1で維持されるのでしょうか?

いいえ、そうではありません。データベースダイアグラムは実際の実装ですが、GraphQLはクライアントからデータにアクセスするためのインターフェイスです。この2つは関連していますが、異なる場合があります。GraphQLはデータベースを意識しません:SQLコマンドで考えず、wp_postswp_usersといったデータベーステーブルの存在も知りません。

そのため、WordPressのGraphQLスキーマを作成する際に、データベースダイアグラムをあまり気にする必要はありません。さらに言えば、WordPressデータモデルの技術的負債の一部を修正するGraphQLスキーマを作成することも可能です。

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!
}

また、対応するコネクションも作成します。コネクションとは、スカラー(数値や文字列など)の代わりに別のエンティティを返すフィールドです。たとえば、投稿が著者を持ち、ユーザーが投稿を所有することを表現します:

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はデータを取得するためのインターフェイスであるため、クエリを実行する初期段階が必要です。

この初期段階がRoot型、より正確にはQueryRoot型とMutationRoot型(それぞれクエリとミューテーションを扱うため)です。

これらの2つの型において、get_posts()get_users()wp_signon()を実行する際のように、エンティティに依存しないすべての操作をマッピングします:

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

フィールドは、表現する操作と同じ名前やシグネチャを持つ必要はありません。たとえば、フィールドloginUsersignOnよりも適切と考えることができます。

スキーマ要素のグループ化

スキーマを簡素化してより使いやすくするための改善を加えることができます。たとえば、フィールドはすべての引数を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()のように、特定のエンティティに依存する操作があります。これはある投稿に対して適用されます。GraphQLの仕様上、対応するミューテーションはGraphQLスキーマのMutationRoot型に追加する必要があります。

この操作は次のようにマッピングされます:

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

このプラグインはネストされたミューテーションもサポートしており、オプトイン機能として提供されています(標準的なGraphQLの動作ではないため)。その場合、ミューテーションはMutationRootだけでなく、任意の型の下に追加することもできます。この場合、次のようになります:

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

RootUpdatePostFilterInputPostUpdateFilterInput(つまり、ルートからのミューテーションとネストされたミューテーション)の違いに注目してください:前者には、どの投稿を変更するかを示す必須プロパティidがありますが、後者にはそれが不要なため含まれていません。

カスタム投稿への対応

GraphQLには型の継承がありません。そのため、CustomPost型を持ち、PostPageがそれを拡張するといったことはできません。

GraphQLはこの欠如を補うために2つのリソースを提供しています:インターフェイスとユニオン型です。

最初のリソースとして、スキーマにCustomPostインターフェイスを作成し、カスタム投稿に期待されるすべてのフィールドを宣言します。そして、インターフェイスを実装する型としてPostPageGenericCustomPost(インストールされているテーマやプラグインによって定義されたすべてのカスタム投稿タイプを表す)を定義します:

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!
}

2つ目のリソースとして、すべてのカスタム投稿タイプを返すCustomPostUnion型をスキーマに作成します:

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スキーマでは投稿を扱っているのかカスタム投稿を扱っているのかを明示的に示す必要があります。この2つは同じではないからです!これらを互換的に呼ぶことはWordPressの技術的負債であり、プラグインは可能な限りこれを修正しようとしています。

このため、カスタム投稿は常にPostではなくCustomPostと呼ばれ、カスタム投稿を扱うフィールドは常にpostsではなくcustomPostsと呼ばれ、カスタム投稿のIDを受け取るフィールド引数は(マッピングされたWordPress関数でそう呼ばれていても)postIDではなくcustomPostIDと呼ばれます。

これにより、期待されることが常に明確になります:

  • フィールドUser.customPostsはpostやpageを含む任意のカスタム投稿のリストを返すことができ、User.postsはpostのみを返します
  • フィールドRoot.setFeaturedImageOnCustomPostは任意のカスタム投稿にアイキャッチ画像を追加できます。そのためsetFeaturedImageOnPostとは呼ばれません

タグ(およびカテゴリー)を単一の型にグループ化しない理由

なぜPostTag型(PostCategoryも同様)は単にTagではなくそのような名前なのでしょうか?

それは、このクエリを実行した場合(productがCPTの場合)、postとproductのtagsフィールドの結果は常に異なり、重複しないからです:

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

postに追加されたタグは、productのタグを取得する際に表示されません(逆も同様です)(ただし、productがpost_tagタクソノミーも使用している場合は例外ですが、その場合でもPostTag型で表現できます)。これはWordPressではあまり問題になりません。なぜなら、これらのアイテムは同じデータベーステーブルの異なる行と見なせるからです。しかし、強い型付けを持つGraphQLでは重要です。

したがって、これらのエンティティを独自の型の下に分けておくことが良い設計上の決断です。postのタグはPostTag型で返し、カスタムプラグインが独自のproduct CPTを実装している場合は、そのタグにProductTag型を使用する必要があります。

メディアアイテムに固有のアイデンティティを与える

WordPressのメディアエンティティは実装上の都合からカスタム投稿タイプになっています。しかし、GraphQLスキーマではこの技術的負債を回避し、メディア要素をカスタム投稿ではなく独立したエンティティとしてモデリングできます。

これはGraphQLスキーマに次のような決定をもたらします:

  • Media型はCustomPostインターフェイスを実装せず、CustomPostUnion型の一部にもなりません
  • Media型はexcerptdatestatusなどのカスタム投稿タイプに期待される多くのフィールドを持ちません。代わりに、メディア要素に期待されるフィールドのみを持ちます:
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

enumの識別とマッピング

状況によっては、WordPressは特定のセットから固定値を使用します。たとえば、投稿のステータスは"publish""draft""pending""trash"のいずれかのみです。

GraphQLでは、これらを(文字列ではなく)enumとして扱い、対応する列挙型を作成できます。GraphQLの標準に従い、enumは大文字で記述するべきです:

enum CUSTOM_POST_STATUS {
  PUBLISH
  DRAFT
  PENDING
  TRASH
}

しかし、その場合、get_posts( [ "post_status" => "PUBLISH" ] )を実行しても動作しないため、クエリをWordPressとのやり取りに直接使用することができなくなります。

そのため、妥協案として、これらのenum値を小文字のままにします:

enum CUSTOM_POST_STATUS {
  publish
  draft
  pending
  trash
}

追加の型のマッピング

ブロックはWordPressのデータベースダイアグラムには直接表示されません。これはwp_postsに格納されているためです(wp_blocksというテーブルは存在しません)。しかし、それでも独立したエンティティです。

そのため、ブロックをマッピングするためにBlock型を導入することができます:

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