Completed
Pull Request — 8.x-3.x (#525)
by Sebastian
05:04 queued 02:30
created

QueryProcessor::executeBatch()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 2
dl 0
loc 9
rs 9.6666
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\GraphQL\Visitors\CacheContextsCollector;
11
use Drupal\graphql\Plugin\SchemaPluginManager;
12
use Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface;
13
use GraphQL\Error\Error;
14
use GraphQL\Error\FormattedError;
15
use GraphQL\Executor\ExecutionResult;
16
use GraphQL\Executor\Executor;
17
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
18
use GraphQL\Executor\Promise\PromiseAdapter;
19
use GraphQL\Language\AST\DocumentNode;
20
use GraphQL\Language\Parser;
21
use GraphQL\Language\Visitor;
22
use GraphQL\Server\Helper;
23
use GraphQL\Server\OperationParams;
24
use GraphQL\Server\RequestError;
25
use GraphQL\Server\ServerConfig;
26
use GraphQL\Utils\AST;
27
use GraphQL\Utils\TypeInfo;
28
use GraphQL\Utils\Utils;
29
use GraphQL\Validator\DocumentValidator;
30
use GraphQL\Validator\Rules\AbstractValidationRule;
31
use GraphQL\Validator\ValidationContext;
32
33
// TODO: Refactor this and clean it up.
34
class QueryProcessor {
35
36
  /**
37
   * The current user account.
38
   *
39
   * @var \Drupal\Core\Session\AccountProxyInterface
40
   */
41
  protected $currentUser;
42
43
  /**
44
   * The schema plugin manager.
45
   *
46
   * @var \Drupal\graphql\Plugin\SchemaPluginManager
47
   */
48
  protected $pluginManager;
49
50
  /**
51
   * The query provider service.
52
   *
53
   * @var \Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface
54
   */
55
  protected $queryProvider;
56
57
  /**
58
   * The cache backend for caching query results.
59
   *
60
   * @var \Drupal\Core\Cache\CacheBackendInterface
61
   */
62
  protected $cacheBackend;
63
64
  /**
65
   * The cache contexts manager service.
66
   *
67
   * @var \Drupal\Core\Cache\Context\CacheContextsManager
68
   */
69
  protected $contextsManager;
70
71
  /**
72
   * The configuration service parameter.
73
   *
74
   * @var array
75
   */
76
  protected $config;
77
78
  /**
79
   * Processor constructor.
80
   *
81
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
82
   *   The current user.
83
   * @param \Drupal\Core\Cache\Context\CacheContextsManager $contextsManager
84
   *   The cache contexts manager service.
85
   * @param \Drupal\graphql\Plugin\SchemaPluginManager $pluginManager
86
   *   The schema plugin manager.
87
   * @param \Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface $queryProvider
88
   *   The query provider service.
89
   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
90
   *   The cache backend for caching query results.
91
   * @param array $config
92
   *   The configuration service parameter.
93
   */
94
  public function __construct(
95
    AccountProxyInterface $currentUser,
96
    CacheContextsManager $contextsManager,
97
    SchemaPluginManager $pluginManager,
98
    QueryProviderInterface $queryProvider,
99
    CacheBackendInterface $cacheBackend,
100
    array $config
101
  ) {
102
    $this->currentUser = $currentUser;
103
    $this->contextsManager = $contextsManager;
104
    $this->pluginManager = $pluginManager;
105
    $this->queryProvider = $queryProvider;
106
    $this->cacheBackend = $cacheBackend;
107
    $this->config = $config;
108
  }
109
110
  /**
111
   * Processes one or multiple graphql operations.
112
   *
113
   * @param string $schema
114
   *   The plugin id of the schema to use.
115
   * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params
116
   *   The graphql operation(s) to execute.
117
   * @param array $globals
118
   *   The query context.
119
   *
120
   * @return \Drupal\graphql\GraphQL\Execution\QueryResult|\Drupal\graphql\GraphQL\Execution\QueryResult[]
121
   *   The query result.
122
   *
123
   */
124
  public function processQuery($schema, $params, array $globals = []) {
125
    // Load the plugin from the schema manager.
126
    $plugin = $this->pluginManager->createInstance($schema);
127
    $schema = $plugin->getSchema();
128
129
    // If the current user has appropriate permissions, allow to bypass
130
    // the secure fields restriction.
131
    $globals['bypass field security'] = $this->currentUser->hasPermission('bypass graphql field security');
132
133
    // Create the server config.
134
    $config = ServerConfig::create();
135
    $config->setDebug(!empty($this->config['development']));
136
    $config->setSchema($schema);
137
    $config->setQueryBatching(TRUE);
138
    $config->setContext(function () use ($globals, $plugin) {
139
      // Each document (e.g. in a batch query) gets its own resolve context but
140
      // the global parameters are shared. This allows us to collect the cache
141
      // metadata and contextual values (e.g. inheritance for language) for each
142
      // query separately.
143
      $context = new ResolveContext($globals);
144
      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...
145
        $context->addCacheableDependency($plugin)->addCacheTags(['graphql_response']);
146
      }
147
148
      return $context;
149
    });
150
151
    $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...
152
      if (isset($params->queryId)) {
153
        // Assume that pre-parsed documents are already validated. This allows
154
        // us to store pre-validated query documents e.g. for persisted queries
155
        // effectively improving performance by skipping run-time validation.
156
        return [];
157
      }
158
159
      return array_values(DocumentValidator::defaultRules());
160
    });
