Completed
Pull Request — 8.x-3.x (#586)
by Philipp
02:34
created

QueryProcessor::executeCacheableOperation()   C

Complexity

Conditions 7
Paths 3

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 13
nc 3
nop 4
dl 0
loc 22
rs 6.9811
c 0
b 0
f 0
1
<?php
2
3
namespace Drupal\graphql\GraphQL\Execution;
4
5
use Drupal\Core\Cache\CacheableDependencyInterface;
6
use Drupal\Core\Cache\CacheableMetadata;
7
use Drupal\Core\Cache\CacheBackendInterface;
8
use Drupal\Core\Cache\Context\CacheContextsManager;
9
use Drupal\Core\Session\AccountProxyInterface;
10
use Drupal\graphql\Plugin\SchemaPluginManager;
11
use Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface;
12
use GraphQL\Error\Error;
13
use GraphQL\Error\FormattedError;
14
use GraphQL\Executor\ExecutionResult;
15
use GraphQL\Executor\Executor;
16
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
17
use GraphQL\Executor\Promise\PromiseAdapter;
18
use GraphQL\Language\AST\DocumentNode;
19
use GraphQL\Language\Parser;
20
use GraphQL\Language\Visitor;
21
use GraphQL\Server\Helper;
22
use GraphQL\Server\OperationParams;
23
use GraphQL\Server\RequestError;
24
use GraphQL\Server\ServerConfig;
25
use GraphQL\Utils\AST;
26
use GraphQL\Utils\TypeInfo;
27
use GraphQL\Utils\Utils;
28
use GraphQL\Validator\DocumentValidator;
29
use GraphQL\Validator\Rules\AbstractValidationRule;
30
use GraphQL\Validator\ValidationContext;
31
32
// TODO: Refactor this and clean it up.
33
class QueryProcessor {
34
35
  /**
36
   * The current user account.
37
   *
38
   * @var \Drupal\Core\Session\AccountProxyInterface
39
   */
40
  protected $currentUser;
41
42
  /**
43
   * The schema plugin manager.
44
   *
45
   * @var \Drupal\graphql\Plugin\SchemaPluginManager
46
   */
47
  protected $pluginManager;
48
49
  /**
50
   * The query provider service.
51
   *
52
   * @var \Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface
53
   */
54
  protected $queryProvider;
55
56
  /**
57
   * The cache backend for caching query results.
58
   *
59
   * @var \Drupal\Core\Cache\CacheBackendInterface
60
   */
61
  protected $cacheBackend;
62
63
  /**
64
   * The cache contexts manager service.
65
   *
66
   * @var \Drupal\Core\Cache\Context\CacheContextsManager
67
   */
68
  protected $contextsManager;
69
70
  /**
71
   * The configuration service parameter.
72
   *
73
   * @var array
74
   */
75
  protected $config;
76
77
  /**
78
   * Processor constructor.
79
   *
80
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
81
   *   The current user.
82
   * @param \Drupal\Core\Cache\Context\CacheContextsManager $contextsManager
83
   *   The cache contexts manager service.
84
   * @param \Drupal\graphql\Plugin\SchemaPluginManager $pluginManager
85
   *   The schema plugin manager.
86
   * @param \Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface $queryProvider
87
   *   The query provider service.
88
   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
89
   *   The cache backend for caching query results.
90
   * @param array $config
91
   *   The configuration service parameter.
92
   */
93
  public function __construct(
94
    AccountProxyInterface $currentUser,
95
    CacheContextsManager $contextsManager,
96
    SchemaPluginManager $pluginManager,
97
    QueryProviderInterface $queryProvider,
98
    CacheBackendInterface $cacheBackend,
99
    array $config
100
  ) {
101
    $this->currentUser = $currentUser;
102
    $this->contextsManager = $contextsManager;
103
    $this->pluginManager = $pluginManager;
104
    $this->queryProvider = $queryProvider;
105
    $this->cacheBackend = $cacheBackend;
106
    $this->config = $config;
107
  }
108
109
  /**
110
   * Processes one or multiple graphql operations.
111
   *
112
   * @param string $schema
113
   *   The plugin id of the schema to use.
114
   * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params
115
   *   The graphql operation(s) to execute.
116
   * @param array $globals
117
   *   The query context.
118
   *
119
   * @return \Drupal\graphql\GraphQL\Execution\QueryResult|\Drupal\graphql\GraphQL\Execution\QueryResult[]
120
   *   The query result.
121
   *
122
   */
123
  public function processQuery($schema, $params, array $globals = []) {
124
    // Load the plugin from the schema manager.
125
    $plugin = $this->pluginManager->createInstance($schema);
126
    $schema = $plugin->getSchema();
127
128
    // If the current user has appropriate permissions, allow to bypass
129
    // the secure fields restriction.
130
    $globals['bypass field security'] = $this->currentUser->hasPermission('bypass graphql field security');
131
132
    // Create the server config.
133
    $config = ServerConfig::create();
134
    $config->setDebug(!empty($this->config['development']));
135
    $config->setSchema($schema);
136
    $config->setQueryBatching(TRUE);
137
    $config->setContext(function () use ($globals, $plugin) {
138
      // Each document (e.g. in a batch query) gets its own resolve context but
139
      // the global parameters are shared. This allows us to collect the cache
140
      // metadata and contextual values (e.g. inheritance for language) for each
141
      // query separately.
142
      $context = new ResolveContext($globals);
143
      if ($plugin instanceof CacheableDependencyInterface) {
0 ignored issues
show
Bug introduced by
The class Drupal\Core\Cache\CacheableDependencyInterface does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
144
        $context->addCacheableDependency($plugin)->addCacheTags(['graphql_response']);
145
      }
146
147
      return $context;
148
    });
149
150
    $config->setValidationRules(function (OperationParams $params, DocumentNode $document, $operation) {
0 ignored issues
show
Unused Code introduced by
The parameter $document is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $operation is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
151
      if (isset($params->queryId)) {
152
        // Assume that pre-parsed documents are already validated. This allows
153
        // us to store pre-validated query documents e.g. for persisted queries
154
        // effectively improving performance by skipping run-time validation.
155
        return [];
156
      }
157
158
      return array_values(DocumentValidator::defaultRules());
159
    });
160
161
    $config->setPersistentQueryLoader(function ($id, OperationParams $params) {
162
      if ($query = $this->queryProvider->getQuery($id, $params)) {
163
        return $query;
164
      }
165
166
      throw new RequestError(sprintf("Failed to load query map for id '%s'.", $id));
167
    });
168
169
    if (is_array($params)) {
170
      return $this->executeBatch($config, $params);
171
    }
172
173
    return $this->executeSingle($config, $params);
174
  }
175
176
  /**
177
   * @param \GraphQL\Server\ServerConfig $config
178
   * @param \GraphQL\Server\OperationParams $params
179
   *
180
   * @return mixed
181
   */
182
  public function executeSingle(ServerConfig $config, OperationParams $params) {
183
    $adapter = new SyncPromiseAdapter();
184
    $result = $this->executeOperationWithReporting($adapter, $config, $params, FALSE);
185
    return $adapter->wait($result);
186
  }
187
188
  /**
189
   * @param \GraphQL\Server\ServerConfig $config
190
   * @param array $params
191
   *
192
   * @return mixed
193
   */
194
  public function executeBatch(ServerConfig $config, array $params) {
195
    $adapter = new SyncPromiseAdapter();
196
    $result = array_map(function ($params) use ($adapter, $config) {
197
      return $this->executeOperationWithReporting($adapter, $config, $params, TRUE);
198
    }, $params);
199
200
    $result = $adapter->all($result);
201
    return $adapter->wait($result);
202
  }
203
204
  /**
205
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
206
   * @param \GraphQL\Server\ServerConfig $config
207
   * @param \GraphQL\Server\OperationParams $params
208
   * @param bool $batching
209
   *
210
   * @return \GraphQL\Executor\Promise\Promise
211
   */
212
  protected function executeOperationWithReporting(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
213
    $result = $this->executeOperation($adapter, $config, $params, $batching);
214
215
    // Format and print errors.
216
    return $result->then(function(QueryResult $result) use ($config) {
217
      if ($config->getErrorsHandler()) {
218
        $result->setErrorsHandler($config->getErrorsHandler());
219
      }
220
221
      if ($config->getErrorFormatter() || $config->getDebug()) {
222
        $result->setErrorFormatter(FormattedError::prepareFormatter($config->getErrorFormatter(), $config->getDebug()));
223
      }
224
225
      return $result;
226
    });
227
  }
228
229
  /**
230
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
231
   * @param \GraphQL\Server\ServerConfig $config
232
   * @param \GraphQL\Server\OperationParams $params
233
   * @param bool $batching
234
   *
235
   * @return \GraphQL\Executor\Promise\Promise
236
   */
237
  protected function executeOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
238
    try {
239
      if (!$config->getSchema()) {
240
        throw new \LogicException('Missing schema for query execution.');
241
      }
242
243
      if ($batching && !$config->getQueryBatching()) {
244
        throw new RequestError('Batched queries are not supported by this server.');
245
      }
246
247
      if ($errors = $this->validateOperationParams($params)) {
248
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
249
      }
250
251
      $document = $params->queryId ? $this->loadPersistedQuery($config, $params) : $params->query;
252
      if (!$document instanceof DocumentNode) {
253
        $document = Parser::parse($document);
254
      }
255
256
      // Read the operation type from the document. Subscriptions and mutations
257
      // only work through POST requests. One cannot have mutations and queries
258
      // in the same document, hence this check is sufficient.
259
      $operation = $params->operation;
260
      $type = AST::getOperation($document, $operation);
261
      if ($params->isReadOnly() && $type !== 'query') {
262
        throw new RequestError('GET requests are only supported for query operations.');
263
      }
264
265
      // If one of the validation rules found any problems, do not resolve the
266
      // query and bail out early instead.
267
      if ($errors = $this->validateOperation($config, $params, $document)) {
268
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
269
      }
270
271
      // Only queries can be cached (mutations and subscriptions can't).
272
      if ($type === 'query') {
273
        return $this->executeCacheableOperation($adapter, $config, $params, $document);
274
      }
275
276
      return $this->executeUncachableOperation($adapter, $config, $params, $document);
277
    }
278
    catch (RequestError $exception) {
279
      return $adapter->createFulfilled(new QueryResult(NULL, [Error::createLocatedError($exception)]));
280
    }
281
    catch (Error $exception) {
282
      return $adapter->createFulfilled(new QueryResult(NULL, [$exception]));
283
    }
284
  }
285
286
  /**
287
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
288
   * @param \GraphQL\Server\ServerConfig $config
289
   * @param \GraphQL\Server\OperationParams $params
290
   * @param \GraphQL\Language\AST\DocumentNode $document
291
   *
292
   * @return \GraphQL\Executor\Promise\Promise|mixed
293
   */
294
  protected function executeCacheableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
295
    $contextCacheId = 'ccid:' . $this->cacheIdentifier($params, $document, new CacheableMetadata());
296
297
    if (!$config->getDebug() && ($contextCache = $this->cacheBackend->get($contextCacheId)) && $contexts = $contextCache->data) {
298
      $cacheId = 'cid:' . $this->cacheIdentifier($params, $document, (new CacheableMetadata())->addCacheContexts($contexts));
299
      if (($cache = $this->cacheBackend->get($cacheId)) && $result = $cache->data) {
300
        return $adapter->createFulfilled($result);
301
      }
302
    }
303
304
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
305
306
    return $result->then(function (QueryResult $result) use ($contextCacheId, $params, $document) {
307
      // Write this query into the cache if it is cacheable.
308
      if ($result->getCacheMaxAge() !== 0) {
309
        $cacheId = 'cid:' . $this->cacheIdentifier($params, $document, (new CacheableMetadata())->addCacheContexts($result->getCacheContexts()));
310
        $this->cacheBackend->set($contextCacheId, $result->getCacheContexts(), $result->getCacheMaxAge(), $result->getCacheTags());
311
        $this->cacheBackend->set($cacheId, $result, $result->getCacheMaxAge(), $result->getCacheTags());
312
      }
313
      return $result;
314
    });
315
  }
