Completed
Push — 8.x-3.x ( 18ad76...366b9c )
by Sebastian
01:47
created

QueryProcessor::executeSingle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 2
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Drupal\graphql\GraphQL\Execution;
4
5
use Drupal\Core\Cache\Cache;
6
use Drupal\Core\Cache\CacheableMetadata;
7
use Drupal\Core\Cache\CacheBackendInterface;
8
use Drupal\Core\Cache\Context\CacheContextsManager;
9
use Drupal\graphql\Plugin\SchemaPluginManager;
10
use Drupal\graphql\GraphQL\Cache\CacheableRequestError;
11
use GraphQL\Error\Error;
12
use GraphQL\Error\FormattedError;
13
use GraphQL\Executor\ExecutionResult;
14
use GraphQL\Executor\Executor;
15
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
16
use GraphQL\Executor\Promise\PromiseAdapter;
17
use GraphQL\Language\AST\DocumentNode;
18
use GraphQL\Language\Parser;
19
use GraphQL\Language\Visitor;
20
use GraphQL\Server\Helper;
21
use GraphQL\Server\OperationParams;
22
use GraphQL\Server\RequestError;
23
use GraphQL\Server\ServerConfig;
24
use GraphQL\Utils\AST;
25
use GraphQL\Utils\TypeInfo;
26
use GraphQL\Utils\Utils;
27
use GraphQL\Validator\Rules\AbstractValidationRule;
28
use GraphQL\Validator\ValidationContext;
29
use Symfony\Component\HttpFoundation\RequestStack;
30
31
// TODO: Refactor this and clean it up.
32
class QueryProcessor {
33
34
  /**
35
   * The schema plugin manager.
36
   *
37
   * @var \Drupal\graphql\Plugin\SchemaPluginManager
38
   */
39
  protected $pluginManager;
40
41
  /**
42
   * The cache backend for caching query results.
43
   *
44
   * @var \Drupal\Core\Cache\CacheBackendInterface
45
   */
46
  protected $cacheBackend;
47
48
  /**
49
   * The cache contexts manager service.
50
   *
51
   * @var \Drupal\Core\Cache\Context\CacheContextsManager
52
   */
53
  protected $contextsManager;
54
55
  /**
56
   * The request stack.
57
   *
58
   * @var \Symfony\Component\HttpFoundation\RequestStack
59
   */
60
  protected $requestStack;
61
62
  /**
63
   * Processor constructor.
64
   *
65
   * @param \Drupal\Core\Cache\Context\CacheContextsManager $contextsManager
66
   *   The cache contexts manager service.
67
   * @param \Drupal\graphql\Plugin\SchemaPluginManager $pluginManager
68
   *   The schema plugin manager.
69
   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
70
   *   The cache backend for caching query results.
71
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
72
   *   The request stack.
73
   */
74
  public function __construct(
75
    CacheContextsManager $contextsManager,
76
    SchemaPluginManager $pluginManager,
77
    CacheBackendInterface $cacheBackend,
78
    RequestStack $requestStack
79
  ) {
80
    $this->contextsManager = $contextsManager;
81
    $this->pluginManager = $pluginManager;
82
    $this->cacheBackend = $cacheBackend;
83
    $this->requestStack = $requestStack;
84
  }
85
86
  /**
87
   * Processes one or multiple graphql operations.
88
   *
89
   * @param string $schema
90
   *   The plugin id of the schema to use.
91
   * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params
92
   *   The graphql operation(s) to execute.
93
   *
94
   * @return \Drupal\graphql\GraphQL\Execution\QueryResult|\Drupal\graphql\GraphQL\Execution\QueryResult[]
95
   *   The query result.
96
   *
97
   * @throws \Drupal\Component\Plugin\Exception\PluginException
98
   */
99
  public function processQuery($schema, $params) {
100
    // Load the plugin from the schema manager.
101
    $plugin = $this->pluginManager->createInstance($schema);
102
    $config = $plugin->getServer();
103
104
    if (is_array($params)) {
105
      return $this->executeBatch($config, $params);
106
    }
107
108
    return $this->executeSingle($config, $params);
109
  }
110
111
  /**
112
   * @param \GraphQL\Server\ServerConfig $config
113
   * @param \GraphQL\Server\OperationParams $params
114
   *
115
   * @return mixed
116
   */
117
  public function executeSingle(ServerConfig $config, OperationParams $params) {
118
    $adapter = new SyncPromiseAdapter();
119
    $result = $this->executeOperationWithReporting($adapter, $config, $params, FALSE);
120
    return $adapter->wait($result);
121
  }
122
123
  /**
124
   * @param \GraphQL\Server\ServerConfig $config
125
   * @param array $params
126
   *
127
   * @return mixed
128
   */
129
  public function executeBatch(ServerConfig $config, array $params) {
130
    $adapter = new SyncPromiseAdapter();
131
    $result = array_map(function ($params) use ($adapter, $config) {
132
      return $this->executeOperationWithReporting($adapter, $config, $params, TRUE);
133
    }, $params);
134
135
    $result = $adapter->all($result);
136
    return $adapter->wait($result);
137
  }
138
139
  /**
140
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
141
   * @param \GraphQL\Server\ServerConfig $config
142
   * @param \GraphQL\Server\OperationParams $params
143
   * @param bool $batching
144
   *
145
   * @return \GraphQL\Executor\Promise\Promise
146
   */
147
  protected function executeOperationWithReporting(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
148
    $result = $this->executeOperation($adapter, $config, $params, $batching);
149
150
    // Format and print errors.
151
    return $result->then(function(QueryResult $result) use ($config) {
152
      if ($config->getErrorsHandler()) {
153
        $result->setErrorsHandler($config->getErrorsHandler());
154
      }
155
156
      if ($config->getErrorFormatter() || $config->getDebug()) {
157
        $result->setErrorFormatter(FormattedError::prepareFormatter($config->getErrorFormatter(), $config->getDebug()));
158
      }
159
160
      return $result;
161
    });
162
  }
163
164
  /**
165
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
166
   * @param \GraphQL\Server\ServerConfig $config
167
   * @param \GraphQL\Server\OperationParams $params
168
   * @param bool $batching
169
   *
170
   * @return \GraphQL\Executor\Promise\Promise
171
   */
172
  protected function executeOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
173
    try {
174
      if (!$config->getSchema()) {
175
        throw new \LogicException('Missing schema for query execution.');
176
      }
177
178
      if ($batching && !$config->getQueryBatching()) {
179
        throw new RequestError('Batched queries are not supported by this server.');
180
      }
181
182
      if ($errors = $this->validateOperationParams($params)) {
183
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
184
      }
185
186
      $document = $params->queryId ? $this->loadPersistedQuery($config, $params) : $params->query;
187
      if (!$document instanceof DocumentNode) {
188
        $document = Parser::parse($document);
189
      }
190
191
      // Read the operation type from the document. Subscriptions and mutations
192
      // only work through POST requests. One cannot have mutations and queries
193
      // in the same document, hence this check is sufficient.
194
      $operation = $params->operation;
195
      $type = AST::getOperation($document, $operation);
196
      if ($params->isReadOnly() && $type !== 'query') {
197
        throw new RequestError('GET requests are only supported for query operations.');
198
      }
199
200
      // If one of the validation rules found any problems, do not resolve the
201
      // query and bail out early instead.
202
      if ($errors = $this->validateOperation($config, $params, $document)) {
203
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
204
      }
205
206
      // Only queries can be cached (mutations and subscriptions can't).
207
      if ($type === 'query') {
208
        return $this->executeCacheableOperation($adapter, $config, $params, $document);
209
      }
210
211
      return $this->executeUncachableOperation($adapter, $config, $params, $document);
212
    }
213
    catch (CacheableRequestError $exception) {
214
      return $adapter->createFulfilled(
215
        new QueryResult(NULL, [Error::createLocatedError($exception)], [], $exception)
216
      );
217
    }
218
    catch (RequestError $exception) {
219
      return $adapter->createFulfilled(new QueryResult(NULL, [Error::createLocatedError($exception)]));
220
    }
221
    catch (Error $exception) {
222
      return $adapter->createFulfilled(new QueryResult(NULL, [$exception]));
223
    }
224
  }