161
162
    $config->setPersistentQueryLoader(function ($id, OperationParams $params) {
163
      if ($query = $this->queryProvider->getQuery($id, $params)) {
164
        return $query;
165
      }
166
167
      throw new RequestError(sprintf("Failed to load query map for id '%s'.", $id));
168
    });
169
170
    if (is_array($params)) {
171
      return $this->executeBatch($config, $params);
172
    }
173
174
    return $this->executeSingle($config, $params);
175
  }
176
177
  /**
178
   * @param \GraphQL\Server\ServerConfig $config
179
   * @param \GraphQL\Server\OperationParams $params
180
   *
181
   * @return mixed
182
   */
183
  public function executeSingle(ServerConfig $config, OperationParams $params) {
184
    $adapter = new SyncPromiseAdapter();
185
    $result = $this->executeOperationWithReporting($adapter, $config, $params, FALSE);
186
    return $adapter->wait($result);
187
  }
188
189
  /**
190
   * @param \GraphQL\Server\ServerConfig $config
191
   * @param array $params
192
   *
193
   * @return mixed
194
   */
195
  public function executeBatch(ServerConfig $config, array $params) {
196
    $adapter = new SyncPromiseAdapter();
197
    $result = array_map(function ($params) use ($adapter, $config) {
198
      return $this->executeOperationWithReporting($adapter, $config, $params, TRUE);
199
    }, $params);
200
201
    $result = $adapter->all($result);
202
    return $adapter->wait($result);
203
  }
204
205
  /**
206
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
207
   * @param \GraphQL\Server\ServerConfig $config
208
   * @param \GraphQL\Server\OperationParams $params
209
   * @param bool $batching
210
   *
211
   * @return \GraphQL\Executor\Promise\Promise
212
   */
213
  protected function executeOperationWithReporting(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
214
    $result = $this->executeOperation($adapter, $config, $params, $batching);
215
216
    // Format and print errors.
217
    return $result->then(function(QueryResult $result) use ($config) {
218
      if ($config->getErrorsHandler()) {
219
        $result->setErrorsHandler($config->getErrorsHandler());
220
      }
221
222
      if ($config->getErrorFormatter() || $config->getDebug()) {
223
        $result->setErrorFormatter(FormattedError::prepareFormatter($config->getErrorFormatter(), $config->getDebug()));
224
      }
225
226
      return $result;
227
    });
228
  }
