Passed
Pull Request — 2.1 (#71)
by Vincent
14:21 queued 08:17
created

ClosureCompiler::validateFilters()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 12
c 1
b 0
f 0
dl 0
loc 24
ccs 13
cts 13
cp 1
rs 8.8333
cc 7
nc 6
nop 1
crap 7
1
<?php
2
3
namespace Bdf\Prime\Query\Closure;
4
5
use Bdf\Prime\Query\Closure\Filter\AndFilter;
6
use Bdf\Prime\Query\Closure\Filter\OrFilter;
7
use Bdf\Prime\Query\Contract\Whereable;
8
use Bdf\Prime\Repository\RepositoryInterface;
9
use Closure;
10
use Doctrine\DBAL\Query\Expression\CompositeExpression;
11
use InvalidArgumentException;
12
use LogicException;
13
use PhpParser\NodeTraverser;
14
use PhpParser\Parser;
15
use PhpParser\ParserFactory;
16
use Psr\SimpleCache\CacheInterface;
17
use ReflectionFunction;
18
use ReflectionNamedType;
19
20
use ReflectionType;
21
22
use ReflectionUnionType;
23
24
use function count;
25
use function file_get_contents;
26
use function md5;
27
use function sprintf;
28
use function str_contains;
29
use function strchr;
30
31
/**
32
 * Parse a closure filter and compile it to a where filter builder function
33
 *
34
 * @template E as object
35
 */
36
final class ClosureCompiler
37
{
38
    private static ?Parser $parser = null;
39
40
    /**
41
     * @var RepositoryInterface<E>
42
     */
43
    private RepositoryInterface $repository;
44
    private ?CacheInterface $cache;
45
46
    /**
47
     * @param RepositoryInterface<E> $repository
48
     * @param CacheInterface|null $cache
49
     */
50 357
    public function __construct(RepositoryInterface $repository, ?CacheInterface $cache = null)
51
    {
52 357
        $this->repository = $repository;
53 357
        $this->cache = $cache;
54
    }
55
56
    /**
57
     * Compile a predicate closure to a where filter builder function
58
     *
59
     * Usage:
60
     * <code>
61
     * $query->where($compiler->compile(fn (MyEntity $e) => $e->id != 5 && $e->name == 'foo'));
62
     * </code>
63
     *
64
     * @param Closure(E):bool $closure The predicate closure
65
     *
66
     * @return callable(Whereable):void
67
     *
68
     * @see Whereable::where() Should be used with the returned value of this method
69
     */
70 28
    public function compile(Closure $closure): callable
71
    {
72 28
        $reflection = new ReflectionFunction($closure);
73
74 28
        return $this->normalizeFilters($reflection, $this->load($reflection));
75
    }
76
77
    /**
78
     * Try to load the filters from cache or parse the closure
79
     *
80
     * @param ReflectionFunction $reflection
81
     * @return AndFilter
82
     */
83 28
    private function load(ReflectionFunction $reflection): AndFilter
84
    {
85 28
        if ($this->cache) {
86 1
            $key = 'prime.closure.' . md5($reflection->getFileName() . $reflection->getStartLine());
87
88 1
            if ($filters = $this->cache->get($key)) {
89 1
                return $filters;
90
            }
91
        }
92
93 28
        $filters = $this->parseClosure($reflection);
94
95 10
        if ($this->cache) {
96 1
            $this->cache->set($key, $filters);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $key does not seem to be defined for all execution paths leading up to this point.
Loading history...
97
        }
98
99 10
        return $filters;
100
    }
101
102 28
    private function parseClosure(ReflectionFunction $reflection): AndFilter
103
    {
104 28
        if (!class_exists(ParserFactory::class)) {
105
            throw new LogicException('Closure filters requires the "nikic/php-parser" package. Please install it with "composer require nikic/php-parser"');
106
        }
107
108 28
        if ($reflection->getNumberOfParameters() !== 1) {
109 1
            throw new InvalidArgumentException('Closure must have only one parameter');
110
        }
111
112 27
        $parameter = $reflection->getParameters()[0];
113 27
        $this->checkParameterType($parameter->getType());
114
115 25
        if (self::$parser === null) {
116 1
            self::$parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
117
        }
118
119 25
        $ast = self::$parser->parse(file_get_contents($reflection->getFileName()));
0 ignored issues
show
Bug introduced by
The method parse() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

119
        /** @scrutinizer ignore-call */ 
120
        $ast = self::$parser->parse(file_get_contents($reflection->getFileName()));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
120 25
        $traverser = new NodeTraverser();
121 25
        $traverser->addVisitor($extractor = new ClosureFiltersExtractorVisitor($reflection));
122 25
        $traverser->traverse($ast);
0 ignored issues
show
Bug introduced by
It seems like $ast can also be of type null; however, parameter $nodes of PhpParser\NodeTraverser::traverse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

122
        $traverser->traverse(/** @scrutinizer ignore-type */ $ast);
Loading history...
123
124 13
        $filters = $extractor->filters();
125
126 12
        $this->validateFilters($filters);
127
128 10
        return $filters;
129
    }
130
131
    /**
132
     * @psalm-suppress UndefinedClass - ReflectionUnionType does not exist on PHP < 8.0
133
     * @psalm-suppress TypeDoesNotContainType
134
     */
135 27
    private function checkParameterType(?ReflectionType $type): void
136
    {
137 27
        if ($type === null) {
138 1
            throw new InvalidArgumentException('Closure parameter must declare the entity type');
139
        }
140
141 26
        $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
142 26
        $typeName = null;
143
144 26
        foreach ($types as $atomicType) {
145 26
            if (!$atomicType instanceof ReflectionNamedType) {
146
                continue;
147
            }
148
149 26
            $typeName = $atomicType->getName();
150
151 26
            if ($typeName === $this->repository->entityName()) {
152 24
                return;
153
            }
154
155 2
            if (is_subclass_of($this->repository->entityName(), $typeName)) {
156 1
                return;
157
            }
158
        }
159
160 1
        throw new InvalidArgumentException(sprintf('Expect parameter of type "%s" but get "%s"', $this->repository->entityName(), $typeName));
161
    }
162
163 12
    private function validateFilters(AndFilter $filters): void
164
    {
165 12
        $metadata = $this->repository->metadata();
166
167 12
        foreach ($filters->filters as $filter) {
168 12
            if ($filter instanceof OrFilter) {
169 3
                foreach ($filter->filters as $subFilters) {
170 3
                    $this->validateFilters($subFilters);
171
                }
172 3
                continue;
173
            }
174
175 12
            $property = $filter->property;
176
177 12
            if ($metadata->attributeExists($property)) {
178 10
                continue;
179
            }
180
181
            // Check if it's on an embedded or relation
182 5
            if (str_contains($property, '.') && $metadata->embedded(strchr($property, '.', true))) {
183 3
                continue;
184
            }
185
186 2
            throw new InvalidArgumentException(sprintf('Property "%s" is not mapped to database.', $filter->property));
187
        }
188
    }
189
190 10
    private function normalizeFilters(ReflectionFunction $reflection, AndFilter $compiledFilters): NestedFilters
191
    {
192 10
        $filters = [];
193
194 10
        foreach ($compiledFilters as $filter) {
195 10
            if ($filter instanceof OrFilter) {
196 3
                $filters[] = [$this->normalizeOrFilters($reflection, $filter), null, null];
197 3
                continue;
198
            }
199
200 10
            $filters[] = [
201 10
                $filter->property,
202 10
                $filter->operator,
203 10
                $filter->value->get($reflection)
204 10
            ];
205
        }
206
207 10
        return new NestedFilters($filters);
208
    }
209
210 3
    private function normalizeOrFilters(ReflectionFunction $reflection, OrFilter $compiledFilters): NestedFilters
211
    {
212 3
        $filters = [];
213
214 3
        foreach ($compiledFilters->filters as $subFilters) {
215 3
            if (count($subFilters) !== 1) {
216 1
                $filters[] = [$this->normalizeFilters($reflection, $subFilters), null, null];
217 1
                continue;
218
            }
219
220 3
            $filters[] = [
221 3
                $subFilters[0]->property,
222 3
                $subFilters[0]->operator,
223 3
                $subFilters[0]->value->get($reflection)
224 3
            ];
225
        }
226
227 3
        return new NestedFilters($filters, CompositeExpression::TYPE_OR);
228
    }
229
}
230