Blog

👶🏻 WordPress verjongen via GraphQL

Leonardo Losoviz
Door Leonardo Losoviz ·

WordPress is een legacy-CMS: het bestaat al meer dan 17 jaar en is gevuld met PHP-code die, als je het opnieuw mocht doen, heel anders geschreven zou worden.

GraphQL is een moderne interface om data op te halen. Let op het woord "interface": het maakt niet uit hoe het onderliggende datasysteem is geïmplementeerd, alleen hoe de data wordt blootgesteld.

Wat gebeurt er als we deze twee combineren? Hoe moeten we de GraphQL-interface ontwerpen om data uit WordPress op te halen?

Er zijn een paar voor de hand liggende strategieën die we kunnen toepassen:

  1. De traditie respecteren en een mapping bieden die het WordPress-datamodel ongewijzigd behoudt, inclusief de technische schuld die het door de jaren heen heeft opgebouwd

  2. De technische schuld oplossen door een interface te bieden die data op een abstracte, niet per se aan WordPress gebonden manier blootstelt

Beide benaderingen hebben voor- en nadelen, en geen ervan is goed of fout. Het is gewoon een kwestie van voorkeur — sommig gedrag boven ander stellen.

Voor de plugin Gato GraphQL heb ik de tweede benadering gekozen, met als doel een GraphQL-schema te maken dat, hoewel het op WordPress is gebaseerd en voor WordPress werkt, niet aan WordPress gebonden is (bijvoorbeeld door inconsistente namen en relaties te verwijderen).

Het resultaat is dat GraphQL WordPress verjongt: we hebben nog steeds WordPress als ons onderliggende CMS, met zijn legacy PHP-code, maar de datalaag kan opnieuw worden opgebouwd, gebaseerd op gezond verstand in plaats van traditie. De datalaag gaat terug van een puber naar een peuter.

GraphQL + WordPress rock

Het resultaat is een GraphQL-schema dat het WordPress-datamodel vertegenwoordigt, en dat ook geneste mutaties ondersteunt.

Laten we eens bekijken hoe dat is aangepakt.

Het WordPress-datamodel