229
230
  /**
231
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
232
   * @param \GraphQL\Server\ServerConfig $config
233
   * @param \GraphQL\Server\OperationParams $params
234
   * @param bool $batching
235
   *
236
   * @return \GraphQL\Executor\Promise\Promise
237
   */
238
  protected function executeOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
239
    try {
240
      if (!$config->getSchema()) {
241
        throw new \LogicException('Missing schema for query execution.');
242
      }
243
244
      if ($batching && !$config->getQueryBatching()) {
245
        throw new RequestError('Batched queries are not supported by this server.');
246
      }
247
248
      if ($errors = $this->validateOperationParams($params)) {
249
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
250
      }
251
252
      $document = $params->queryId ? $this->loadPersistedQuery($config, $params) : $params->query;
253
      if (!$document instanceof DocumentNode) {
254
        $document = Parser::parse($document);
255
      }
256
257
      // Read the operation type from the document. Subscriptions and mutations
258
      // only work through POST requests. One cannot have mutations and queries
259
      // in the same document, hence this check is sufficient.
260
      $operation = $params->operation;
261
      $type = AST::getOperation($document, $operation);
262
      if ($params->isReadOnly() && $type !== 'query') {
263
        throw new RequestError('GET requests are only supported for query operations.');
264
      }
265
266
      // If one of the validation rules found any problems, do not resolve the
267
      // query and bail out early instead.
268
      if ($errors = $this->validateOperation($config, $params, $document)) {
269
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
270
      }
271
272
      // Only queries can be cached (mutations and subscriptions can't).
273
      if ($type === 'query') {
274
        return $this->executeCacheableOperation($adapter, $config, $params, $document);
275
      }
276
277
      return $this->executeUncachableOperation($adapter, $config, $params, $document);
278
    }
279
    catch (RequestError $exception) {
280
      return $adapter->createFulfilled(new QueryResult(NULL, [Error::createLocatedError($exception)]));
281
    }
282
    catch (Error $exception) {
283
      return $adapter->createFulfilled(new QueryResult(NULL, [$exception]));
284
    }
285
  }
286
287
  /**
288
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
289
   * @param \GraphQL\Server\ServerConfig $config
290
   * @param \GraphQL\Server\OperationParams $params
291
   * @param \GraphQL\Language\AST\DocumentNode $document
292
   *
293
   * @return \GraphQL\Executor\Promise\Promise|mixed
294
   */
295
  protected function executeCacheableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
296
    $schema = $config->getSchema();
297
298
    // Collect cache contexts from the query document.
299
    $contexts = [];
300
    $info = new TypeInfo($schema);
301
    $visitor = (new CacheContextsCollector())->getVisitor($info, $contexts);
302
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, $visitor));
303
304
    // Generate a cache identifier from the collected contexts.
305
    $metadata = (new CacheableMetadata())->addCacheContexts($contexts);
306
    $cid = $this->cacheIdentifier($params, $document, $metadata);
307
    if (!$config->getDebug() && ($cache = $this->cacheBackend->get($cid)) && $result = $cache->data) {
308
      return $adapter->createFulfilled($result);
309
    }
310
311
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
312
    return $result->then(function (QueryResult $result) use ($cid, $metadata) {
313
      if ($missing = array_diff($result->getCacheContexts(), $metadata->getCacheContexts())) {
314
        throw new \LogicException(sprintf(
315
          'The query result yielded cache contexts (%s) that were not part of the static query analysis.',
316
          implode(', ', $missing)
317
        ));
318
      }
319
320
      // Add the statically collected cache contexts.
321
      $result->addCacheableDependency($metadata);
322
      // Write this query into the cache if it is cacheable.
323
      if ($result->getCacheMaxAge() !== 0) {
324
        $this->cacheBackend->set($cid, $result, $result->getCacheMaxAge(), $result->getCacheTags());
325
      }
326
327
      return $result;
328
    });