316
317
  /**
318
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
319
   * @param \GraphQL\Server\ServerConfig $config
320
   * @param \GraphQL\Server\OperationParams $params
321
   * @param \GraphQL\Language\AST\DocumentNode $document
322
   *
323
   * @return \GraphQL\Executor\Promise\Promise
324
   */
325
  protected function executeUncachableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
326
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
327
    return $result->then(function (QueryResult $result) {
328
      // Mark the query result as uncacheable.
329
      $result->mergeCacheMaxAge(0);
330
      return $result;
331
    });
332
  }
333
334
  /**
335
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
336
   * @param \GraphQL\Server\ServerConfig $config
337
   * @param \GraphQL\Server\OperationParams $params
338
   * @param \GraphQL\Language\AST\DocumentNode $document
339
   *
340
   * @return \GraphQL\Executor\Promise\Promise
341
   */
342
  protected function doExecuteOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
343
    $operation = $params->operation;
344
    $variables = $params->variables;
345
    $context = $this->resolveContextValue($config, $params, $document, $operation);
346
    $root = $this->resolveRootValue($config, $params, $document, $operation);
347
    $resolver = $config->getFieldResolver();
348
    $schema = $config->getSchema();
349
350
    $promise = Executor::promiseToExecute(
351
      $adapter,
352
      $schema,
353
      $document,
354
      $root,
355
      $context,
356
      $variables,
357
      $operation,
358
      $resolver
359
    );
