Completed
Push — 8.x-3.x ( a73523...92e606 )
by Sebastian
01:53
created

QueryProcessor::doExecuteOperation()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 39

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
nc 2
nop 5
dl 0
loc 39
rs 8.9848
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
      $persisted = isset($params->queryId);
187
      $document = $persisted ? $this->loadPersistedQuery($config, $params) : $params->query;
188
      if (!$document instanceof DocumentNode) {
189
        $document = Parser::parse($document);
190
      }
191
192
      // Read the operation type from the document. Subscriptions and mutations
193
      // only work through POST requests. One cannot have mutations and queries
194
      // in the same document, hence this check is sufficient.
195
      $operation = $params->operation;
196
      $type = AST::getOperation($document, $operation);
197
      if ($params->isReadOnly() && $type !== 'query') {
198
        throw new RequestError('GET requests are only supported for query operations.');
199
      }
200
201
      // Only queries can be cached (mutations and subscriptions can't).
202
      if ($type === 'query') {
203
        return $this->executeCacheableOperation($adapter, $config, $params, $document, !$persisted);
204
      }
205
206
      return $this->executeUncachableOperation($adapter, $config, $params, $document, !$persisted);
207
    }
208
    catch (CacheableRequestError $exception) {
209
      return $adapter->createFulfilled(
210
        new QueryResult(NULL, [Error::createLocatedError($exception)], [], $exception)
211
      );
212
    }
213
    catch (RequestError $exception) {
214
      return $adapter->createFulfilled(new QueryResult(NULL, [Error::createLocatedError($exception)]));
215
    }
216
    catch (Error $exception) {
217
      return $adapter->createFulfilled(new QueryResult(NULL, [$exception]));
218
    }
219
  }
220
221
  /**
222
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
223
   * @param \GraphQL\Server\ServerConfig $config
224
   * @param \GraphQL\Server\OperationParams $params
225
   * @param \GraphQL\Language\AST\DocumentNode $document
226
   * @param bool $validate
227
   *
228
   * @return \GraphQL\Executor\Promise\Promise|mixed
229
   */
230
  protected function executeCacheableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document, $validate = TRUE) {
231
    $contextCacheId = 'ccid:' . $this->cacheIdentifier($params, $document);
232
    if (!$config->getDebug() && $contextCache = $this->cacheBackend->get($contextCacheId)) {
233
      $contexts = $contextCache->data ?: [];
234
      $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
235
      if ($cache = $this->cacheBackend->get($cid)) {
236
        return $adapter->createFulfilled($cache->data);
237
      }
238
    }
239
240
    $result = $this->doExecuteOperation($adapter, $config, $params, $document, $validate);
241
    return $result->then(function (QueryResult $result) use ($contextCacheId, $params, $document) {
242
      // Write this query into the cache if it is cacheable.
243
      if ($result->getCacheMaxAge() !== 0) {
244
        $contexts = $result->getCacheContexts();
245
        $expire = $this->maxAgeToExpire($result->getCacheMaxAge());
246
        $tags = $result->getCacheTags();
247
        $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
248
        $this->cacheBackend->set($contextCacheId, $contexts, $expire, $tags);
249
        $this->cacheBackend->set($cid, $result, $expire, $tags);
250
      }
251
252
      return $result;
253
    });
254
  }
255
256
  /**
257
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
258
   * @param \GraphQL\Server\ServerConfig $config
259
   * @param \GraphQL\Server\OperationParams $params
260
   * @param \GraphQL\Language\AST\DocumentNode $document
261
   * @param bool $validate
262
   *
263
   * @return \GraphQL\Executor\Promise\Promise
264
   */
265
  protected function executeUncachableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document, $validate = TRUE) {
266
    $result = $this->doExecuteOperation($adapter, $config, $params, $document, $validate);
267
    return $result->then(function (QueryResult $result) {
268
      // Mark the query result as uncacheable.
269
      $result->mergeCacheMaxAge(0);
270
      return $result;
271
    });
272
  }
273
274
  /**
275
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
276
   * @param \GraphQL\Server\ServerConfig $config
277
   * @param \GraphQL\Server\OperationParams $params
278
   * @param \GraphQL\Language\AST\DocumentNode $document
279
   * @param bool $validate
280
   *
281
   * @return \GraphQL\Executor\Promise\Promise
282
   */
