Completed
Pull Request — 8.x-3.x (#877)
by Sebastian
03:39 queued 02:14
created

EntityQuery::isUnsafeOperator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Drupal\graphql_core\Plugin\GraphQL\Fields\EntityQuery;
4
5
use Drupal\Core\Cache\CacheableMetadata;
6
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
7
use Drupal\Core\Entity\EntityInterface;
8
use Drupal\Core\Entity\EntityTypeManagerInterface;
9
use Drupal\Core\Entity\Query\QueryInterface;
10
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
11
use Drupal\graphql\GraphQL\Execution\ResolveContext;
12
use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase;
13
use GraphQL\Error\Error;
14
use Symfony\Component\DependencyInjection\ContainerInterface;
15
use GraphQL\Type\Definition\ResolveInfo;
16
17
/**
18
 * @GraphQLField(
19
 *   id = "entity_query",
20
 *   secure = true,
21
 *   type = "EntityQueryResult",
22
 *   arguments = {
23
 *     "filter" = "EntityQueryFilterInput",
24
 *     "sort" = "[EntityQuerySortInput]",
25
 *     "offset" = {
26
 *       "type" = "Int",
27
 *       "default" = 0
28
 *     },
29
 *     "limit" = {
30
 *       "type" = "Int",
31
 *       "default" = 10
32
 *     },
33
 *     "revisions" = {
34
 *       "type" = "EntityQueryRevisionMode",
35
 *       "default" = "default"
36
 *     }
37
 *   },
38
 *   deriver = "Drupal\graphql_core\Plugin\Deriver\Fields\EntityQueryDeriver"
39
 * )
40
 */
41
class EntityQuery extends FieldPluginBase implements ContainerFactoryPluginInterface {
42
  use DependencySerializationTrait;
43
44
  /**
45
   * The entity type manager.
46
   *
47
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
48
   */
49
  protected $entityTypeManager;
50
51
  /**
52
   * {@inheritdoc}
53
   */
54
  public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) {
55
    return new static(
56
      $configuration,
57
      $pluginId,
58
      $pluginDefinition,
59
      $container->get('entity_type.manager')
60
    );
61
  }
62
63
  /**
64
   * EntityQuery constructor.
65
   *
66
   * @param array $configuration
67
   *   The plugin configuration array.
68
   * @param string $pluginId
69
   *   The plugin id.
70
   * @param mixed $pluginDefinition
71
   *   The plugin definition.
72
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
73
   *   The entity type manager service.
74
   */
75
  public function __construct(array $configuration, $pluginId, $pluginDefinition, EntityTypeManagerInterface $entityTypeManager) {
76
    parent::__construct($configuration, $pluginId, $pluginDefinition);
77
    $this->entityTypeManager = $entityTypeManager;
78
  }
79
80
  /**
81
   * {@inheritdoc}
82
   */
83
  protected function getCacheDependencies(array $result, $value, array $args, ResolveContext $context, ResolveInfo $info) {
84
    $entityType = $this->getEntityType($value, $args, $context, $info);
85
    $type = $this->entityTypeManager->getDefinition($entityType);
86
87
    $metadata = new CacheableMetadata();
88
    $metadata->addCacheTags($type->getListCacheTags());
89
    $metadata->addCacheContexts($type->getListCacheContexts());
90
91
    return [$metadata];
92
  }
93
94
  /**
95
   * {@inheritdoc}
96
   */
97
  public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) {
98
    yield $this->getQuery($value, $args, $context, $info);
99
  }
100
101
  /**
102
   * Retrieve the target entity type of this plugin.
103
   *
104
   * @param mixed $value
105
   *   The parent value.
106
   * @param array $args
107
   *   The field arguments array.
108
   * @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
109
   *   The resolve context.
110
   * @param \GraphQL\Type\Definition\ResolveInfo $info
111
   *   The resolve info object.
112
   *
113
   * @return null|string
114
   *   The entity type object or NULL if none could be derived.
115
   */
