Completed
Push — master ( c86a60...8ed3bd )
by Kévin
16s
created

AbstractFilter::isPropertyNested()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 20
Code Lines 12

Duplication

Lines 11
Ratio 55 %

Importance

Changes 0
Metric Value
dl 11
loc 20
rs 8.8571
c 0
b 0
f 0
cc 6
eloc 12
nc 12
nop 1
1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Filter;
15
16
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
17
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18
use ApiPlatform\Core\Exception\InvalidArgumentException;
19
use ApiPlatform\Core\Util\RequestParser;
20
use Doctrine\Common\Persistence\ManagerRegistry;
21
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
22
use Doctrine\ORM\Query\Expr\Join;
23
use Doctrine\ORM\QueryBuilder;
24
use Psr\Log\LoggerInterface;
25
use Psr\Log\NullLogger;
26
use Symfony\Component\HttpFoundation\Request;
27
use Symfony\Component\HttpFoundation\RequestStack;
28
29
/**
30
 * {@inheritdoc}
31
 *
32
 * Abstract class with helpers for easing the implementation of a filter.
33
 *
34
 * @author Kévin Dunglas <[email protected]>
35
 * @author Théo FIDRY <[email protected]>
36
 */
37
abstract class AbstractFilter implements FilterInterface
38
{
39
    protected $managerRegistry;
40
    protected $requestStack;
41
    protected $logger;
42
    protected $properties;
43
44
    public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, LoggerInterface $logger = null, array $properties = null)
45
    {
46
        $this->managerRegistry = $managerRegistry;
47
        $this->requestStack = $requestStack;
48
        $this->logger = $logger ?? new NullLogger();
49
        $this->properties = $properties;
50
    }
51
52
    /**
53
     * {@inheritdoc}
54
     */
55
    public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
56
    {
57
        $request = $this->requestStack->getCurrentRequest();
58
        if (null === $request) {
59
            return;
60
        }
61
62
        foreach ($this->extractProperties($request, $resourceClass) as $property => $value) {
63
            $this->filterProperty($property, $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName);
64
        }
65
    }
66
67
    /**
68
     * Passes a property through the filter.
69
     *
70
     * @param string                      $property
71
     * @param mixed                       $value
72
     * @param QueryBuilder                $queryBuilder
73
     * @param QueryNameGeneratorInterface $queryNameGenerator
74
     * @param string                      $resourceClass
75
     * @param string|null                 $operationName
76
     */
77
    abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null);
78
79
    /**
80
     * Gets class metadata for the given resource.
81
     *
82
     * @param string $resourceClass
83
     *
84
     * @return ClassMetadata
85
     */
86
    protected function getClassMetadata(string $resourceClass): ClassMetadata
87
    {
88
        return $this
89
            ->managerRegistry
90
            ->getManagerForClass($resourceClass)
91
            ->getClassMetadata($resourceClass);
92
    }
93
94
    /**
95
     * Determines whether the given property is enabled.
96
     *
97
     * @param string $property
98
     *
99
     * @return bool
100
     */
101
    protected function isPropertyEnabled(string $property/*, string $resourceClass*/): bool
102
    {
103 View Code Duplication
        if (func_num_args() > 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...
104
            $resourceClass = func_get_arg(1);
105
        } else {
106
            if (__CLASS__ !== get_class($this)) {
107
                $r = new \ReflectionMethod($this, __FUNCTION__);
108
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
introduced by
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
109
                    @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
110
                }
111
            }
112
            $resourceClass = null;
113
        }
114
115
        if (null === $this->properties) {
116
            // to ensure sanity, nested properties must still be explicitly enabled
117
            return !$this->isPropertyNested($property, $resourceClass);
118
        }
119
120
        return array_key_exists($property, $this->properties);
121
    }
122
123
    /**
124
     * Determines whether the given property is mapped.
125
     *
126
     * @param string $property
127
     * @param string $resourceClass
128
     * @param bool   $allowAssociation
129
     *
130
     * @return bool
131
     */
132
    protected function isPropertyMapped(string $property, string $resourceClass, bool $allowAssociation = false): bool
