Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
95.59% covered (success)
95.59%
65 / 68
55.77% covered (warning)
55.77%
29 / 52
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
StaticReflectionCache
100.00% covered (success)
100.00%
95 / 95
95.59% covered (success)
95.59%
65 / 68
55.77% covered (warning)
55.77%
29 / 52
100.00% covered (success)
100.00%
7 / 7
114.16
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%
20 / 20
93.33% covered (success)
93.33%
14 / 15
42.86% covered (danger)
42.86%
6 / 14
100.00% covered (success)
100.00%
1 / 1
12.72
 parameterMetadata
100.00% covered (success)
100.00%
18 / 18
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
62.50% covered (warning)
62.50%
5 / 8
100.00% covered (success)
100.00%
1 / 1
6.32
 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%
14 / 14
92.86% covered (success)
92.86%
13 / 14
40.00% covered (danger)
40.00%
6 / 15
100.00% covered (success)
100.00%
1 / 1
13.78
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma\Hydration;
6
7use BackedEnum;
8use Celema\Quma\Column;
9use Celema\Quma\Exception\InvalidHydrationTarget;
10use Celema\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 The tests clear the cache with it. */
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        // Builtin type names like `int` are never class names.
47        if (!class_exists($class)) {
48            throw InvalidHydrationTarget::forTarget(
49                $class,
50                reason: 'target is not an existing class',
51            );
52        }
53
54        if (is_a($class, Hydratable::class, true)) {
55            return new ClassMetadata($class, true, true, null);
56        }
57
58        $reflection = new ReflectionClass($class);
59
60        if (!$reflection->isInstantiable()) {
61            throw InvalidHydrationTarget::forTarget($class, reason: 'target is not instantiable');
62        }
63
64        $constructor = $reflection->getConstructor();
65
66        if ($constructor === null) {
67            throw InvalidHydrationTarget::forTarget(
68                $class,
69                reason: 'target has no constructor; declare a public constructor or implement Hydratable',
70            );
71        }
72
73        $parameters = [];
74
75        // Reflection lists the parameters in declaration order.
76        foreach ($constructor->getParameters() as $parameter) {
77            $parameters[] = $this->parameterMetadata($class, $parameter);
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        );
115    }
116
117    /**
118     * @param class-string $class
119     * @param non-empty-string $parameterName
120     * @return non-empty-string
121     */
122    private function columnName(
123        string $class,
124        ReflectionParameter $parameter,
125        string $parameterName,
126    ): string {
127        $attributes = $parameter->getAttributes(Column::class);
128
129        if ($attributes === []) {
130            return $parameterName;
131        }
132
133        try {
134            $column = $attributes[0]->newInstance();
135        } catch (Throwable) {
136            throw InvalidHydrationTarget::forParameter(
137                $class,
138                $parameterName,
139                'has an invalid #[Column] attribute',
140            );
141        }
142
143        $columnName = $column->name;
144
145        if ($columnName === '' || trim($columnName) === '') {
146            throw InvalidHydrationTarget::forParameter(
147                $class,
148                $parameterName,
149                'has an empty #[Column] name',
150            );
151        }
152
153        return $columnName;
154    }
155
156    /**
157     * @param class-string $class
158     * @param non-empty-string $parameterName
159     */
160    private function typeMetadata(
161        string $class,
162        string $parameterName,
163        ReflectionType $type,
164    ): TypeMetadata {
165        if ($type instanceof ReflectionNamedType) {
166            $name = $this->namedTypeMetadata($class, $parameterName, $type);
167
168            return new TypeMetadata('named', $type->allowsNull(), [$name]);
169        }
170
171        if ($type instanceof ReflectionUnionType) {
172            $names = [];
173
174            foreach ($type->getTypes() as $inner) {
175                if (!$inner instanceof ReflectionNamedType) {
176                    throw InvalidHydrationTarget::forParameter(
177                        $class,
178                        $parameterName,
179                        'uses an unsupported intersection or DNF type',
180                    );
181                }
182
183                if ($inner->getName() === 'null') {
184                    continue;
185                }
186
187                $names[] = $this->namedTypeMetadata($class, $parameterName, $inner);
188            }
189
190            /**
191             * ReflectionUnionType always contains at least one non-null arm in valid PHP;
192             * Psalm cannot infer that after the runtime null-arm filter above.
193             *
194             * @var non-empty-list<NamedTypeMetadata> $names
195             */
196            return new TypeMetadata('union', $type->allowsNull(), $names);
197        }
198
199        throw InvalidHydrationTarget::forParameter(
200            $class,
201            $parameterName,
202            'uses an unsupported intersection type',
203        );
204    }
205
206    /**
207     * @param class-string $class
208     * @param non-empty-string $parameterName
209     */
210    private function namedTypeMetadata(
211        string $class,
212        string $parameterName,
213        ReflectionNamedType $type,
214    ): NamedTypeMetadata {
215        // Reflection reports builtin type names in lowercase.
216        $name = $type->getName();
217
218        if ($type->isBuiltin() && in_array($name, ['int', 'float', 'bool', 'string'], true)) {
219            return new NamedTypeMetadata($name, $name, null, null);
220        }
221
222        if ($name === DateTimeImmutable::class) {
223            return new NamedTypeMetadata($name, null, 'immutable', null);
224        }
225
226        if ($name === DateTime::class) {
227            return new NamedTypeMetadata($name, null, 'mutable', null);
228        }
229
230        if (is_subclass_of($name, BackedEnum::class)) {
231            return new NamedTypeMetadata($name, null, null, $name);
232        }
233
234        throw InvalidHydrationTarget::forParameter(
235            $class,
236            $parameterName,
237            "uses unsupported type {$name}",
238        );
239    }
240}