116
  protected function getEntityType($value, array $args, ResolveContext $context, ResolveInfo $info) {
117
    $definition = $this->getPluginDefinition();
118
    if (isset($definition['entity_type'])) {
119
      return $definition['entity_type'];
120
    }
121
122
    if ($value instanceof EntityInterface) {
0 ignored issues
show
Bug introduced by
The class Drupal\Core\Entity\EntityInterface 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...
123
      return $value->getEntityType()->id();
124
    }
125
126
    return NULL;
127
  }
128
129
  /**
130
   * Create the full entity query for the plugin's entity type.
131
   *
132
   * @param mixed $value
133
   *   The parent entity type.
134
   * @param array $args
135
   *   The field arguments array.
136
   * @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
137
   *   The resolve context.
138
   * @param \GraphQL\Type\Definition\ResolveInfo $info
139
   *   The resolve info object.
140
   *
141
   * @return \Drupal\Core\Entity\Query\QueryInterface|null
142
   *   The entity query object.
143
   */
144
  protected function getQuery($value, array $args, ResolveContext $context, ResolveInfo $info) {
145
    if (!$query = $this->getBaseQuery($value, $args, $context, $info)) {
146
      return NULL;
147
    }
148
149
    $query->range($args['offset'], $args['limit']);
150
151
    if (array_key_exists('revisions', $args)) {
152
      $query = $this->applyRevisionsMode($query, $args['revisions']);
153
    }
154
155
    if (array_key_exists('filter', $args)) {
156
      $secure = $context->getGlobal('development', FALSE) || $context->getGlobal('bypass field security', FALSE);
157
      $query = $this->applyFilter($query, $args['filter'], $secure);
158
    }
159
160
    if (array_key_exists('sort', $args)) {
161
      $query = $this->applySort($query, $args['sort']);
162
    }
163
164
    return $query;
165
  }
166
167
  /**
168
   * Create the basic entity query for the plugin's entity type.
169
   *
170
   * @param mixed $value
171
   *   The parent entity type.
172
   * @param array $args
173
   *   The field arguments array.
174
   * @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
175
   *   The resolve context.
176
   * @param \GraphQL\Type\Definition\ResolveInfo $info
177
   *   The resolve info object.
178
   *
179
   * @return \Drupal\Core\Entity\Query\QueryInterface|null
180
   *   The entity query object.
181
   */
182
  protected function getBaseQuery($value, array $args, ResolveContext $context, ResolveInfo $info) {
183
    $entityType = $this->getEntityType($value, $args, $context, $info);
184
    $entityStorage = $this->entityTypeManager->getStorage($entityType);
185
    $query = $entityStorage->getQuery();
186
    $query->accessCheck(TRUE);
187
188
    // The context object can e.g. transport the parent entity language.
189
    $query->addMetaData('graphql_context', $this->getQueryContext($value, $args, $context, $info));
190
191
    return $query;
192
  }
193
194
  /**
195
   * Retrieves an arbitrary value to write into the query metadata.
196
   *
197
   * @param mixed $value
198
   *   The parent value.
199
   * @param array $args
200
   *   The field arguments array.
201
   * @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
202
   *   The resolve context.
203
   * @param \GraphQL\Type\Definition\ResolveInfo $info
204
   *   The resolve info object.
205
   *
206
   * @return mixed
207
   *   The query context.
208
   */
209
  protected function getQueryContext($value, array $args, ResolveContext $context, ResolveInfo $info) {
210
    // Forward the whole set of arguments by default.
211
    return [
212
      'parent' => $value,
213
      'args' => $args,
214
      'info' => $info,
215
    ];
216
  }
217
218
  /**
219
   * Apply the specified revision filtering mode to the query.
220
   *
221
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
222
   *   The entity query object.
223
   * @param mixed $mode
224
   *   The revision query mode.
225
   *
226
   * @return \Drupal\Core\Entity\Query\QueryInterface
227
   *   The entity query object.
228
   */