133
    {
134 View Code Duplication
        if ($this->isPropertyNested($property, $resourceClass)) {
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...
135
            $propertyParts = $this->splitPropertyParts($property, $resourceClass);
136
            $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
137
            $property = $propertyParts['field'];
138
        } else {
139
            $metadata = $this->getClassMetadata($resourceClass);
140
        }
141
142
        return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property));
143
    }
144
145
    /**
146
     * Determines whether the given property is nested.
147
     *
148
     * @param string $property
149
     *
150
     * @return bool
151
     */
152
    protected function isPropertyNested(string $property/*, string $resourceClass*/): bool
153
    {
154 View Code Duplication
        if (func_num_args() > 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...
155
            $resourceClass = func_get_arg(1);
156
        } else {
157
            if (__CLASS__ !== get_class($this)) {
158
                $r = new \ReflectionMethod($this, __FUNCTION__);
159
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
introduced by
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
160
                    @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
161
                }
162
            }
163
            $resourceClass = null;
164
        }
165
166
        if (false === $pos = strpos($property, '.')) {
167
            return false;
168
        }
169
170
        return null !== $resourceClass && $this->getClassMetadata($resourceClass)->hasAssociation(substr($property, 0, $pos));
171
    }
172
173
    /**
174
     * Determines whether the given property is embedded.
175
     *
176
     * @param string $property
177
     * @param string $resourceClass
178
     *
179
     * @return bool
180
     */
181
    protected function isPropertyEmbedded(string $property, string $resourceClass): bool
182
    {
183
        return false !== strpos($property, '.') && $this->getClassMetadata($resourceClass)->hasField($property);
184
    }
185
186
    /**
187
     * Gets nested class metadata for the given resource.
188
     *
189
     * @param string   $resourceClass
190
     * @param string[] $associations
191
     *
192
     * @return ClassMetadata
193
     */
194
    protected function getNestedMetadata(string $resourceClass, array $associations): ClassMetadata
195
    {
196
        $metadata = $this->getClassMetadata($resourceClass);
197
198
        foreach ($associations as $association) {
199
            if ($metadata->hasAssociation($association)) {
200
                $associationClass = $metadata->getAssociationTargetClass($association);
201
202
                $metadata = $this
203
                    ->managerRegistry
204
                    ->getManagerForClass($associationClass)
205
                    ->getClassMetadata($associationClass);
206
            }
207
        }
208
209
        return $metadata;
210
    }
211
212
    /**
213
     * Splits the given property into parts.
214
     *
215
     * Returns an array with the following keys:
216
     *   - associations: array of associations according to nesting order
217
     *   - field: string holding the actual field (leaf node)
218
     *
219
     * @param string $property
220
     *
221
     * @return array
222
     */
223
    protected function splitPropertyParts(string $property/*, string $resourceClass*/): array
224
    {
225
        $parts = explode('.', $property);
226
227
        if (func_num_args() > 1) {
228
            $resourceClass = func_get_arg(1);
229
        } else {
230
            if (__CLASS__ !== get_class($this)) {
231
                $r = new \ReflectionMethod($this, __FUNCTION__);
232
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
introduced by
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
233
                    @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
234
                }
235
            }
236
237
            return [
238
                'associations' => array_slice($parts, 0, -1),
239
                'field' => end($parts),
240
            ];
241
        }
242
243
        $metadata = $this->getClassMetadata($resourceClass);
244
        $slice = 0;
245
246
        foreach ($parts as $part) {
247
            if ($metadata->hasAssociation($part)) {
248
                $metadata = $this->getClassMetadata($metadata->getAssociationTargetClass($part));
249
                $slice += 1;
250
            }
251
        }
252
253
        if ($slice === count($parts)) {
254
            $slice -= 1;
255
        }
256
257
        return [
258
            'associations' => array_slice($parts, 0, $slice),
259
            'field' => implode('.', array_slice($parts, $slice)),
260
        ];
261
    }
262
263
    /**
264
     * Extracts properties to filter from the request.
265
     *
266
     * @param Request $request
267
     *
268
     * @return array
269
     */
270
    protected function extractProperties(Request $request/*, string $resourceClass*/): array
