👶🏻 Odmładzanie WordPressa przez GraphQL
WordPress to przestarzały CMS: stworzony ponad 17 lat temu, jest wypełniony kodem PHP, który — gdyby dać mu drugą szansę — zostałby napisany zupełnie inaczej.
GraphQL to nowoczesny interfejs do dostępu do danych. Zwróć uwagę na słowo „interfejs": nie ma znaczenia, jak zaimplementowany jest bazowy system danych, liczy się jedynie to, jak dane są eksponowane.
Co się dzieje, gdy połączymy te dwa elementy? Jak powinniśmy zaprojektować interfejs GraphQL do dostępu do danych z WordPressa?
Możemy przyjąć kilka oczywistych strategii:
-
Szanować tradycję i zapewnić mapowanie, które zachowuje model danych WordPressa w niezmienionej postaci, włącznie z długiem technicznym nagromadzionym przez lata
-
Naprawić dług techniczny, dostarczając interfejs eksponujący dane w sposób abstrakcyjny, niekoniecznie powiązany z WordPressem
Oba podejścia mają zalety i wady i żadne nie jest ani złe, ani dobre. To kwestia przyjętych priorytetów — preferowania jednego zachowania nad drugim.
Dla wtyczki Gato GraphQL wybrałem to drugie podejście, starając się stworzyć schemat GraphQL, który — choć oparty na WordPressie i przeznaczony dla WordPressa — nie jest z nim ściśle powiązany (np. przez usunięcie niespójnych nazw i relacji).
Efektem jest to, że GraphQL odmładza WordPressa: wciąż mamy WordPressa jako nasz bazowy CMS z jego przestarzałym kodem PHP, ale warstwa danych może zostać stworzona od nowa — oparta na zdrowym rozsądku, a nie na tradycji. Warstwa danych wraca z okresu dojrzewania do stanu niemowlęcego.

Efektem jest schemat GraphQL reprezentujący model danych WordPressa, obsługujący również zagnieżdżone mutacje.
Sprawdźmy, jak to zostało zrealizowane.
Model danych WordPressa
WordPress posiada następujące encje:
- posty
- strony
- custom posty
- elementy mediów
- użytkownicy
- role użytkowników
- tagi
- kategorie
- komentarze
- bloki
- właściwości meta
- inne (opcje, wtyczki, motywy itp.)
Encje te mogą tworzyć hierarchię. Na przykład post, strona i elementy mediów są custom post types, a tagi i kategorie to taksonomie.
Oto diagram bazy danych WordPressa, pokazujący sposób przechowywania danych wszystkich encji:

Czy mapowanie jest dokładną repliką diagramu BD?
Czy podczas mapowania bazy danych WordPressa na schemat GraphQL powyższy diagram jest respektowany w stosunku 1 do 1?
Nie, nie jest. Choć diagram bazy danych to rzeczywista implementacja, GraphQL jest interfejsem dostępu do danych po stronie klienta. Oba są ze sobą powiązane, ale mogą się różnić. GraphQL nie dba o bazę danych: nie myśli w kategoriach poleceń SQL ani nie wie, że istnieją tabele bazy danych o nazwach wp_posts i wp_users.
Nie musimy więc zbytnio przejmować się diagramem bazy danych podczas tworzenia schematu GraphQL dla WordPressa. Oznacza to, że możemy stworzyć schemat GraphQL, który naprawia część długu technicznego modelu danych WordPressa.
Mapowanie modelu danych WordPressa jako schemat GraphQL
Przejdźmy do mapowania. Najpierw mapujemy oryginalne encje jako typy, w miarę możliwości. Z listy encji w modelu danych WordPressa tworzymy następujące typy dla schematu GraphQL:
PostPageMediaUserUserRolePostTagPostCategoryComment
Następnie dodajemy wszystkie oczekiwane pola do każdego typu. Do reprezentowania schematu możemy użyć SDL, czyli Schema Definition Language. (Służy to wyłącznie celom dokumentacyjnym; sama wtyczka nie używa SDL do kodowania schematu — to wszystko jest kodem PHP).
Oto pola (spośród wielu innych) dla Post:
type Post {
id: ID!
title: String
content: String
excerpt: String
publishedAt: Date!
}Oto pola (spośród wielu innych) dla User:
type User {
id: ID!
name: String
email: String!
}Tworzymy również odpowiednie połączenia, czyli pola zwracające inną encję (zamiast wartości skalarnej, jak liczba lub ciąg znaków). Na przykład reprezentujemy post posiadający autora oraz użytkownika posiadającego posty:
type Post {
author: User!
}
type User {
posts: [Post]
}Pola i połączenia mogą również przyjmować argumenty. Na przykład umożliwiamy formatowanie Post.date oraz wyszukiwanie i ograniczanie liczby wpisów w User.posts:
type Post {
date(format: String): Date!
}
type User {
posts(limit: Int, search: String): [Post]
}Kontynuujemy to dla wszystkich encji w modelu danych WordPressa. Po zakończeniu otrzymamy schemat GraphQL dla WordPressa, widoczny za pomocą klienta Voyager (dostępnego jako "Interactive Schema" w menu wtyczki):

