Passed
Push — main ( e48091...6c9276 )
by Anatoly
05:36 queued 13s
created

Hydrator::getAnnotationReader()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Nekhay <[email protected]>
7
 * @copyright Copyright (c) 2021, Anatoly Nekhay
8
 * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/hydrator
10
 */
11
12
declare(strict_types=1);
13
14
namespace Sunrise\Hydrator;
15
16
use JsonException;
17
use LogicException;
18
use ReflectionClass;
19
use ReflectionNamedType;
20
use ReflectionProperty;
21
use SimdJsonException;
0 ignored issues
show
Bug introduced by
The type SimdJsonException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
use Sunrise\Hydrator\Annotation\Alias;
23
use Sunrise\Hydrator\Annotation\Ignore;
24
use Sunrise\Hydrator\Exception\InvalidDataException;
25
use Sunrise\Hydrator\Exception\InvalidValueException;
26
use Sunrise\Hydrator\TypeConverter\ArrayTypeConverter;
27
use Sunrise\Hydrator\TypeConverter\BackedEnumTypeConverter;
28
use Sunrise\Hydrator\TypeConverter\BoolTypeConverter;
29
use Sunrise\Hydrator\TypeConverter\FloatTypeConverter;
30
use Sunrise\Hydrator\TypeConverter\IntTypeConverter;
31
use Sunrise\Hydrator\TypeConverter\RelationshipTypeConverter;
32
use Sunrise\Hydrator\TypeConverter\StringTypeConverter;
33
use Sunrise\Hydrator\TypeConverter\TimestampTypeConverter;
34
use Sunrise\Hydrator\TypeConverter\TimezoneTypeConverter;
35
use Sunrise\Hydrator\TypeConverter\UidTypeConverter;
36
37
use function array_key_exists;
38
use function extension_loaded;
39
use function is_array;
40
use function is_object;
41
use function json_decode;
42
use function simdjson_decode;
0 ignored issues
show
introduced by
The function simdjson_decode was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
43
use function sprintf;
44
use function usort;
45
46
use const JSON_THROW_ON_ERROR;
47
use const PHP_MAJOR_VERSION;
48
use const PHP_VERSION_ID;
49
50
/**
51
 * Hydrator
52
 */
