Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
94.59% covered (success)
94.59%
70 / 74
51.85% covered (warning)
51.85%
28 / 54
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
StaticReflectionCache
100.00% covered (success)
100.00%
124 / 124
94.59% covered (success)
94.59%
70 / 74
51.85% covered (warning)
51.85%
28 / 54
100.00% covered (success)
100.00%
8 / 8
154.55
100.00% covered (success)
100.00%
1 / 1
 metadata
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
21 / 21
94.12% covered (success)
94.12%
16 / 17
23.81% covered (danger)
23.81%
5 / 21
100.00% covered (success)
100.00%
1 / 1
28.67
 parameterMetadata
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 columnName
100.00% covered (success)
100.00%
18 / 18
92.31% covered (success)
92.31%
12 / 13
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
8.12
 typeMetadata
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
12 / 12
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
8.83
 namedTypeMetadata
100.00% covered (success)
100.00%
21 / 21
92.86% covered (success)
92.86%
13 / 14
75.00% covered (warning)
75.00%
6 / 8
100.00% covered (success)
100.00%
1 / 1
6.56
 isBuiltinTypeName
100.00% covered (success)
100.00%
20 / 20
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Quma\Hydration;
6
7use BackedEnum;
8use Celemas\Quma\Column;
9use Celemas\Quma\Exception\InvalidHydrationTarget;
10use Celemas\Quma\Hydratable;
11use DateTime;
12use DateTimeImmutable;
13use ReflectionClass;
14use ReflectionNamedType;
15use ReflectionParameter;
16use ReflectionType;
17use ReflectionUnionType;
18use Throwable;
19
20/** @internal */
21final class StaticReflectionCache implements MetadataCache
22{
23    /** @var array<class-string, ClassMetadata> */
24    private static array $entries = [];
25
26    /** @param class-string $class */
27    #[\Override]
28    public function metadata(string $class): ClassMetadata
29    {
30        if (!array_key_exists($class, self::$entries)) {
31            self::$entries[$class] = $this->build($class);
32        }
33
34        return self::$entries[$class];
35    }
36
37    /** @psalm-suppress PossiblyUnusedMethod */
38    public static function reset(): void
39    {
40        self::$entries = [];
41    }
42
43    /** @param class-string $class */
44    private function build(string $class): ClassMetadata
45    {
46        if ($this->isBuiltinTypeName($class) || !class_exists($class)) {
47            throw InvalidHydrationTarget::forTarget(
48                $class,
49                reason: 'target is not an existing class',
50            );
51        }
52
53        if (is_a($class, Hydratable::class, true)) {
54            return new ClassMetadata($class, true, true, null);
55        }
56
57        $reflection = new ReflectionClass($class);
58
59        if (!$reflection->isInstantiable()) {
60            throw InvalidHydrationTarget::forTarget($class, reason: 'target is not instantiable');
61        }
62
63        $constructor = $reflection->getConstructor();
64
65        if ($constructor === null) {
66            return new ClassMetadata($class, false, true, []);
67        }
68
69        $parameters = [];
70
71        foreach ($constructor->getParameters() as $parameter) {
72            $parameters[] = $this->parameterMetadata($class, $parameter);
73        }
74
75        usort(
76            $parameters,
77            static fn(ParameterMetadata $a, ParameterMetadata $b): int => $a->position <=> $b->position,
78        );
79
80        return new ClassMetadata($class, false, true, $parameters);
81    }
82
83    /** @param class-string $class */
84    private function parameterMetadata(
85        string $class,
86        ReflectionParameter $parameter,
87    ): ParameterMetadata {
88        $name = $parameter->getName();
89
90        if ($parameter->isVariadic()) {
91            throw InvalidHydrationTarget::forParameter($class, $name, 'is variadic');
92        }
93
94        if ($parameter->isPassedByReference()) {
95            throw InvalidHydrationTarget::forParameter($class, $name, 'is by-reference');
96        }
97
98        $type = $parameter->getType();
99
100        if ($type === null) {
101            throw InvalidHydrationTarget::forParameter($class, $name, 'has no declared type');
102        }
103
104        $column = $this->columnName($class, $parameter, $name);
105        $hasDefault = $parameter->isDefaultValueAvailable();
106
107        return new ParameterMetadata(
108            $name,
109            $column,
110            $this->typeMetadata($class, $name, $type),
111            $type->allowsNull(),
112            $hasDefault,
113            $hasDefault ? $parameter->getDefaultValue() : null,
114            $parameter->getPosition(),
115        );
116    }
117
118    /**
119     * @param class-string $class
120     * @param non-empty-string $parameterName
121     * @return non-empty-string
122     */
123    private function columnName(
124        string $class,
125        ReflectionParameter $parameter,
126        string $parameterName,
127    ): string {
128        $attributes = $parameter->getAttributes(Column::class);
129
130        if ($attributes === []) {
131            return $parameterName;
132        }
133
134        try {
135            $column = $attributes[0]->newInstance();
136        } catch (Throwable) {
137            throw InvalidHydrationTarget::forParameter(
138                $class,
139                $parameterName,
140                'has an invalid #[Column] attribute',
141            );
142        }
143
144        $columnName = $column->name;
145
146        if ($columnName === '' || trim($columnName) === '') {
147            throw InvalidHydrationTarget::forParameter(
148                $class,
149                $parameterName,
150                'has an empty #[Column] name',
151            );
152        }
153
154        return $columnName;
155    }
156
157    /**
158     * @param class-string $class
159     * @param non-empty-string $parameterName
160     */
161    private function typeMetadata(
162        string $class,
163        string $parameterName,
164        ReflectionType $type,
165    ): TypeMetadata {
166        if ($type instanceof ReflectionNamedType) {
167            $name = $this->namedTypeMetadata($class, $parameterName, $type);
168
169            return new TypeMetadata('named', $type->allowsNull(), [$name]);
170        }
171
172        if ($type instanceof ReflectionUnionType) {
173            $names = [];
174
175            foreach ($type->getTypes() as $inner) {
176                if (!$inner instanceof ReflectionNamedType) {
177                    throw InvalidHydrationTarget::forParameter(
178                        $class,
179                        $parameterName,
180                        'uses an unsupported intersection or DNF type',
181                    );
182                }
183
184                if (strtolower($inner->getName()) === 'null') {
185                    continue;
186                }
187
188                $names[] = $this->namedTypeMetadata($class, $parameterName, $inner);
189            }
190
191            /**
192             * ReflectionUnionType always contains at least one non-null arm in valid PHP;
193             * Psalm cannot infer that after the runtime null-arm filter above.
194             *
195             * @var non-empty-list<NamedTypeMetadata> $names
196             */
197            return new TypeMetadata('union', $type->allowsNull(), $names);
198        }
199
200        throw InvalidHydrationTarget::forParameter(
201            $class,
202            $parameterName,
203            'uses an unsupported intersection type',
204        );
205    }
206
207    /**
208     * @param class-string $class
209     * @param non-empty-string $parameterName
210     */
211    private function namedTypeMetadata(
212        string $class,
213        string $parameterName,
214        ReflectionNamedType $type,
215    ): NamedTypeMetadata {
216        $name = $type->getName();
217        $lower = strtolower($name);
218
219        if ($type->isBuiltin()) {
220            if (in_array($lower, ['int', 'float', 'bool', 'string'], true)) {
221                return new NamedTypeMetadata($lower, true, null, $lower, null, null);
222            }
223
224            throw InvalidHydrationTarget::forParameter(
225                $class,
226                $parameterName,
227                "uses unsupported type {$name}",
228            );
229        }
230
231        if ($name === DateTimeImmutable::class) {
232            return new NamedTypeMetadata($name, false, DateTimeImmutable::class, null, 'immutable', null);
233        }
234
235        if ($name === DateTime::class) {
236            return new NamedTypeMetadata($name, false, DateTime::class, null, 'mutable', null);
237        }
238
239        if (is_subclass_of($name, BackedEnum::class)) {
240            return new NamedTypeMetadata($name, false, $name, null, null, $name);
241        }
242
243        throw InvalidHydrationTarget::forParameter(
244            $class,
245            $parameterName,
246            "uses unsupported type {$name}",
247        );
248    }
249
250    private function isBuiltinTypeName(string $target): bool
251    {
252        return in_array(
253            strtolower($target),
254            [
255                'array',
256                'bool',
257                'callable',
258                'false',
259                'float',
260                'int',
261                'iterable',
262                'mixed',
263                'never',
264                'null',
265                'object',
266                'string',
267                'true',
268                'void',
269            ],
270            true,
271        );
272    }
273}