360
361
    return $promise->then(function (ExecutionResult $result) use ($context) {
362
363
      $metadata = (new CacheableMetadata())
364
        ->addCacheContexts($this->filterCacheContexts($context->getCacheContexts()))
365
        ->addCacheTags($context->getCacheTags())
366
        ->setCacheMaxAge($context->getCacheMaxAge());
367
368
      // Do not cache in development mode or if there are any errors.
369
      if ($context->getGlobal('development') || !empty($result->errors)) {
370
        $metadata->setCacheMaxAge(0);
371
      }
372
373
      return new QueryResult($result->data, $result->errors, $result->extensions, $metadata);
374
    });
375
  }
376
377
  /**
378
   * @param \GraphQL\Server\OperationParams $params
379
   *
380
   * @return array
381
   */
382
  protected function validateOperationParams(OperationParams $params) {
383
    $errors = (new Helper())->validateOperationParams($params);
384
    return array_map(function (RequestError $error) {
385
      return Error::createLocatedError($error, NULL, NULL);
386
    }, $errors);
387
  }
388
389
  /**
390
   * @param \GraphQL\Server\ServerConfig $config
391
   * @param \GraphQL\Server\OperationParams $params
392
   * @param \GraphQL\Language\AST\DocumentNode $document
393
   *
394
   * @return \GraphQL\Error\Error[]
395
   */