53
class Hydrator implements HydratorInterface
54
{
55
56
    /**
57
     * @var AnnotationReaderInterface
58
     */
59
    private AnnotationReaderInterface $annotationReader;
60
61
    /**
62
     * @var list<TypeConverterInterface>
0 ignored issues
show
Bug introduced by
The type Sunrise\Hydrator\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
63
     */
64
    private array $typeConverters = [];
65
66
    /**
67
     * Constructor of the class
68
     */
69 506
    public function __construct()
70
    {
71 506
        $this->annotationReader = PHP_MAJOR_VERSION >= 8 ? new AnnotationReader() : DoctrineAnnotationReader::default();
72
73 506
        $this->addTypeConverter(
74 506
            new BoolTypeConverter(),
75 506
            new IntTypeConverter(),
76 506
            new FloatTypeConverter(),
77 506
            new StringTypeConverter(),
78 506
            new BackedEnumTypeConverter(),
79 506
            new TimestampTypeConverter(),
80 506
            new TimezoneTypeConverter(),
81 506
            new UidTypeConverter(),
82 506
            new ArrayTypeConverter(),
83 506
            new RelationshipTypeConverter(),
84 506
        );
85
    }
86
87
    /**
88
     * @inheritDoc
89
     */
90 506
    public function addTypeConverter(TypeConverterInterface ...$typeConverters): void
91
    {
92 506
        foreach ($typeConverters as $typeConverter) {
93 506
            if ($typeConverter instanceof AnnotationReaderAwareInterface) {
94 506
                $typeConverter->setAnnotationReader($this->annotationReader);
95
            }
96 506
            if ($typeConverter instanceof HydratorAwareInterface) {
97 506
                $typeConverter->setHydrator($this);
98
            }
99
100 506
            $this->typeConverters[] = $typeConverter;
101
        }
102
103
        // phpcs:ignore Generic.Files.LineLength
104 506
        usort($this->typeConverters, static fn(TypeConverterInterface $a, TypeConverterInterface $b): int => $b->getWeight() <=> $a->getWeight());
105
    }
106
107
    /**
108
     * @inheritDoc
109
     */
110 441
    public function castValue($value, Type $type, array $path)
111
    {
112 441
        foreach ($this->typeConverters as $typeConverter) {
113 441
            $result = $typeConverter->castValue($value, $type, $path);
114 441
            if ($result->valid()) {
115 325
                return $result->current();
116
            }
117
        }
118
119 1
        throw Exception\UnsupportedPropertyTypeException::unsupportedType($type->getHolder(), $type->getName());
120
    }
121
122
    /**
123
     * Sets the given annotation reader
124
     *
125
     * @param AnnotationReaderInterface|\Doctrine\Common\Annotations\Reader $annotationReader
126
     *
127
     * @return self
128
     */
129
    public function setAnnotationReader($annotationReader): self
130
    {
131
        if ($annotationReader instanceof AnnotationReaderInterface) {
132
            $this->annotationReader = $annotationReader;
133
            return $this;
134
        }
135
136
        // BC with previous versions...
137
        if ($annotationReader instanceof \Doctrine\Common\Annotations\Reader) {
0 ignored issues
show
introduced by
$annotationReader is always a sub-type of Doctrine\Common\Annotations\Reader.
Loading history...
138
            $this->annotationReader = new DoctrineAnnotationReader($annotationReader);
139
            return $this;
140
        }
141
142
        throw new LogicException('Unsupported annotation reader');
143
    }
144
145
    /**
146
     * Uses the doctrine's default annotation reader
147
     *
148
     * @return self
149
     *
150
     * @throws LogicException If the doctrine/annotations package isn't installed on the server.
151
     *
152
     * @deprecated 3.1.0
153
     */
154 1
    public function useDefaultAnnotationReader(): self
155
    {
156 1
        $this->annotationReader = DoctrineAnnotationReader::default();
157
158 1
        return $this;
159
    }
160
161
    /**
162
     * Hydrates the given object with the given data
163
     *
164
     * @param class-string<T>|T $object
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T>|T at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>|T.
Loading history...
165
     * @param array<array-key, mixed> $data
166
     * @param list<array-key> $path
167
     *
168
     * @return T
169
     *
170
     * @throws Exception\InvalidDataException
171
     *         If the given data is invalid.
172
     *
173
     * @throws Exception\UninitializableObjectException
174
     *         If the object cannot be initialized.
175
     *
176
     * @throws Exception\UntypedPropertyException
177
     *         If one of the object properties isn't typed.
178
     *
179
     * @throws Exception\UnsupportedPropertyTypeException
180
     *         If one of the object properties contains an unsupported type.
181
     *
182
     * @template T of object
183
     */
184 504
    public function hydrate($object, array $data, array $path = []): object
185
    {
186 504
        [$object, $class] = $this->instantObject($object);
187 503
        $properties = $class->getProperties();
188 503
        $defaultValues = $this->getClassConstructorDefaultValues($class);
189 503
        $violations = [];
190 503
        foreach ($properties as $property) {
191 503
            if (PHP_VERSION_ID < 80100) {
192
                /** @psalm-suppress UnusedMethodCall */
193
                $property->setAccessible(true);
194
            }
195
196 503
            if ($property->isStatic()) {
197 1
                continue;
198
            }
199
200 502
            if ($this->annotationReader->getAnnotations($property, Ignore::class)->valid()) {
201 1
                continue;
202
            }
203
204 501
            $key = $property->getName();
205 501
            $alias = $this->annotationReader->getAnnotations($property, Alias::class)->current();
206 501
            if (isset($alias)) {
207 2
                $key = $alias->value;
208
            }
209
210 501
            if (array_key_exists($key, $data) === false) {
211 37
                if ($property->isInitialized($object)) {
212 15
                    continue;
213
                }
214
215 22
                if (array_key_exists($property->getName(), $defaultValues)) {
216 1
                    $property->setValue($object, $defaultValues[$property->getName()]);
217 1
                    continue;
218
                }
219
220 21
                $violations[] = InvalidValueException::shouldBeProvided([...$path, $key]);
221 21
                continue;
222
            }
223
224
            try {
225 471
                $this->hydrateProperty($object, $property, $data[$key], [...$path, $key]);
226 133
            } catch (InvalidDataException $e) {
227 17
                $violations = [...$violations, ...$e->getExceptions()];
228 129
            } catch (InvalidValueException $e) {
229 123
                $violations[] = $e;
230
            }
231
        }
232
233 497
        if (!empty($violations)) {
234 145
            throw new InvalidDataException('Invalid data.', $violations);
235
        }
236
237 355
        return $object;
238
    }
239
240
    /**
241
     * Hydrates the given object with the given JSON
242
     *
243
     * @param class-string<T>|T $object
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T>|T at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>|T.
Loading history...
244
     * @param string $json
245
     * @param int<0, max> $flags
246
     * @param int<1, 2147483647> $depth
247
     * @param list<array-key> $path
248
     *
249
     * @return T
250
     *
251
     * @throws Exception\InvalidDataException
252
     *         If the given data is invalid.
253
     *
254
     * @throws Exception\UninitializableObjectException
255
     *         If the object cannot be initialized.
256
     *
257
     * @throws Exception\UntypedPropertyException
258
     *         If one of the object properties isn't typed.
259
     *
260
     * @throws Exception\UnsupportedPropertyTypeException
261
     *         If one of the object properties contains an unsupported type.
262
     *
263
     * @template T of object
264
     */
265 3
    public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512, array $path = []): object
