Passed
Push — feature-FRAM-112-closure-filte... ( c8f14f )
by Vincent
06:52
created

ClosureCompiler::parseClosure()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 27
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4.0039

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 27
ccs 15
cts 16
cp 0.9375
rs 9.7666
cc 4
nc 4
nop 1
crap 4.0039
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 27
    private function checkParameterType(?ReflectionType $type): void
132
    {
133 27
        if ($type === null) {
134 1
            throw new InvalidArgumentException('Closure parameter must declare the entity type');
135
        }
136
137 26
        $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
138 26
        $typeName = null;
139
140 26
        foreach ($types as $atomicType) {
141 26
            if (!$atomicType instanceof ReflectionNamedType) {
142
                continue;
143
            }
144
145 26
            $typeName = $atomicType->getName();
146
147 26
            if ($typeName === $this->repository->entityName()) {
148 24
                return;
149
            }
150
151 2
            if (is_subclass_of($this->repository->entityName(), $typeName)) {
152 1
                return;
153
            }
154
        }
155
156 1
        throw new InvalidArgumentException(sprintf('Expect parameter of type "%s" but get "%s"', $this->repository->entityName(), $typeName));
157
    }
158
159 12
    private function validateFilters(AndFilter $filters): void
160
    {
161 12
        $metadata = $this->repository->metadata();
162
163 12
        foreach ($filters->filters as $filter) {
164 12
            if ($filter instanceof OrFilter) {
165 3
                foreach ($filter->filters as $subFilters) {
166 3
                    $this->validateFilters($subFilters);
167
                }
168 3
                continue;
169
            }
170
171 12
            $property = $filter->property;
172
173 12
            if ($metadata->attributeExists($property)) {
174 10
                continue;
175
            }
176
177
            // Check if it's on an embedded or relation
178 5
            if (str_contains($property, '.') && $metadata->embedded(strchr($property, '.', true))) {
179 3
                continue;
180
            }
181
182 2
            throw new InvalidArgumentException(sprintf('Property "%s" is not mapped to database.', $filter->property));
183
        }
184
    }
185
186 10
    private function normalizeFilters(ReflectionFunction $reflection, AndFilter $compiledFilters): NestedFilters
187
    {
188 10
        $filters = [];
189
190 10
        foreach ($compiledFilters as $filter) {
191 10
            if ($filter instanceof OrFilter) {
192 3
                $filters[] = [$this->normalizeOrFilters($reflection, $filter), null, null];
193 3
                continue;
194
            }
195
196 10
            $filters[] = [
197 10
                $filter->property,
198 10
                $filter->operator,
199 10
                $filter->value->get($reflection)
200 10
            ];
201
        }
202
203 10
        return new NestedFilters($filters);
204
    }
205
206 3
    private function normalizeOrFilters(ReflectionFunction $reflection, OrFilter $compiledFilters): NestedFilters
207
    {
208 3
        $filters = [];
209
210 3
        foreach ($compiledFilters->filters as $subFilters) {
211 3
            if (count($subFilters) !== 1) {
212 1
                $filters[] = [$this->normalizeFilters($reflection, $subFilters), null, null];
213 1
                continue;
214
            }
215
216 3
            $filters[] = [
217 3
                $subFilters[0]->property,
218 3
                $subFilters[0]->operator,
219 3
                $subFilters[0]->value->get($reflection)
220 3
            ];
221
        }
222
223 3
        return new NestedFilters($filters, CompositeExpression::TYPE_OR);
224
    }
225
}
226