Completed
Push — master ( 29b346...1faa7f )
by Amrouche
19s
created

src/Bridge/Doctrine/Orm/Filter/AbstractFilter.php (16 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
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
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);
1 ignored issue
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
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
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
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);
1 ignored issue
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 View Code Duplication
        if (func_num_args() > 1) {
0 ignored issues
show
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...
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
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);
1 ignored issue
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
238
        if (!isset($resourceClass)) {
239
            return [
240
                'associations' => array_slice($parts, 0, -1),
241
                'field' => end($parts),
242
            ];
243
        }
244
245
        $metadata = $this->getClassMetadata($resourceClass);
246
        $slice = 0;
247
248
        foreach ($parts as $part) {
249
            if ($metadata->hasAssociation($part)) {
250
                $metadata = $this->getClassMetadata($metadata->getAssociationTargetClass($part));
251
                $slice += 1;
252
            }
253
        }
254
255
        if ($slice === count($parts)) {
256
            $slice -= 1;
257
        }
258
259
        return [
260
            'associations' => array_slice($parts, 0, $slice),
261
            'field' => implode('.', array_slice($parts, $slice)),
262
        ];
263
    }
264
265
    /**
266
     * Extracts properties to filter from the request.
267
     *
268
     * @param Request $request
269
     *
270
     * @return array
271
     */
272
    protected function extractProperties(Request $request/*, string $resourceClass*/): array
273
    {
274 View Code Duplication
        if (func_num_args() > 1) {
0 ignored issues
show
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...
275
            $resourceClass = func_get_arg(1);
276
        } else {
277
            if (__CLASS__ !== get_class($this)) {
278
                $r = new \ReflectionMethod($this, __FUNCTION__);
279
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
280
                    @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);
1 ignored issue
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...
281
                }
282
            }
283
            $resourceClass = null;
284
        }
285
286
        $needsFixing = false;
287
288
        if (null !== $this->properties) {
289
            foreach ($this->properties as $property => $value) {
290
                if (($this->isPropertyNested($property, $resourceClass) || $this->isPropertyEmbedded($property, $resourceClass)) && $request->query->has(str_replace('.', '_', $property))) {
291
                    $needsFixing = true;
292
                }
293
            }
294
        }
295
296
        if ($needsFixing) {
297
            $request = RequestParser::parseAndDuplicateRequest($request);
298
        }
299
300
        return $request->query->all();
301
    }
302
303
    /**
304
     * Adds the necessary joins for a nested property.
305
     *
306
     * @param string                      $property
307
     * @param string                      $rootAlias
308
     * @param QueryBuilder                $queryBuilder
309
     * @param QueryNameGeneratorInterface $queryNameGenerator
310
     *
311
     * @throws InvalidArgumentException If property is not nested
312
     *
313
     * @return array An array where the first element is the join $alias of the leaf entity,
314
     *               the second element is the $field name
315
     *               the third element is the $associations array
316
     */
317
    protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator/*, string $resourceClass*/): array
318
    {
319 View Code Duplication
        if (func_num_args() > 4) {
0 ignored issues
show
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...
320
            $resourceClass = func_get_arg(4);
321
        } else {
322
            if (__CLASS__ !== get_class($this)) {
323
                $r = new \ReflectionMethod($this, __FUNCTION__);
324
                if (__CLASS__ !== $r->getDeclaringClass()->getName()) {
0 ignored issues
show
Consider using $r->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
325
                    @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);
1 ignored issue
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...
326
                }
327
            }
328
            $resourceClass = null;
329
        }
330
331
        $propertyParts = $this->splitPropertyParts($property, $resourceClass);
332
        $parentAlias = $rootAlias;
333
334
        foreach ($propertyParts['associations'] as $association) {
335
            $alias = $this->addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association);
336
            $parentAlias = $alias;
337
        }
338
339
        if (!isset($alias)) {
340
            throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property));
341
        }
342
343
        return [$alias, $propertyParts['field'], $propertyParts['associations']];
344
    }
345
346
    /**
347
     * Get the existing join from queryBuilder DQL parts.
348
     *
349
     * @param QueryBuilder $queryBuilder
350
     * @param string       $alias
351
     * @param string       $association  the association field
352
     *
353
     * @return Join|null
354
     */
355
    private function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association)
356
    {
357
        $parts = $queryBuilder->getDQLPart('join');
358
359
        if (!isset($parts['o'])) {
360
            return null;
361
        }
362
363
        foreach ($parts['o'] as $join) {
364
            if (sprintf('%s.%s', $alias, $association) === $join->getJoin()) {
365
                return $join;
366
            }
367
        }
368
369
        return null;
370
    }
371
372
    /**
373
     * Adds a join to the queryBuilder if none exists.
374
     *
375
     * @param QueryBuilder                $queryBuilder
376
     * @param QueryNameGeneratorInterface $queryNameGenerator
377
     * @param string                      $alias
378
     * @param string                      $association        the association field
379
     *
380
     * @return string the new association alias
381
     */
382
    protected function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association): string
383
    {
384
        $join = $this->getExistingJoin($queryBuilder, $alias, $association);
385
386
        if (null === $join) {
387
            $associationAlias = $queryNameGenerator->generateJoinAlias($association);
388
389
            if (true === QueryChecker::hasLeftJoin($queryBuilder)) {
390
                $queryBuilder
391
                    ->leftJoin(sprintf('%s.%s', $alias, $association), $associationAlias);
392
            } else {
393
                $queryBuilder
394
                    ->innerJoin(sprintf('%s.%s', $alias, $association), $associationAlias);
395
            }
396
        } else {
397
            $associationAlias = $join->getAlias();
398
        }
399
400
        return $associationAlias;
401
    }
402
}
403