225
226
  /**
227
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
228
   * @param \GraphQL\Server\ServerConfig $config
229
   * @param \GraphQL\Server\OperationParams $params
230
   * @param \GraphQL\Language\AST\DocumentNode $document
231
   *
232
   * @return \GraphQL\Executor\Promise\Promise|mixed
233
   */
234
  protected function executeCacheableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
235
    $contextCacheId = 'ccid:' . $this->cacheIdentifier($params, $document);
236
    if (!$config->getDebug() && $contextCache = $this->cacheBackend->get($contextCacheId)) {
237
      $contexts = $contextCache->data ?: [];
238
      $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
239
      if ($cache = $this->cacheBackend->get($cid)) {
240
        return $adapter->createFulfilled($cache->data);
241
      }
242
    }
243
244
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
245
    return $result->then(function (QueryResult $result) use ($contextCacheId, $params, $document) {
246
      // Write this query into the cache if it is cacheable.
247
      if ($result->getCacheMaxAge() !== 0) {
248
        $contexts = $result->getCacheContexts();
249
        $expire = $this->maxAgeToExpire($result->getCacheMaxAge());
250
        $tags = $result->getCacheTags();
251
        $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
252
        $this->cacheBackend->set($contextCacheId, $contexts, $expire, $tags);
253
        $this->cacheBackend->set($cid, $result, $expire, $tags);
254
      }
255
256
      return $result;
257
    });