396
  protected function validateOperation(ServerConfig $config, OperationParams $params, DocumentNode $document) {
397
    $operation = $params->operation;
398
    // Skip validation if there are no validation rules to be applied.
399
    if (!$rules = $this->resolveValidationRules($config, $params, $document, $operation)) {
400
      return [];
401
    }
402
403
    $schema = $config->getSchema();
404
    $info = new TypeInfo($schema);
405
    $validation = new ValidationContext($schema, $document, $info);
406
    $visitors = array_values(array_map(function (AbstractValidationRule $rule) use ($validation) {
407
      return $rule($validation);
408
    }, $rules));
409
410
    // Run the query visitor with the prepared validation rules and the cache
411
    // metadata collector and query complexity calculator.
412
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, Visitor::visitInParallel($visitors)));
413
414
    // Return any possible errors collected during validation.
415
    return $validation->getErrors();
416
  }
417
418
  /**
419
   * @param \GraphQL\Server\ServerConfig $config
420
   * @param \GraphQL\Server\OperationParams $params
421
   * @param \GraphQL\Language\AST\DocumentNode $document
422
   * @param $operation
423
   *
424
   * @return mixed
425
   */
426 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...
427
    $root = $config->getRootValue();
428
    if (is_callable($root)) {
429
      $root = $root($params, $document, $operation);
430
    }
431
432
    return $root;
433
  }