Schemat ten ma pewne podobieństwa do diagramu bazy danych WordPressa, ale też wiele różnic. Przeanalizujmy je.
Operacje bez encji są mapowane jako pola Root
Diagram bazy danych WordPressa przedstawia sposób przechowywania danych, więc nie ma „punktu początkowego". GraphQL natomiast jest interfejsem do pobierania danych, dlatego musi istnieć etap inicjalny, od którego wykonywana jest query.
Tym etapem inicjalnym jest typ Root, a dokładniej typy QueryRoot i MutationRoot (obsługujące odpowiednio queries i mutacje).
W tych dwóch typach mapujemy wszystkie operacje, które nie zależą od encji, takie jak wykonanie get_posts(), get_users() lub wp_signon():
type QueryRoot {
posts: [Post]!
users: [User]!
}
type MutationRoot {
logUserIn(username: String, password: String): User
}Pola nie muszą mieć tej samej nazwy ani sygnatury co operacje, które reprezentują. Na przykład pole logUserIn można uznać za bardziej odpowiednie niż signOn.
Wszystkie mutacje trafiają pod MutationRoot
Istnieją operacje, które zależą od encji, jak wp_update_post(), stosowana na konkretnym poście. Odpowiednia mutacja w schemacie GraphQL musi być dodana do typu MutationRoot, ponieważ tak działa GraphQL.
Operacja ta jest mapowana w następujący sposób:
type MutationRoot {
updatePost(input: {
postID: ID!,
newTitle: String,
newContent: String
}): Post
}Wtyczka obsługuje również zagnieżdżone mutacje, oferowane jako funkcja opt-in (ponieważ nie jest to standardowe zachowanie GraphQL). Mutacje mogą więc być dodawane pod dowolnym typem, nie tylko pod MutationRoot. W takim przypadku otrzymujemy:
type Post {
update(input: {
newTitle: String,
newContent: String
}): Post!
}Obsługa custom postów
W GraphQL nie ma dziedziczenia typów. Nie możemy więc mieć typu CustomPost i deklarować, że Post i Page go rozszerzają.
GraphQL oferuje dwa zasoby, które kompensują ten brak: interfejsy i typy union.
W przypadku pierwszego tworzymy interfejs CustomPost dla schematu, deklarując wszystkie pola oczekiwane od custom posta, i definiujemy typy Post oraz Page implementujące ten interfejs:
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!
}W przypadku drugiego tworzymy typ CustomPostUnion dla schematu, zwracający wszystkie custom post types:
union CustomPostUnion = Post | PageI sprawiamy, że pola zwracają ten typ tam, gdzie jest to odpowiednie:
type QueryRoot {
customPost(id: ID): CustomPostUnion
customPosts: [CustomPostUnion]!
}
type User {
customPosts: [CustomPostUnion]
}
type Comment {
customPost: CustomPostUnion!
}Jak widać, w schemacie GraphQL musimy wyraźnie określać, kiedy mamy do czynienia z postami, a kiedy z custom postami — to nie to samo! Używanie tych dwóch pojęć zamiennie to dług techniczny WordPressa, który możemy naprawić.
Z tego powodu custom post zawsze jest nazywany CustomPost, a nie Post, pole obsługujące custom posty zawsze nosi nazwę customPosts, a nie posts, a argument pola przyjmujący ID custom posta nosi nazwę customPostID, a nie postID (nawet jeśli tak jest nazwany w mapowanej funkcji WordPressa).
Dzięki temu oczekiwania są zawsze jasne:
- pole
User.customPostsmoże zwrócić listę dowolnych custom postów, w tym postów i stron, aUser.postszwraca wyłącznie posty - pole
Root.setFeaturedImageOnCustomPostmoże dodać wyróżniony obraz do dowolnego custom posta — dlatego nie nazywa sięsetFeaturedImageOnPost
Niegrupowanie tagów (i kategorii) pod jednym typem
Dlaczego typ PostTag (i analogicznie PostCategory) nosi taką nazwę, zamiast po prostu Tag?
Ponieważ podczas wykonywania tej query (gdzie produkt jest CPT), wyniki pola tags dla postów i produktów będą zawsze różne i nie będą się pokrywać:
query {
posts {
tags {
id
name
}
}
products {
tags {
id
name
}
}
}Tagi dodane do postów nie pojawią się podczas pobierania tagów dla produktów i odwrotnie (chyba że produkt używa również taksonomii post_tag, ale wtedy też może być reprezentowany typem PostTag). W WordPressie nie stanowi to dużego problemu, ponieważ te elementy można traktować jako różne wiersze tej samej tabeli bazy danych. Jednak ma to znaczenie dla GraphQL, który jest silnie typowany.
Dlatego dobrą decyzją projektową jest utrzymywanie tych encji oddzielnie, pod własnymi typami — tagi dla postów zwracane są pod typem PostTag, a jeśli niestandardowa wtyczka implementuje własny CPT produktu, powinna używać typu ProductTag dla swoich tagów.
Nadawanie elementom mediów własnej tożsamości
Encje mediów w WordPressie są custom post types jedynie dlatego, że było to wygodne z punktu widzenia implementacji. Jednak schemat GraphQL może uniknąć tego długu technicznego i modelować elementy mediów jako odrębną encję, a nie jako custom posty.
Wynika z tego kilka decyzji dotyczących schematu GraphQL:
- Podczas odpytywania pola
customPostsnie będą pobierane elementy mediów - Typ
Medianie implementuje interfejsuCustomPosti nie będzie częścią typuCustomPostUnion - Typ
Medianie posiada wielu pól oczekiwanych od custom post type, takich jakexcerpt,dateczystatus. Zamiast tego ma wyłącznie pola oczekiwane od elementu mediów:
type Media {
id: ID!
src: String!
width: Int
height: Int
}Identyfikowanie i mapowanie enumów
W niektórych sytuacjach WordPress używa stałych wartości z danego zbioru. Na przykład status posta może być tylko "publish", "draft", "pending" lub "trash".
W GraphQL możemy traktować je jako enumy (zamiast ciągów znaków) i utworzyć odpowiedni typ wyliczeniowy. Zgodnie ze standardem GraphQL, enumy powinny być pisane wielkimi literami, w taki sposób:
enum CUSTOM_POST_STATUS {
PUBLISH
DRAFT
PENDING
TRASH
}Jednak wtedy query nie może być bezpośrednio używana do interakcji z WordPressem, ponieważ wykonanie get_posts( [ "post_status" => "PUBLISH" ] ) nie działa.
Dlatego jako kompromis zachowujemy te wartości enumów małymi literami:
enum CUSTOM_POST_STATUS {
publish
draft
pending
trash
}Mapowanie dodatkowych typów
Bloki nie są bezpośrednio widoczne na diagramie bazy danych WordPressa, ponieważ są przechowywane w wp_posts (nie ma tabeli wp_blocks), jednak stanowią odrębną encję.
Dlatego wprowadzamy typ Block, aby je zmapować:
type Post {
blocks: [Block]
}
type Block {
type: String!
attributes: JSONObject
}