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

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