258
  }
259
260
  /**
261
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
262
   * @param \GraphQL\Server\ServerConfig $config
263
   * @param \GraphQL\Server\OperationParams $params
264
   * @param \GraphQL\Language\AST\DocumentNode $document
265
   *
266
   * @return \GraphQL\Executor\Promise\Promise
267
   */
268
  protected function executeUncachableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
269
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
270
    return $result->then(function (QueryResult $result) {
271
      // Mark the query result as uncacheable.
272
      $result->mergeCacheMaxAge(0);
273
      return $result;
274
    });
275
  }
276
277
  /**
278
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
279
   * @param \GraphQL\Server\ServerConfig $config
280
   * @param \GraphQL\Server\OperationParams $params
281
   * @param \GraphQL\Language\AST\DocumentNode $document
282
   *
283
   * @return \GraphQL\Executor\Promise\Promise
284
   */
285
  protected function doExecuteOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
286
    $operation = $params->operation;
287
    $variables = $params->variables;
288
    $context = $this->resolveContextValue($config, $params, $document, $operation);
289
    $root = $this->resolveRootValue($config, $params, $document, $operation);
290
    $resolver = $config->getFieldResolver();
291
    $schema = $config->getSchema();
292
293
    $promise = Executor::promiseToExecute(
294
      $adapter,
295
      $schema,
296
      $document,
297
      $root,
298
      $context,
299
      $variables,
300
      $operation,
301
      $resolver
302
    );
303
304
    return $promise->then(function (ExecutionResult $result) use ($context) {
305
      $metadata = (new CacheableMetadata())
306
        ->addCacheContexts($this->filterCacheContexts($context->getCacheContexts()))
307
        ->addCacheTags($context->getCacheTags())
308
        ->setCacheMaxAge($context->getCacheMaxAge());
309
310
      // Do not cache in development mode or if there are any errors.
311
      if ($context->getGlobal('development') || !empty($result->errors)) {
312
        $metadata->setCacheMaxAge(0);
313
      }
314
315
      return new QueryResult($result->data, $result->errors, $result->extensions, $metadata);
316
    });
317
  }
318
319
  /**
320
   * @param \GraphQL\Server\OperationParams $params
321
   *
322
   * @return array
323
   */
324
  protected function validateOperationParams(OperationParams $params) {
325
    $errors = (new Helper())->validateOperationParams($params);
326
    return array_map(function (RequestError $error) {
327
      return Error::createLocatedError($error, NULL, NULL);
328
    }, $errors);
329
  }
330
331
  /**
332
   * @param \GraphQL\Server\ServerConfig $config
333
   * @param \GraphQL\Server\OperationParams $params
334
   * @param \GraphQL\Language\AST\DocumentNode $document
335
   *
336
   * @return \GraphQL\Error\Error[]
337
   * @throws \Exception
338
   */
339
  protected function validateOperation(ServerConfig $config, OperationParams $params, DocumentNode $document) {
340
    $operation = $params->operation;
341
    // Skip validation if there are no validation rules to be applied.
342
    if (!$rules = $this->resolveValidationRules($config, $params, $document, $operation)) {
343
      return [];
344
    }
345
346
    $schema = $config->getSchema();
347
    $info = new TypeInfo($schema);
348
    $validation = new ValidationContext($schema, $document, $info);
349
    $visitors = array_values(array_map(function (AbstractValidationRule $rule) use ($validation) {
350
      return $rule($validation);
351
    }, $rules));
352
353
    // Run the query visitor with the prepared validation rules and the cache
354
    // metadata collector and query complexity calculator.
355
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, Visitor::visitInParallel($visitors)));
356
357
    // Return any possible errors collected during validation.
358
    return $validation->getErrors();
359
  }
360
361
  /**
362
   * @param \GraphQL\Server\ServerConfig $config
363
   * @param \GraphQL\Server\OperationParams $params
364
   * @param \GraphQL\Language\AST\DocumentNode $document
365
   * @param $operation
366
   *
367
   * @return mixed
368
   */
369 View Code Duplication
  protected function resolveRootValue(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
370
    $root = $config->getRootValue();
371
    if (is_callable($root)) {
372
      $root = $root($params, $document, $operation);
373
    }
374
375
    return $root;
376
  }
