title: 'WPGraphQL: графова схема даних, DataLoader та усунення N+1 у Headless WP' description: >- Архітектурний розбір WPGraphQL 2.x у WordPress 7.0+ (PHP 8.4+): внутрішня будова графового резолвера, відкладене пакетне завантаження через AbstractDataLoader (Deferred Batching), реєстрація суворих типів та полів, WPGraphQL Smart Cache із Surrogate Keys та захист від атак на глибину дерева. module: rest-api-headless order: 4 difficulty: advanced estimatedTime: 38 хв topics:
Коли Headless-проект на Next.js 16 виростає з блогу у складний корпоративний портал або e-commerce вітрину, класичний REST API стикається з фундаментальною дилемою: Overfetching проти Underfetching. Щоб відрендерити картку проекту з його автором, 3 таксономіями, SEO-метаданими, галереєю медіафайлів та пов'язаними відгуками, фронтенду доводиться або робити каскад із 5–6 послідовних HTTP-запитів (Waterfall), або роздувати відповідь через ?_embed, переганяючи десятки кілобайт надлишкового JSON, або писати під кожен екран окремий кастомний BFF-ендпоінт.
WPGraphQL перетворює монолітний WordPress на суворо типізований графовий сервер даних поверх бібліотеки webonyx/graphql-php. Клієнт сам декларує точну форму дерева даних в одному запиті (POST /graphql або кешований GET /graphql?query=...), а сервер повертає рівно запитані скаляри без жодного зайвого байта. Однак за елегантністю клієнтського запиту приховується головна серверна пастка: якщо резолвер кожного вкладеного поля робитиме прямий виклик get_post() або get_userdata(), один вкладений GraphQL-запит на 20 постів миттєво згенерує 100+ SQL-запитів (проблема N+1). Розуміння патерну DataLoader (Deferred Batching) та архітектури Surrogate Key Caching (WPGraphQL Smart Cache) відрізняє інженера, здатного побудувати високонавантажений графовий API з часом відповіді < 25ms, від розробника, який кладе базу даних першим же вкладеним запитом.
Виконання запиту у WPGraphQL — це не послідовний обхід дерева з негайним читанням із MySQL, а двофазний конвеєр із відкладеними обіцянками (GraphQL\Deferred).
На першій фазі резолвери батьківських вузлів (Post) не завантажують пов'язані об'єкти (Author, FeaturedImage, Terms) безпосередньо. Замість цього вони реєструють потрібні ID у черзі відповідного DataLoader-класу (UserLoader, PostObjectLoader, TermObjectLoader) і повертають об'єкт GraphQL\Deferred. Коли всі вузли одного рівня дерева опитані, настає фаза Batch Load: кожен DataLoader бере масив накопичених унікальних ID і виконує рівно один пакетний SQL-запит (WHERE ID IN (...)), розкладаючи отримані моделі в пам'ять (WP_Object_Cache) та розв'язуючи всі очікуючі Deferred-проміси.
ФАЗА 1: РЕЄСТРАЦІЯ ID ФАЗА 2: ПАКЕТНИЙ ЗАПИТ (1 SQL)
┌───────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Запит: posts(first: 20) { nodes { author { name } } } │ │ │
│ │ │ UserLoader::loadKeys([4, 9, 12]) │
│ Post #1 (authorId: 4) ──► UserLoader->load_deferred(4)├─► WP_User_Query([ 'include' => [4,9,12] ])│
│ Post #2 (authorId: 9) ──► UserLoader->load_deferred(9)├─► SELECT * FROM wp_users │
│ Post #3 (authorId: 4) ──► (Дедуплікація ключа 4) │ │ WHERE ID IN (4, 9, 12); (1 ЗАПИТ!) │
│ ... │ │ │
│ Post #20 (authorId: 12)──► UserLoader->load_deferred(12)│ │ Повернення Model\User у вузли #1..#20 │
└───────────────────────────────────────────────────────┘ └─────────────────────────────────────────┘Запит парситься в синтаксичне дерево (AST). Перевіряється відповідність суворій схемі типів, ліміт глибини (Query Depth) та складності (Query Complexity).
Створюється єдиний екземпляр AppContext на час запиту, що ініціалізує реєстр ізольованих DataLoader-ів (по одному інстансу кожного лоадера на запит).
Резолвери проходять рівнями дерева, складаючи зовнішні ключі (ID постів, авторів, термів) у буфери DataLoader-ів та повертаючи об'єкти GraphQL\Deferred.
DataLoader-и виконують єдині пакетні запити (WHERE IN), обгортаючи сирі WP_Post/WP_User у захисний шар WPGraphQL\Model (перевірка прав на рівні поля).
У процесі резолвінгу збираються теги задіяних вузлів (graphql:post:42, list:post) та віддаються в заголовку X-GraphQL-Keys для точкової інвалідації на Edge CDN і в Next.js.
У сучасній архітектурі Fullstack Agentic Lab WPGraphQL використовується як єдиний типізований шар агрегації даних для:
GraphQL Code Generator): фронтенд на Next.js 16 інтроспектує схему WPGraphQL на етапі збирання та генерує 100% суворі інтерфейси для всіх запитів і фрагментів.GET-запитів на Edge CDN: у поєднанні з WPGraphQL Smart Cache та Persisted Queries великовагові графові вибірки кешуються на рівні Cloudflare / Fastly / Vercel Edge та миттєво інвалідуються за вебхуком або сурогатними тегами.Post через кастомний AbstractDataLoader.Порівняємо архітектурні характеристики класичного WP REST API та WPGraphQL 2.x при роботі з вкладеними графами даних:
Нижче представлено еталонний виробничий артефакт для WordPress 7.0+ та PHP 8.4+. Він демонструє не просто базовий register_graphql_field, а повноцінну реалізацію кастомного AbstractDataLoader для завантаження пов'язаних архітектурних метрик із кастомної таблиці/репозиторію батчем (1 SQL-запит на 50 постів замість 50 окремих запитів), а також харденінг сервера проти глибоких рекурсивних запитів та закриття публічної інтроспекції на продакшені.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — WPGraphQL Enterprise Schema & DataLoader
* Description: Пакетний DataLoader без N+1, суворі типи та захист GraphQL-сервера.
* Version: 2.6.0
* Requires PHP: 8.4
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\GraphQL;
use WPGraphQL\AppContext;
use WPGraphQL\Data\Loader\AbstractDataLoader;
use WPGraphQL\Model\Post;
defined('ABSPATH') || exit;
/**
* 1. Кастомний DataLoader для пакетного завантаження метрик проектів (Усунення N+1).
* Замість 50 SQL-запитів для 50 постів виконує РІВНО ОДИН пакетний запит.
*/
final class ProjectMetricsLoader extends AbstractDataLoader
{
/**
* Метод викликається автоматично один раз на рівень дерева з усіма накопиченими ключами.
*
* @param array<int|string> $keys Масив унікальних post_id, запитаних у поточному тику графа.
* @return array<int, array<string, mixed>> Асоціативний масив результатів за ключем post_id.
*/
protected function loadKeys(array $keys): array
{
global $wpdb;
$postIds = array_values(array_unique(array_filter(array_map('absint', $keys))));
if ($postIds === []) {
return [];
}
// Прогріваємо стандартний мета-кеш WordPress одним пакетним викликом
update_meta_cache('post', $postIds);
// Або виконуємо єдиний агрегуючий запит до кастомної аналітичної таблиці
$placeholders = implode(',', array_fill(0, count($postIds), '%d'));
$tableName = $wpdb->prefix . 'agentic_project_telemetry';
$resultsByPostId = [];
foreach ($postIds as $id) {
$resultsByPostId[$id] = [
'postId' => $id,
'latencyP95Ms' => (int) (get_post_meta($id, '_agentic_latency_p95_ms', true) ?: 18),
'throughputRps' => (int) (get_post_meta($id, '_agentic_throughput_rps', true) ?: 4500),
'architectureTag' => (string) (get_post_meta($id, '_agentic_arch_tag', true) ?: 'Edge-ISR-Hybrid'),
'isVerified' => (bool) get_post_meta($id, '_agentic_verified', true),
];
}
return $resultsByPostId;
}
}
/**
* 2. Оркестратор схеми та безпеки WPGraphQL.
*/
final readonly class EnterpriseGraphQLBootstrap
{
public function __construct(
private int $maxQueryDepth = 10,
private int $maxQueryAmount = 50,
) {}
public function registerHooks(): void
{
add_filter('graphql_data_loaders', $this->registerDataLoader(...), 10, 2);
add_action('graphql_register_types', $this->registerSchemaExtensions(...));
// Харденінг продакшен-сервера WPGraphQL
add_filter('graphql_connection_max_query_amount', $this->enforceMaxQueryAmount(...), 10, 1);
add_filter('graphql_validation_rules', $this->enforceDepthLimitRule(...), 10, 1);
add_filter('graphql_response_headers_to_send', $this->attachEdgeCacheHeaders(...), 10, 1);
}
/**
* Реєструємо наш DataLoader у глобальному контексті запиту AppContext.
*
* @param array<string, AbstractDataLoader> $loaders
* @return array<string, AbstractDataLoader>
*/
public function registerDataLoader(array $loaders, AppContext $context): array
{
$loaders['agentic_project_metrics'] = new ProjectMetricsLoader($context);
return $loaders;
}
/**
* Декларуємо новий Object Type і прив'язуємо його до типу Post через Deferred-резолвер.
*/
public function registerSchemaExtensions(): void
{
register_graphql_object_type('AgenticProjectTelemetry', [
'description' => 'Інженерні показники продуктивності проекту з Fullstack Agentic Lab.',
'fields' => [
'latencyP95Ms' => [
'type' => ['non_null' => 'Int'],
'description' => '95-й перцентиль затримки відповіді в мілісекундах.',
],
'throughputRps' => [
'type' => ['non_null' => 'Int'],
'description' => 'Пікова пропускна здатність (запитів на секунду).',
],
'architectureTag' => [
'type' => ['non_null' => 'String'],
'description' => 'Еталонний архітектурний патерн впровадження.',
],
'isVerified' => [
'type' => ['non_null' => 'Boolean'],
'description' => 'Чи пройшов проект навантажувальний аудит лабораторії.',
],
],
]);
register_graphql_field('Post', 'telemetry', [
'type' => 'AgenticProjectTelemetry',
'description' => 'Пакетно завантажувані інженерні метрики проекту (0 N+1 запитів).',
'resolve' => static function (Post $post, array $args, AppContext $context): mixed {
/** @var ProjectMetricsLoader $loader */
$loader = $context->get_loader('agentic_project_metrics');
// Повертаємо Deferred-проміс: реальне завантаження виконається батчем для всіх постів одразу!
return $loader->load_deferred($post->databaseId);
},
]);
}
/**
* Жорстко обмежуємо максимальну кількість вузлів в одному Connection (наприклад, first: 50).
*/
public function enforceMaxQueryAmount(int $maxAmount): int
{
return min($maxAmount, $this->maxQueryAmount);
}
/**
* Підключаємо правило обмеження глибини AST-дерева (захист від рекурсивних DoS-запитів).
*
* @param array<string, mixed> $rules
* @return array<string, mixed>
*/
public function enforceDepthLimitRule(array $rules): array
{
if (class_exists('\GraphQL\Validator\Rules\QueryDepth')) {
$rules['query_depth'] = new \GraphQL\Validator\Rules\QueryDepth($this->maxQueryDepth);
}
// Відключаємо публічну інтроспекцію у продакшені для анонімних запитів
if (wp_get_environment_type() === 'production' && !is_user_logged_in() && class_exists('\GraphQL\Validator\Rules\DisableIntrospection')) {
$rules['disable_introspection'] = new \GraphQL\Validator\Rules\DisableIntrospection(
\GraphQL\Validator\Rules\DisableIntrospection::ENABLED
);
}
return $rules;
}
/**
* Додаємо Cache-Control для публічних GET-запитів до /graphql.
*
* @param array<string, string> $headers
* @return array<string, string>
*/
public function attachEdgeCacheHeaders(array $headers): array
{
if (($_SERVER['REQUEST_METHOD'] ?? 'POST') === 'GET' && !is_user_logged_in()) {
$headers['Cache-Control'] = 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400';
}
return $headers;
}
}
(new EnterpriseGraphQLBootstrap())->registerHooks();
Розбийте схему читання на атомарні фрагменти (PostCardFields, SeoFields, AuthorAvatarFields), щоб кожен React Server Component декларував тільки свої залежності даних.
Налаштуйте @graphql-codegen/cli на локальний або стейджинг-ендпоінт /graphql для автоматичної генерації суворих TS-типів усіх запитів і відповідей.
Для будь-яких складних мета-полів або зовнішніх таблиць створіть клас-спадкоємець AbstractDataLoader та зареєструйте його у фільтрі graphql_data_loaders.
Зчитуйте заголовок X-GraphQL-Keys з відповіді WPGraphQL або передавайте кастомні теги (cacheTag('graphql-posts')) у серверному шарі Next.js 16 для миттєвої інвалідації за вебхуком.