WordPress heeft de volgende entiteiten:

  • posts
  • pagina's
  • custom posts
  • media-elementen
  • gebruikers
  • gebruikersrollen
  • tags
  • categorieën
  • reacties
  • blokken
  • meta-eigenschappen
  • overige (opties, plugins, thema's, etc.)

Deze entiteiten kunnen een hiërarchie hebben. Zo zijn post, pagina en media-elementen allemaal custom post types, en zijn tags en categorieën beide taxonomieën.

Dit is het databasediagram van WordPress, dat laat zien hoe de data voor alle entiteiten wordt opgeslagen:

Het databasediagram van WordPress

Is de mapping een exacte kopie van het DB-diagram?

Als we de WordPress-database naar een GraphQL-schema mappen, wordt het bovenstaande diagram dan 1 op 1 gevolgd?

Nee, dat is niet zo. Terwijl het databasediagram een daadwerkelijke implementatie is, is GraphQL een interface om data vanuit de client op te halen. Beide zijn gerelateerd, maar kunnen verschillen. GraphQL maakt zich geen zorgen over de database: het denkt niet in SQL-opdrachten en weet niet dat er databasetabellen bestaan die wp_posts en wp_users heten.

We hoeven ons dus niet al te druk te maken over het databasediagram bij het maken van het GraphQL-schema voor WordPress. Dat betekent dat we een GraphQL-schema kunnen produceren dat een deel van de technische schuld van het WordPress-datamodel oplost.

Het WordPress-datamodel mappen als GraphQL-schema

Laten we de mapping uitvoeren. Eerst mappen we de oorspronkelijke entiteiten zoveel mogelijk als types. Uit de lijst van entiteiten in het WordPress-datamodel produceren we de volgende types voor het GraphQL-schema:

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

Vervolgens voegen we alle verwachte velden toe aan elk type. Om het schema te representeren kunnen we de SDL gebruiken, oftewel de Schema Definition Language. (Dit is alleen voor documentatiedoeleinden; de plugin zelf gebruikt geen SDL om het schema vast te leggen: het is allemaal PHP-code).

Dit zijn de velden (onder andere) voor een Post:

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

Dit zijn de velden (onder andere) voor een User:

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

We maken ook de bijbehorende verbindingen, dat zijn velden die een andere entiteit teruggeven (in plaats van een scalaire waarde, zoals een getal of een string). Zo representeren we dat een post een auteur heeft en dat een gebruiker posts bezit:

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

Velden en verbindingen kunnen ook argumenten accepteren. Zo stellen we Post.date in staat om geformatteerd te worden, en User.posts om entries te doorzoeken en hun aantal te beperken:

type Post {
  date(format: String): Date!
}
 
type User {
  posts(limit: Int, search: String): [Post]
}

We doen dit voor alle entiteiten in het WordPress-datamodel. Als we klaar zijn, komen we uit op het GraphQL-schema voor WordPress, dat zichtbaar is via de Voyager-client (beschikbaar als "Interactive Schema" in het menu van de plugin):

Het GraphQL-schema voor WordPress

Dit schema vertoont overeenkomsten met het WordPress-databasediagram, maar ook veel verschillen. Laten we die analyseren.

Operaties zonder entiteit worden als Root-velden gemapped

Het WordPress-databasediagram geeft aan hoe data wordt opgeslagen, dus er is geen "beginpunt". GraphQL is echter een interface om data op te halen, dus er moet een beginstadium zijn vanwaaruit de query wordt uitgevoerd.

Dit beginstadium is het Root-type, of preciezer gezegd, de types QueryRoot en MutationRoot (voor het afhandelen van respectievelijk queries en mutaties).

In deze twee types mappen we alle operaties die niet afhankelijk zijn van een entiteit, zoals het uitvoeren van get_posts(), get_users() of wp_signon():

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

De velden hoeven niet dezelfde naam of signatuur te hebben als de operatie die ze vertegenwoordigen. Het veld logUserIn kan bijvoorbeeld als passender worden beschouwd dan signOn.

Alle mutaties gaan onder MutationRoot

Er zijn operaties die wel afhankelijk zijn van een entiteit, zoals wp_update_post(), die wordt toegepast op een bepaalde post. De bijbehorende mutatie in het GraphQL-schema moet worden toegevoegd aan het type MutationRoot, omdat GraphQL zo werkt.

Deze operatie wordt dan als volgt gemapped:

type MutationRoot {
  updatePost(input: {
    postID: ID!,
    newTitle: String,
    newContent: String
  }): Post
}

Deze plugin ondersteunt ook geneste mutaties, aangeboden als een opt-in functie (omdat dit geen standaard GraphQL-gedrag is). Mutaties kunnen dan ook worden toegevoegd onder elk type, niet alleen onder MutationRoot. In dat geval krijgen we:

type Post {
  update(input: {
    newTitle: String,
    newContent: String
  }): Post!
}

Omgaan met custom posts

Er is geen type-overerving in GraphQL. We kunnen dus geen type CustomPost hebben en verklaren dat Post en Page dit uitbreiden.

GraphQL biedt twee middelen om dit gebrek te compenseren: interfaces en union-types.

Voor het eerste maken we een interface CustomPost voor het schema, waarin we alle velden declareren die van een custom post worden verwacht, en we definiëren de types Post en Page om de interface te implementeren:

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

Voor het tweede maken we een type CustomPostUnion voor het schema dat alle custom post types teruggeeft:

union CustomPostUnion = Post | Page

En laten we velden dit type teruggeven waar dat van toepassing is:

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

Zoals te zien is, moeten we in het GraphQL-schema expliciet aangeven wanneer we met posts werken en wanneer met custom posts, want dat zijn niet hetzelfde! Deze twee door elkaar gebruiken is technische schuld van WordPress die we kunnen oplossen.

Daarom wordt een custom post altijd CustomPost genoemd en niet Post, wordt een veld dat met custom posts werkt altijd customPosts genoemd en niet posts, en wordt een veldargument dat het ID van een custom post ontvangt customPostID genoemd en niet postID (ook al heet het zo in de gemapte WordPress-functie).

De verwachting is dan altijd duidelijk:

  • veld User.customPosts kan een lijst van welke custom post dan ook teruggeven, inclusief posts en pagina's, terwijl User.posts alleen posts teruggeeft
  • veld Root.setFeaturedImageOnCustomPost kan een uitgelichte afbeelding toevoegen aan elke custom post, daarom heet het niet setFeaturedImageOnPost

Tags (en categorieën) niet groeperen onder één type

Waarom heet het type PostTag (en hetzelfde geldt voor PostCategory) zo, in plaats van gewoon Tag?

Omdat, wanneer je deze query uitvoert (waarbij een product een CPT is), de resultaten van het veld tags voor posts en producten altijd verschillend en niet-overlappend zijn:

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

Tags die aan posts zijn toegevoegd, verschijnen niet bij het ophalen van tags voor producten, en omgekeerd (tenzij een product ook de taxonomie post_tag gebruikt, maar dan kan het ook worden weergegeven met het type PostTag). In WordPress is dit geen groot probleem, omdat deze items kunnen worden beschouwd als verschillende rijen uit dezelfde databasetabel. Maar voor GraphQL, dat sterk getypeerd is, maakt het wel uit.

Het is dan ook een goede ontwerpbeslissing om deze entiteiten gescheiden te houden, elk onder hun eigen type, waarbij tags voor posts worden teruggegeven onder het type PostTag, en als een aangepaste plugin zijn eigen product-CPT implementeert, het type ProductTag moet gebruiken voor zijn tags.

Media-items een eigen identiteit geven

Media-entiteiten in WordPress zijn custom post types, simpelweg omdat dat implementatietechnisch handig was. Het GraphQL-schema kan deze technische schuld echter vermijden en media-elementen modelleren als een aparte entiteit, niet als custom posts.

Dit impliceert de volgende beslissingen voor het GraphQL-schema:

  • Bij het opvragen van het veld customPosts worden geen media-elementen opgehaald
  • Het type Media implementeert de interface CustomPost niet en maakt geen deel uit van het type CustomPostUnion
  • Het type Media heeft niet veel velden die van een custom post type worden verwacht, zoals excerpt, date en status. In plaats daarvan heeft het alleen de velden die van een media-element worden verwacht:
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

Enums identificeren en mappen

In bepaalde situaties gebruikt WordPress vaste waarden uit een bepaalde set. Zo kan de status van een post alleen "publish", "draft", "pending" of "trash" zijn.

In GraphQL kunnen we deze behandelen als enums (in plaats van strings) en een bijbehorend enum-type aanmaken. Conform de GraphQL-standaard moeten enums in hoofdletters worden geschreven, als volgt:

enum CUSTOM_POST_STATUS {
  PUBLISH
  DRAFT
  PENDING
  TRASH
}

Dan kan de query echter niet direct worden gebruikt om met WordPress te communiceren, omdat het uitvoeren van get_posts( [ "post_status" => "PUBLISH" ] ) niet werkt.

Als compromis houden we deze enum-waarden dan ook in kleine letters:

enum CUSTOM_POST_STATUS {
  publish
  draft
  pending
  trash
}

Extra types mappen

Blokken zijn niet direct zichtbaar in het WordPress-databasediagram, omdat ze worden opgeslagen in wp_posts (er is geen tabel wp_blocks), maar het zijn toch afzonderlijke entiteiten.

Daarom introduceren we het type Block om ze te mappen:

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

Abonneer je op onze nieuwsbrief

Blijf op de hoogte van alle updates over Gato GraphQL.