329
  }
330
331
  /**
332
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
333
   * @param \GraphQL\Server\ServerConfig $config
334
   * @param \GraphQL\Server\OperationParams $params
335
   * @param \GraphQL\Language\AST\DocumentNode $document
336
   *
337
   * @return \GraphQL\Executor\Promise\Promise
338
   */
339
  protected function executeUncachableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
340
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
341
    return $result->then(function (QueryResult $result) {
342
      // Mark the query result as uncacheable.
343
      $result->mergeCacheMaxAge(0);
344
      return $result;
345
    });
346
  }
347
348
  /**
349
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
350
   * @param \GraphQL\Server\ServerConfig $config
351
   * @param \GraphQL\Server\OperationParams $params
352
   * @param \GraphQL\Language\AST\DocumentNode $document
353
   *
354
   * @return \GraphQL\Executor\Promise\Promise
355
   */
356
  protected function doExecuteOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
357
    $operation = $params->operation;
358
    $variables = $params->variables;
359
    $context = $this->resolveContextValue($config, $params, $document, $operation);
360
    $root = $this->resolveRootValue($config, $params, $document, $operation);
361
    $resolver = $config->getFieldResolver();
362
    $schema = $config->getSchema();
363
364
    $promise = Executor::promiseToExecute(
365
      $adapter,
366
      $schema,
367
      $document,
368
      $root,
369
      $context,
370
      $variables,
371
      $operation,
372
      $resolver
373
    );
374
375
    return $promise->then(function (ExecutionResult $result) use ($context) {
376
      // Add the collected cache metadata to the result.
377
      $metadata = (new CacheableMetadata())->addCacheableDependency($context);
378
379
      // Do not cache in development mode or if there are any errors.
380
      if ($context->getGlobal('development') || !empty($result->errors)) {
381
        $metadata->setCacheMaxAge(0);
382
      }
383
384
      return new QueryResult($result->data, $result->errors, $result->extensions, $metadata);
385
    });
386
  }
387
388
  /**
389
   * @param \GraphQL\Server\OperationParams $params
390
   *
391
   * @return array
392
   */
393
  protected function validateOperationParams(OperationParams $params) {
394
    $errors = (new Helper())->validateOperationParams($params);
395
    return array_map(function (RequestError $error) {
396
      return Error::createLocatedError($error, NULL, NULL);
397
    }, $errors);
398
  }
399
400
  /**
401
   * @param \GraphQL\Server\ServerConfig $config
402
   * @param \GraphQL\Server\OperationParams $params
403
   * @param \GraphQL\Language\AST\DocumentNode $document
404
   *
405
   * @return \GraphQL\Error\Error[]
406
   */
407
  protected function validateOperation(ServerConfig $config, OperationParams $params, DocumentNode $document) {
408
    $operation = $params->operation;
409
    // Skip validation if there are no validation rules to be applied.
410
    if (!$rules = $this->resolveValidationRules($config, $params, $document, $operation)) {
411
      return [];
412
    }
413
414
    $schema = $config->getSchema();
415
    $info = new TypeInfo($schema);
416
    $validation = new ValidationContext($schema, $document, $info);
417
    $visitors = array_values(array_map(function (AbstractValidationRule $rule) use ($validation) {
418
      return $rule($validation);
419
    }, $rules));
420
421
    // Run the query visitor with the prepared validation rules and the cache
422
    // metadata collector and query complexity calculator.
423
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, Visitor::visitInParallel($visitors)));
424
425
    // Return any possible errors collected during validation.
426
    return $validation->getErrors();
427
  }
428
429
  /**
430
   * @param \GraphQL\Server\ServerConfig $config
431
   * @param \GraphQL\Server\OperationParams $params
432
   * @param \GraphQL\Language\AST\DocumentNode $document
433
   * @param $operation
434
   *
435
   * @return mixed
436
   */