229
  protected function applyRevisionsMode(QueryInterface $query, $mode) {
230
    if ($mode === 'all') {
231
      // Mark the query as such and sort by the revision id too.
232
      $query->allRevisions();
233
      $query->addTag('revisions');
234
    }
235
    else if ($mode === 'latest') {
236
      // Mark the query to only include latest revision and sort by revision id.
237
      $query->latestRevision();
238
      $query->addTag('revisions');
239
    }
240
241
    return $query;
242
  }
243
244
  /**
245
   * Apply the specified sort directives to the query.
246
   *
247
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
248
   *   The entity query object.
249
   * @param mixed $sort
250
   *   The sort definitions from the field arguments.
251
   *
252
   * @return \Drupal\Core\Entity\Query\QueryInterface
253
   *   The entity query object.
254
   */
255
  protected function applySort(QueryInterface $query, $sort) {
256
    if (!empty($sort) && is_array($sort)) {
257
      foreach ($sort as $item) {
258
        $direction = !empty($item['direction']) ? $item['direction'] : 'DESC';
259
        $language = !empty($item['language']) ? $item['language'] : null;
260
        $query->sort($item['field'], $direction, $language);
261
      }
262
    }
263
264
    return $query;
265
  }
266
267
  /**
268
   * Apply the specified filter conditions to the query.
269
   *
270
   * Recursively picks up all filters and aggregates them into condition groups
271
   * according to the nested structure of the filter argument.
272
   *
273
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
274
   *   The entity query object.
275
   * @param mixed $filter
276
   *   The filter definitions from the field arguments.
277
   * @param bool $secure
278
   *   Whether the query is running in a secure context. Defaults to FALSE.
279
   *
280
   * @return \Drupal\Core\Entity\Query\QueryInterface
281
   *   The entity query object.
282
   * @throws \GraphQL\Error\Error
283
   */
284
  protected function applyFilter(QueryInterface $query, $filter, $secure = FALSE) {
285
    if (!empty($filter) && is_array($filter)) {
286
      //Conditions can be disabled. Check we are not adding an empty condition group.
287
      $filterConditions = $this->buildFilterConditions($query, $filter, $secure);
288
      if (count($filterConditions->conditions())) {
289
        $query->condition($filterConditions);
290
      }
291
    }
292
293
    return $query;
294
  }
295
296
  /**
297
   * Recursively builds the filter condition groups.
298
   *
299
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
300
   *   The entity query object.
301
   * @param array $filter
302
   *   The filter definitions from the field arguments.
303
   * @param bool $secure
304
   *   Whether the query is running in a secure context. Defaults to FALSE.
305
   *
306
   * @return \Drupal\Core\Entity\Query\ConditionInterface
307
   *   The generated condition group according to the given filter definitions.
308
   *
309
   * @throws \GraphQL\Error\Error If the given operator and value for a filter are invalid.
310
   */
311
  protected function buildFilterConditions(QueryInterface $query, array $filter, $secure = FALSE) {
312
    $conjunction = !empty($filter['conjunction']) ? $filter['conjunction'] : 'AND';
313
    $group = $conjunction === 'AND' ? $query->andConditionGroup() : $query->orConditionGroup();
314
315
    // Apply filter conditions.
316
    $conditions = !empty($filter['conditions']) ? $filter['conditions'] : [];
317
    foreach ($conditions as $condition) {
318
      // Check if we need to disable this condition.
319
      if (isset($condition['enabled']) && empty($condition['enabled'])) {
320
        continue;
321
      }
322
323
      $field = $condition['field'];
324
      $value = !empty($condition['value']) ? $condition['value'] : NULL;
325
      $operator = !empty($condition['operator']) ? $condition['operator'] : NULL;
326
      $language = !empty($condition['language']) ? $condition['language'] : NULL;
327
328
      // We need at least a value or an operator.
329
      if (empty($operator) && empty($value)) {
330
        throw new Error(sprintf("Missing value and operator in filter for '%s'.", $field));
331
      }
332
      // Unary operators need a single value.
333
      else if (!empty($operator) && $this->isUnaryOperator($operator)) {
334 View Code Duplication
        if (empty($value) || count($value) > 1) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across 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...
335
          throw new Error(sprintf("Unary operators must be associated with a single value (field '%s').", $field));
336
        }
337
338
        // Pick the first item from the values.
339
        $value = reset($value);
340
      }
341
      // Range operators need exactly two values.
342
      else if (!empty($operator) && $this->isRangeOperator($operator)) {
343 View Code Duplication
        if (empty($value) || count($value) !== 2) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across 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...
344
          throw new Error(sprintf("Range operators must require exactly two values (field '%s').", $field));
345
        }
346
      }