434
435
  /**
436
   * @param \GraphQL\Server\ServerConfig $config
437
   * @param \GraphQL\Server\OperationParams $params
438
   * @param \GraphQL\Language\AST\DocumentNode $document
439
   * @param $operation
440
   *
441
   * @return mixed
442
   */
443 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...
444
    $context = $config->getContext();
445
    if (is_callable($context)) {
446
      $context = $context($params, $document, $operation);
447
    }
448
449
    return $context;
450
  }
451
452
  /**
453
   * @param \GraphQL\Server\ServerConfig $config
454
   * @param \GraphQL\Server\OperationParams $params
455
   * @param \GraphQL\Language\AST\DocumentNode $document
456
   * @param $operation
457
   *
458
   * @return array
459
   */
460
  protected function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
461
    // Allow customizing validation rules per operation:
462
    $rules = $config->getValidationRules();
463
    if (is_callable($rules)) {
464
      $rules = $rules($params, $document, $operation);
465
      if (!is_array($rules)) {
466
        throw new \LogicException(sprintf("Expecting validation rules to be array or callable returning array, but got: %s", Utils::printSafe($rules)));
467
      }
468
    }
469
470
    return $rules;
471
  }
472
473
  /**
474
   * @param \GraphQL\Server\ServerConfig $config
475
   * @param \GraphQL\Server\OperationParams $params
476
   *
477
   * @return mixed
478
   * @throws \GraphQL\Server\RequestError
479
   */
480
  protected function loadPersistedQuery(ServerConfig $config, OperationParams $params) {
481
    if (!$loader = $config->getPersistentQueryLoader()) {
482
      throw new RequestError('Persisted queries are not supported by this server.');
483
    }
484
485
    $source = $loader($params->queryId, $params);
486
    if (!is_string($source) && !$source instanceof DocumentNode) {
487
      throw new \LogicException(sprintf('The persisted query loader must return query string or instance of %s but got: %s.', DocumentNode::class, Utils::printSafe($source)));
488
    }
489
490
    return $source;
491
  }
492
493
  /**
494
   * @param \GraphQL\Language\AST\DocumentNode $document
495
   *
496
   * @return array
497
   */
498
  protected function serializeDocument(DocumentNode $document) {
499
    return $this->sanitizeRecursive(AST::toArray($document));
500
  }
501
502
  /**
503
   * @param array $item
504
   *
505
   * @return array
506
   */
507
  protected function sanitizeRecursive(array $item) {
508
    unset($item['loc']);
509
510
    foreach ($item as &$value) {
511
      if (is_array($value)) {
512
        $value = $this->sanitizeRecursive($value);
513
      }
514
    }
515
516
    return $item;
517
  }
518
519
  /**
520
   * @param \GraphQL\Server\OperationParams $params
521
   * @param \GraphQL\Language\AST\DocumentNode $document
522
   * @param \Drupal\Core\Cache\CacheableMetadata $metadata
523
   *
524
   * @return string
525
   */
526
  protected function cacheIdentifier(OperationParams $params, DocumentNode $document, CacheableMetadata $metadata) {
527
    // Ignore language contexts since they are handled by graphql internally.
528
    $contexts = $this->filterCacheContexts($metadata->getCacheContexts());
529
    $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys();
530
531
    // Sorting the variables will cause fewer cache vectors.
532
    $variables = $params->variables ?: [];
533
    ksort($variables);
534
535
    // Prepend the hash of the serialized document to the cache contexts.
536
    $hash = hash('sha256', json_encode([
537
      'query' => $this->serializeDocument($document),
538
      'variables' => $variables,
539
    ]));
540
541
    return implode(':', array_values(array_merge([$hash], $keys)));
542
  }
543
544
  /**
545
   * Filter unused contexts.
546
   *
547
   * Removes the language contexts from a list of context ids.
548
   *
549
   * @param string[] $contexts
550
   *   The list of context id's.
551
   *
552
   * @return string[]
553
   *   The filtered list of context id's.
554
   */
555
  protected function filterCacheContexts(array $contexts) {
556
    return array_filter($contexts, function ($context) {
557
      return strpos($context, 'languages:') !== 0;
558
    });
559
  }
560
}
561