437 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...
438
    $root = $config->getRootValue();
439
    if (is_callable($root)) {
440
      $root = $root($params, $document, $operation);
441
    }
442
443
    return $root;
444
  }
445
446
  /**
447
   * @param \GraphQL\Server\ServerConfig $config
448
   * @param \GraphQL\Server\OperationParams $params
449
   * @param \GraphQL\Language\AST\DocumentNode $document
450
   * @param $operation
451
   *
452
   * @return mixed
453
   */
454 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...
455
    $context = $config->getContext();
456
    if (is_callable($context)) {
457
      $context = $context($params, $document, $operation);
458
    }
459
460
    return $context;
461
  }
462
463
  /**
464
   * @param \GraphQL\Server\ServerConfig $config
465
   * @param \GraphQL\Server\OperationParams $params
466
   * @param \GraphQL\Language\AST\DocumentNode $document
467
   * @param $operation
468
   *
469
   * @return array
470
   */
471
  protected function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
472
    // Allow customizing validation rules per operation:
473
    $rules = $config->getValidationRules();
474
    if (is_callable($rules)) {
475
      $rules = $rules($params, $document, $operation);
476
      if (!is_array($rules)) {
477
        throw new \LogicException(sprintf("Expecting validation rules to be array or callable returning array, but got: %s", Utils::printSafe($rules)));
478
      }
479
    }
480
481
    return $rules;
482
  }
483
484
  /**
485
   * @param \GraphQL\Server\ServerConfig $config
486
   * @param \GraphQL\Server\OperationParams $params
487
   *
488
   * @return mixed
489
   * @throws \GraphQL\Server\RequestError
490
   */
491
  protected function loadPersistedQuery(ServerConfig $config, OperationParams $params) {
492
    if (!$loader = $config->getPersistentQueryLoader()) {
493
      throw new RequestError('Persisted queries are not supported by this server.');
494
    }
495
496
    $source = $loader($params->queryId, $params);
497
    if (!is_string($source) && !$source instanceof DocumentNode) {
498
      throw new \LogicException(sprintf('The persisted query loader must return query string or instance of %s but got: %s.', DocumentNode::class, Utils::printSafe($source)));
499
    }
500
501
    return $source;
502
  }
503
504
  /**
505
   * @param \GraphQL\Language\AST\DocumentNode $document
506
   *
507
   * @return array
508
   */
509
  protected function serializeDocument(DocumentNode $document) {
510
    return $this->sanitizeRecursive(AST::toArray($document));
511
  }
512
513
  /**
514
   * @param array $item
515
   *
516
   * @return array
517
   */
518
  protected function sanitizeRecursive(array $item) {
519
    unset($item['loc']);
520
521
    foreach ($item as &$value) {
522
      if (is_array($value)) {
523
        $value = $this->sanitizeRecursive($value);
524
      }
525
    }
526
527
    return $item;
528
  }
529
530
  /**
531
   * @param \GraphQL\Server\OperationParams $params
532
   * @param \GraphQL\Language\AST\DocumentNode $document
533
   * @param \Drupal\Core\Cache\CacheableMetadata $metadata
534
   *
535
   * @return string
536
   */
537
  protected function cacheIdentifier(OperationParams $params, DocumentNode $document, CacheableMetadata $metadata) {
538
    $contexts = $metadata->getCacheContexts();
539
    $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys();
540
541
    // Sorting the variables will cause fewer cache vectors.
542
    $variables = $params->variables ?: [];
543
    ksort($variables);
544
545
    // Prepend the hash of the serialized document to the cache contexts.
546
    $hash = hash('sha256', json_encode([
547
      'query' => $this->serializeDocument($document),
548
      'variables' => $variables,
549
    ]));
550
551
    return implode(':', array_values(array_merge([$hash], $keys)));
552
  }
553
}
554