266
    {
267
        // @codeCoverageIgnoreStart
268
        if (!extension_loaded('json') && !extension_loaded('simdjson')) {
269
            throw new LogicException('Requires JSON or Simdjson extension.');
270
        } // @codeCoverageIgnoreEnd
271
272
        try {
273
            // phpcs:ignore Generic.Files.LineLength
274 3
            $data = extension_loaded('simdjson') ? simdjson_decode($json, true, $depth) : json_decode($json, true, $depth, $flags | JSON_THROW_ON_ERROR);
0 ignored issues
show
Bug introduced by
The function simdjson_decode was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

274
            $data = extension_loaded('simdjson') ? /** @scrutinizer ignore-call */ simdjson_decode($json, true, $depth) : json_decode($json, true, $depth, $flags | JSON_THROW_ON_ERROR);
Loading history...
275 1
        } catch (JsonException|SimdJsonException $e) {
276
            // phpcs:ignore Generic.Files.LineLength
277 1
            throw new InvalidDataException(sprintf('The JSON is invalid and couldn‘t be decoded due to: %s', $e->getMessage()));
278
        }
279
280 2
        if (!is_array($data)) {
281 1
            throw new InvalidDataException('The JSON must be in the form of an array or an object.');
282
        }
283
284 1
        return $this->hydrate($object, $data);
285
    }
286
287
    /**
288
     * Instantiates the given object
289
     *
290
     * @param class-string<T>|T $object
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T>|T at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>|T.
Loading history...
291
     *
292
     * @return array{0: T, 1: ReflectionClass}
293
     *
294
     * @throws Exception\UninitializableObjectException
295
     *         If the given object cannot be instantiated.
296
     *
297
     * @template T of object
298
     */
299 504
    private function instantObject($object): array
300
    {
301 504
        $class = new ReflectionClass($object);
302
303 504
        if (is_object($object)) {
304 73
            return [$object, $class];
305
        }
306
307 503
        if (!$class->isInstantiable()) {
308 1
            throw new Exception\UninitializableObjectException(sprintf(
309 1
                'The class %s cannot be hydrated because it is an uninstantiable class.',
310 1
                $class->getName(),
311 1
            ));
312
        }
313
314 502
        return [$class->newInstanceWithoutConstructor(), $class];
315
    }
316
317
    /**
318
     * Hydrates the given property with the given value
319
     *
320
     * @param object $object
321
     * @param ReflectionProperty $property
322
     * @param mixed $value
323
     * @param list<array-key> $path
324
     *
325
     * @return void
326
     *
327
     * @throws InvalidDataException If the given value is invalid.
328
     *
329
     * @throws InvalidValueException If the given value is invalid.
330
     *
331
     * @throws Exception\UntypedPropertyException If the given property isn't typed.
332
     *
333
     * @throws Exception\UnsupportedPropertyTypeException If the given property contains an unsupported type.
334
     */
335 471
    private function hydrateProperty(object $object, ReflectionProperty $property, $value, array $path): void
336
    {
337 471
        $type = $this->getPropertyType($property);
338
339 469
        if ($value === null) {
340 32
            if (!$type->allowsNull()) {
341 17
                throw InvalidValueException::shouldNotBeEmpty($path);
342
            }
343
344 15
            $property->setValue($object, null);
345 15
            return;
346
        }
347
348 441
        $property->setValue($object, $this->castValue($value, $type, $path));
349
    }
350
351
    /**
352
     * Gets the given property's type
353
     *
354
     * @param ReflectionProperty $property
355
     *
356
     * @return Type
357
     *
358
     * @throws Exception\UntypedPropertyException If the given property isn't typed.
359
     *
360
     * @throws Exception\UnsupportedPropertyTypeException If the given property contains an unsupported type.
361
     */
362 471
    private function getPropertyType(ReflectionProperty $property): Type
363
    {
364 471
        $type = $property->getType();
365
366 471
        if (!isset($type)) {
367 1
            throw new Exception\UntypedPropertyException(sprintf(
368 1
                'The property %s.%s is not typed.',
369 1
                $property->getDeclaringClass()->getName(),
370 1
                $property->getName(),
371 1
            ));
372
        }
373
374 470
        if (!($type instanceof ReflectionNamedType)) {
375 1
            throw Exception\UnsupportedPropertyTypeException::unsupportedType($property, (string) $type);
376
        }
377
378 469
        return new Type($property, $type->getName(), $type->allowsNull());
379
    }
380
381
    /**
382
     * Gets default values from the given class's constructor
383
     *
384
     * @param ReflectionClass<T> $class
385
     *
386
     * @return array<non-empty-string, mixed>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<non-empty-string, mixed> at position 2 could not be parsed: Unknown type name 'non-empty-string' at position 2 in array<non-empty-string, mixed>.
Loading history...
387
     *
388
     * @template T of object
389
     */
390 503
    private function getClassConstructorDefaultValues(ReflectionClass $class): array
391
    {
392 503
        $constructor = $class->getConstructor();
393 503
        if ($constructor === null) {
394 502
            return [];
395
        }
396
397 1
        $result = [];
398 1
        foreach ($constructor->getParameters() as $parameter) {
399 1
            if ($parameter->isDefaultValueAvailable()) {
400
                /** @psalm-suppress MixedAssignment */
401 1
                $result[$parameter->getName()] = $parameter->getDefaultValue();
402
            }
403
        }
404
405 1
        return $result;
406
    }
407
}
408