347
      // Null operators can't have a value set.
348
      else if (!empty($operator) && $this->isNullOperator($operator)) {
349
        if (!empty($value)) {
350
          throw new Error(sprintf("Null operators must not be associated with a filter value (field '%s').", $field));
351
        }
352
      }
353
354
      // Protect against unsafe operators
355
      if (!empty($operator) && !$secure && $this->isUnsafeOperator($operator)) {
356
        throw new Error(sprintf("The unsafe query operator '%s' is not allowed in this context (field '%s').", $operator, $field));
357
      }
358
359
      // If no operator is set, however, we default to EQUALS or IN, depending
360
      // on whether the given value is an array with one or more than one items.
361
      if (empty($operator)) {
362
        $value = count($value) === 1 ? reset($value) : $value;
363
        $operator = is_array($value) ? 'IN' : '=';
364
      }
365
366
      // Add the condition for the current field.
367
      $group->condition($field, $value, $operator, $language);
368
    }
369
370
    // Apply nested filter group conditions.
371
    $groups = !empty($filter['groups']) ? $filter['groups'] : [];
372
    foreach ($groups as $args) {
373
      // By default, we use AND condition groups.
374
      // Conditions can be disabled. Check we are not adding an empty condition group.
375
      $filterConditions = $this->buildFilterConditions($query, $args);
376
      if (count($filterConditions->conditions())) {
377
        $group->condition($filterConditions);
378
      }
379
    }
380
381
    return $group;
382
  }
383
384
  /**
385
   * Checks if an operator is a unary operator.
386
   *
387
   * @param string $operator
388
   *   The query operator to check against.
389
   *
390
   * @return bool
391
   *   TRUE if the given operator is unary, FALSE otherwise.
392
   */
393
  protected function isUnaryOperator($operator) {
394
    $unary = ["=", "<>", "<", "<=", ">", ">=", "LIKE", "NOT LIKE"];
395
    return in_array($operator, $unary);
396
  }
397
398
  /**
399
   * Checks if an operator is a null operator.
400
   *
401
   * @param string $operator
402
   *   The query operator to check against.
403
   *
404
   * @return bool
405
   *   TRUE if the given operator is a null operator, FALSE otherwise.
406
   */
407
  protected function isNullOperator($operator) {
408
    $null = ["IS NULL", "IS NOT NULL"];
409
    return in_array($operator, $null);
410
  }
411
412
  /**
413
   * Checks if an operator is a range operator.
414
   *
415
   * @param string $operator
416
   *   The query operator to check against.
417
   *
418
   * @return bool
419
   *   TRUE if the given operator is a range operator, FALSE otherwise.
420
   */
421
  protected function isRangeOperator($operator) {
422
    $range = ["BETWEEN", "NOT BETWEEN"];
423
    return in_array($operator, $range);
424
  }
425
426
  /**
427
   * Checks if an operator is potentially unsafe.
428
   *
429
   * @param string $operator
430
   *   The query operator to check against.
431
   *
432
   * @return bool
433
   *   TRUE if the given operator is an unsafe operator, FALSE otherwise.
434
   */
435
  protected function isUnsafeOperator($operator) {
436
    $unsafe = ["<", "<=", ">", ">=", "LIKE", "NOT LIKE", "BETWEEN", "NOT BETWEEN"];
437
    return in_array($operator, $unsafe);
438
  }
439
440
}
441