283
  protected function doExecuteOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document, $validate = TRUE) {
284
    // If one of the validation rules found any problems, do not resolve the
285
    // query and bail out early instead.
286
    if ($validate && $errors = $this->validateOperation($config, $params, $document)) {
287
      return $adapter->createFulfilled(new QueryResult(NULL, $errors));
288
    }
289
290
    $operation = $params->operation;
291
    $variables = $params->variables;
292
    $context = $this->resolveContextValue($config, $params, $document, $operation);
293
    $root = $this->resolveRootValue($config, $params, $document, $operation);
294
    $resolver = $config->getFieldResolver();
295
    $schema = $config->getSchema();
296
297
    $promise = Executor::promiseToExecute(
298
      $adapter,
299
      $schema,
300
      $document,
301
      $root,
302
      $context,
303
      $variables,
304
      $operation,
305
      $resolver
306
    );
307
308
    return $promise->then(function (ExecutionResult $result) use ($context) {
309
      $metadata = (new CacheableMetadata())
310
        ->addCacheContexts($context->getCacheContexts())
311
        ->addCacheTags($context->getCacheTags())
312
        ->setCacheMaxAge($context->getCacheMaxAge());
313
314
      // Do not cache in development mode or if there are any errors.
315
      if ($context->getGlobal('development') || !empty($result->errors)) {
316
        $metadata->setCacheMaxAge(0);
317
      }
318
319
      return new QueryResult($result->data, $result->errors, $result->extensions, $metadata);
320
    });
321
  }
322
323
  /**
324
   * @param \GraphQL\Server\OperationParams $params
325
   *
326
   * @return array
327
   */
328
  protected function validateOperationParams(OperationParams $params) {
329
    $errors = (new Helper())->validateOperationParams($params);
330
    return array_map(function (RequestError $error) {
331
      return Error::createLocatedError($error, NULL, NULL);
332
    }, $errors);
333
  }
334
335
  /**
336
   * @param \GraphQL\Server\ServerConfig $config
337
   * @param \GraphQL\Server\OperationParams $params
338
   * @param \GraphQL\Language\AST\DocumentNode $document
339
   *
340
   * @return \GraphQL\Error\Error[]
341
   * @throws \Exception
342
   */
343
  protected function validateOperation(ServerConfig $config, OperationParams $params, DocumentNode $document) {
344
    $operation = $params->operation;
345
    // Skip validation if there are no validation rules to be applied.
346
    if (!$rules = $this->resolveValidationRules($config, $params, $document, $operation)) {
347
      return [];
348
    }
349
350
    $schema = $config->getSchema();
351
    $info = new TypeInfo($schema);
352
    $validation = new ValidationContext($schema, $document, $info);
353
    $visitors = array_values(array_map(function (AbstractValidationRule $rule) use ($validation) {
354
      return $rule($validation);
355
    }, $rules));
356
357
    // Run the query visitor with the prepared validation rules and the cache
358
    // metadata collector and query complexity calculator.
359
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, Visitor::visitInParallel($visitors)));
360
361
    // Return any possible errors collected during validation.
362
    return $validation->getErrors();
363
  }
364
365
  /**
366
   * @param \GraphQL\Server\ServerConfig $config
367
   * @param \GraphQL\Server\OperationParams $params
368
   * @param \GraphQL\Language\AST\DocumentNode $document
369
   * @param $operation
370
   *
371
   * @return mixed
372
   */
373 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...
374
    $root = $config->getRootValue();
375
    if (is_callable($root)) {
376
      $root = $root($params, $document, $operation);
377
    }
378
379
    return $root;
380
  }
381
382
  /**
383
   * @param \GraphQL\Server\ServerConfig $config
384
   * @param \GraphQL\Server\OperationParams $params
385
   * @param \GraphQL\Language\AST\DocumentNode $document
386
   * @param $operation
387
   *
388
   * @return mixed
389
   */
390 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...
391
    $context = $config->getContext();
392
    if (is_callable($context)) {
393
      $context = $context($params, $document, $operation);
394
    }
395
396
    return $context;
397
  }