271
    {
272 View Code Duplication
        if (func_num_args() > 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...
273
            $resourceClass = func_get_arg(1);
274
        } else {
275
            if (__CLASS__ !== get_class($this)) {
276
                $r = new \ReflectionMethod($this, __FUNCTION__);
277
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
introduced by
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
278
                    @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
279
                }
280
            }
281
            $resourceClass = null;
282
        }
283
284
        $needsFixing = false;
285
286
        if (null !== $this->properties) {
287
            foreach ($this->properties as $property => $value) {
288
                if (($this->isPropertyNested($property, $resourceClass) || $this->isPropertyEmbedded($property, $resourceClass)) && $request->query->has(str_replace('.', '_', $property))) {
289
                    $needsFixing = true;
290
                }
291
            }
292
        }
293
294
        if ($needsFixing) {
295
            $request = RequestParser::parseAndDuplicateRequest($request);
296
        }
297
298
        return $request->query->all();
299
    }
300
301
    /**
302
     * Adds the necessary joins for a nested property.
303
     *
304
     * @param string                      $property
305
     * @param string                      $rootAlias
306
     * @param QueryBuilder                $queryBuilder
307
     * @param QueryNameGeneratorInterface $queryNameGenerator
308
     *
309
     * @throws InvalidArgumentException If property is not nested
310
     *
311
     * @return array An array where the first element is the join $alias of the leaf entity,
312
     *               the second element is the $field name
313
     *               the third element is the $associations array
314
     */
315
    protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator/*, string $resourceClass*/): array
316
    {
317 View Code Duplication
        if (func_num_args() > 4) {
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...
318
            $resourceClass = func_get_arg(4);
319
        } else {
320
            if (__CLASS__ !== get_class($this)) {
321
                $r = new \ReflectionMethod($this, __FUNCTION__);
322
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
introduced by
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
323
                    @trigger_error(sprintf('Method %s() will have a fifth `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
324
                }
325
            }
326
            $resourceClass = null;
327
        }
328
329
        $propertyParts = $this->splitPropertyParts($property, $resourceClass);
330
        $parentAlias = $rootAlias;
331
332
        foreach ($propertyParts['associations'] as $association) {
333
            $alias = $this->addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association);
334
            $parentAlias = $alias;
335
        }
336
337
        if (!isset($alias)) {
338
            throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property));
339
        }
340
341
        return [$alias, $propertyParts['field'], $propertyParts['associations']];
342
    }
343
344
    /**
345
     * Get the existing join from queryBuilder DQL parts.
346
     *
347
     * @param QueryBuilder $queryBuilder
348
     * @param string       $alias
349
     * @param string       $association  the association field
350
     *
351
     * @return Join|null
352
     */
353
    private function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association)
354
    {
355
        $parts = $queryBuilder->getDQLPart('join');
356
357
        if (!isset($parts['o'])) {
358
            return null;
359
        }
360
361
        foreach ($parts['o'] as $join) {
362
            if (sprintf('%s.%s', $alias, $association) === $join->getJoin()) {
363
                return $join;
364
            }
365
        }
366
367
        return null;
368
    }
369
370
    /**
371
     * Adds a join to the queryBuilder if none exists.
372
     *
373
     * @param QueryBuilder                $queryBuilder
374
     * @param QueryNameGeneratorInterface $queryNameGenerator
375
     * @param string                      $alias
376
     * @param string                      $association        the association field
377
     *
378
     * @return string the new association alias
379
     */
380
    protected function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association): string
381
    {
382
        $join = $this->getExistingJoin($queryBuilder, $alias, $association);
383
384
        if (null === $join) {
385
            $associationAlias = $queryNameGenerator->generateJoinAlias($association);
386
387
            if (true === QueryChecker::hasLeftJoin($queryBuilder)) {
388
                $queryBuilder
389
                    ->leftJoin(sprintf('%s.%s', $alias, $association), $associationAlias);
390
            } else {
391
                $queryBuilder
392
                    ->innerJoin(sprintf('%s.%s', $alias, $association), $associationAlias);
393
            }
394
        } else {
395
            $associationAlias = $join->getAlias();
396
        }
397
398
        return $associationAlias;
399
    }
400
}
401