377
378
  /**
379
   * @param \GraphQL\Server\ServerConfig $config
380
   * @param \GraphQL\Server\OperationParams $params
381
   * @param \GraphQL\Language\AST\DocumentNode $document
382
   * @param $operation
383
   *
384
   * @return mixed
385
   */
386 View Code Duplication
  protected function resolveContextValue(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
387
    $context = $config->getContext();
388
    if (is_callable($context)) {
389
      $context = $context($params, $document, $operation);
390
    }
391
392
    return $context;
393
  }
394
395
  /**
396
   * @param \GraphQL\Server\ServerConfig $config
397
   * @param \GraphQL\Server\OperationParams $params
398
   * @param \GraphQL\Language\AST\DocumentNode $document
399
   * @param $operation
400
   *
401
   * @return array
402
   */
403
  protected function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
404
    // Allow customizing validation rules per operation:
405
    $rules = $config->getValidationRules();
406
    if (is_callable($rules)) {
407
      $rules = $rules($params, $document, $operation);
408
      if (!is_array($rules)) {
409
        throw new \LogicException(sprintf("Expecting validation rules to be array or callable returning array, but got: %s", Utils::printSafe($rules)));
410
      }
411
    }
412
413
    return $rules;
414
  }
415
416
  /**
417
   * @param \GraphQL\Server\ServerConfig $config
418
   * @param \GraphQL\Server\OperationParams $params
419
   *
420
   * @return mixed
421
   * @throws \GraphQL\Server\RequestError
422
   */
423
  protected function loadPersistedQuery(ServerConfig $config, OperationParams $params) {
424
    if (!$loader = $config->getPersistentQueryLoader()) {
425
      throw new RequestError('Persisted queries are not supported by this server.');
426
    }
427
428
    $source = $loader($params->queryId, $params);
429
    if (!is_string($source) && !$source instanceof DocumentNode) {
430
      throw new \LogicException(sprintf('The persisted query loader must return query string or instance of %s but got: %s.', DocumentNode::class, Utils::printSafe($source)));
431
    }
432
433
    return $source;
434
  }
435
436
  /**
437
   * @param \GraphQL\Language\AST\DocumentNode $document
438
   *
439
   * @return array
440
   */
441
  protected function serializeDocument(DocumentNode $document) {
442
    return $this->sanitizeRecursive(AST::toArray($document));
443
  }
444
445
  /**
446
   * @param array $item
447
   *
448
   * @return array
449
   */
450
  protected function sanitizeRecursive(array $item) {
451
    unset($item['loc']);
452
453
    foreach ($item as &$value) {
454
      if (is_array($value)) {
455
        $value = $this->sanitizeRecursive($value);
456
      }
457
    }
458
459
    return $item;
460
  }
461
462
  /**
463
   * @param \GraphQL\Server\OperationParams $params
464
   * @param \GraphQL\Language\AST\DocumentNode $document
465
   * @param array $contexts
466
   *
467
   * @return string
468
   */
469
  protected function cacheIdentifier(OperationParams $params, DocumentNode $document, array $contexts = []) {
470
    // Ignore language contexts since they are handled by graphql internally.
471
    $contexts = $this->filterCacheContexts($contexts);
472
    $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys();
473
474
    // Sorting the variables will cause fewer cache vectors.
475
    $variables = $params->variables ?: [];
476
    ksort($variables);
477
478
    // Prepend the hash of the serialized document to the cache contexts.
479
    $hash = hash('sha256', json_encode([
480
      'query' => $this->serializeDocument($document),
481
      'variables' => $variables,
482
    ]));
483
484
    return implode(':', array_values(array_merge([$hash], $keys)));
485
  }
486
487
  /**
488
   * Filter unused contexts.
489
   *
490
   * Removes the language contexts from a list of context ids.
491
   *
492
   * @param string[] $contexts
493
   *   The list of context id's.
494
   *
495
   * @return string[]
496
   *   The filtered list of context id's.
497
   */
498
  protected function filterCacheContexts(array $contexts) {
499
    return array_filter($contexts, function ($context) {
500
      return strpos($context, 'languages:') !== 0;
501
    });
502
  }
503
504
  /**
505
   * Maps a cache max age value to an "expire" value for the Cache API.
506
   *
507
   * @param int $maxAge
508
   *
509
   * @return int
510
   *   A corresponding "expire" value.
511
   *
512
   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
513
   */
514
  protected function maxAgeToExpire($maxAge) {
515
    $time = $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME');
516
    return ($maxAge === Cache::PERMANENT) ? Cache::PERMANENT : (int) $time + $maxAge;
517
  }
518
}
519