398
399
  /**
400
   * @param \GraphQL\Server\ServerConfig $config
401
   * @param \GraphQL\Server\OperationParams $params
402
   * @param \GraphQL\Language\AST\DocumentNode $document
403
   * @param $operation
404
   *
405
   * @return array
406
   */
407
  protected function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
408
    // Allow customizing validation rules per operation:
409
    $rules = $config->getValidationRules();
410
    if (is_callable($rules)) {
411
      $rules = $rules($params, $document, $operation);
412
      if (!is_array($rules)) {
413
        throw new \LogicException(sprintf("Expecting validation rules to be array or callable returning array, but got: %s", Utils::printSafe($rules)));
414
      }
415
    }
416
417
    return $rules;
418
  }
419
420
  /**
421
   * @param \GraphQL\Server\ServerConfig $config
422
   * @param \GraphQL\Server\OperationParams $params
423
   *
424
   * @return mixed
425
   * @throws \GraphQL\Server\RequestError
426
   */
427
  protected function loadPersistedQuery(ServerConfig $config, OperationParams $params) {
428
    if (!$loader = $config->getPersistentQueryLoader()) {
429
      throw new RequestError('Persisted queries are not supported by this server.');
430
    }
431
432
    $source = $loader($params->queryId, $params);
433
    if (!is_string($source) && !$source instanceof DocumentNode) {
434
      throw new \LogicException(sprintf('The persisted query loader must return query string or instance of %s but got: %s.', DocumentNode::class, Utils::printSafe($source)));
435
    }
436
437
    return $source;
438
  }
439
440
  /**
441
   * @param \GraphQL\Language\AST\DocumentNode $document
442
   *
443
   * @return array
444
   */
445
  protected function serializeDocument(DocumentNode $document) {
446
    return $this->sanitizeRecursive(AST::toArray($document));
447
  }
448
449
  /**
450
   * @param array $item
451
   *
452
   * @return array
453
   */
454
  protected function sanitizeRecursive(array $item) {
455
    unset($item['loc']);
456
457
    foreach ($item as &$value) {
458
      if (is_array($value)) {
459
        $value = $this->sanitizeRecursive($value);
460
      }
461
    }
462
463
    return $item;
464
  }
465
466
  /**
467
   * @param \GraphQL\Server\OperationParams $params
468
   * @param \GraphQL\Language\AST\DocumentNode $document
469
   * @param array $contexts
470
   *
471
   * @return string
472
   */
473
  protected function cacheIdentifier(OperationParams $params, DocumentNode $document, array $contexts = []) {
474
    // Ignore language contexts since they are handled by graphql internally.
475
    $contexts = $contexts;
0 ignored issues
show
Bug introduced by
Why assign $contexts to itself?

This checks looks for cases where a variable has been assigned to itself.

This assignement can be removed without consequences.

Loading history...
476
    $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys();
477
478
    // Sorting the variables will cause fewer cache vectors.
479
    $variables = $params->variables ?: [];
480
    ksort($variables);
481
482
    // Prepend the hash of the serialized document to the cache contexts.
483
    $hash = hash('sha256', json_encode([
484
      'query' => $this->serializeDocument($document),
485
      'variables' => $variables,
486
    ]));
487
488
    return implode(':', array_values(array_merge([$hash], $keys)));
489
  }
490
491
  /**
492
   * Filter unused contexts.
493
   *
494
   * Removes the language contexts from a list of context ids.
495
   *
496
   * @param string[] $contexts
497
   *   The list of context id's.
498
   *
499
   * @return string[]
500
   *   The filtered list of context id's.
501
   */
502
  protected function filterCacheContexts(array $contexts) {
503
    return array_filter($contexts, function ($context) {
504
      return strpos($context, 'languages:') !== 0;
505
    });
506
  }
507
508
  /**
509
   * Maps a cache max age value to an "expire" value for the Cache API.
510
   *
511
   * @param int $maxAge
512
   *
513
   * @return int
514
   *   A corresponding "expire" value.
515
   *
516
   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
517
   */
518
  protected function maxAgeToExpire($maxAge) {
519
    $time = $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME');
520
    return ($maxAge === Cache::PERMANENT) ? Cache::PERMANENT : (int) $time + $maxAge;
521
  }
522
}
523