Test Failed
Pull Request — main (#24)
by Anatoly
26:04 queued 21:36
created

Hydrator::hydrateEnumerableProperty()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 39
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 9

Importance

Changes 0
Metric Value
eloc 15
c 0
b 0
f 0
dl 0
loc 39
rs 8.0555
ccs 6
cts 6
cp 1
cc 9
nc 13
nop 6
crap 9
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 BackedEnum;
0 ignored issues
show
Bug introduced by
The type BackedEnum 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...
17
use Doctrine\Common\Annotations\AnnotationReader;
18
use Doctrine\Common\Annotations\Reader as AnnotationReaderInterface;
19
use JsonException;
20
use Sunrise\Hydrator\Annotation\Alias;
21
use Sunrise\Hydrator\Annotation\Format;
22
use Sunrise\Hydrator\Annotation\Relationship;
23
use Sunrise\Hydrator\Exception\InvalidDataException;
24
use Sunrise\Hydrator\Exception\InvalidValueException;
25
use DateTimeImmutable;
26
use LogicException;
27
use ReflectionAttribute;
28
use ReflectionClass;
29
use ReflectionEnum;
0 ignored issues
show
Bug introduced by
The type ReflectionEnum 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...
30
use ReflectionNamedType;
31
use ReflectionProperty;
32
use ValueError;
33
34
use function array_key_exists;
35
use function class_exists;
36
use function extension_loaded;
37
use function filter_var;
38
use function is_array;
39
use function is_bool;
40
use function is_float;
41
use function is_int;
42
use function is_object;
43
use function is_string;
44
use function is_subclass_of;
45
use function json_decode;
46
use function sprintf;
47
use function trim;
48
49
use const FILTER_NULL_ON_FAILURE;
50
use const FILTER_VALIDATE_BOOLEAN;
51
use const FILTER_VALIDATE_FLOAT;
52
use const FILTER_VALIDATE_INT;
53
use const JSON_THROW_ON_ERROR;
54
use const PHP_MAJOR_VERSION;
55
56
/**
57
 * Hydrator
58
 */
59
class Hydrator implements HydratorInterface
60
{
61
62
    /**
63
     * @var AnnotationReaderInterface|null
64
     */
65
    private ?AnnotationReaderInterface $annotationReader = null;
66
67
    /**
68
     * Gets the annotation reader
69
     *
70
     * @return AnnotationReaderInterface|null
71
     */
72
    public function getAnnotationReader(): ?AnnotationReaderInterface
73
    {
74
        return $this->annotationReader;
75
    }
76
77
    /**
78
     * Sets the given annotation reader
79
     *
80
     * @param AnnotationReaderInterface|null $annotationReader
81
     *
82
     * @return self
83
     */
84
    public function setAnnotationReader(?AnnotationReaderInterface $annotationReader): self
85
    {
86 2
        $this->annotationReader = $annotationReader;
87
88 2
        return $this;
89
    }
90 2
91
    /**
92
     * Uses the default annotation reader
93
     *
94
     * @return self
95
     *
96
     * @throws LogicException
97
     *         If the doctrine/annotations package isn't installed.
98
     */
99
    public function useDefaultAnnotationReader(): self
100
    {
101
        // @codeCoverageIgnoreStart
102
        if (!class_exists(AnnotationReader::class)) {
103
            throw new LogicException('The package doctrine/annotations is required.');
104
        } // @codeCoverageIgnoreEnd
105
106
        $this->annotationReader = new AnnotationReader();
107
108
        return $this;
109
    }
110
111
    /**
112
     * Hydrates the given object with the given data
113
     *
114
     * @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...
115
     * @param array<array-key, mixed> $data
116
     * @param list<array-key> $path
117
     *
118
     * @return T
119
     *
120
     * @throws Exception\InvalidDataException
121
     *         If the given data is invalid.
122
     *
123
     * @throws Exception\UninitializableObjectException
124
     *         If the object cannot be initialized.
125
     *
126
     * @throws Exception\UntypedPropertyException
127
     *         If one of the object properties isn't typed.
128
     *
129
     * @throws Exception\UnsupportedPropertyTypeException
130
     *         If one of the object properties contains an unsupported type.
131
     *
132
     * @template T of object
133
     */
134
    public function hydrate($object, array $data, array $path = []): object
135
    {
136
        $object = $this->instantObject($object);
137
        $class = new ReflectionClass($object);
138
        $properties = $class->getProperties();
139
        $defaultValues = $this->getClassConstructorDefaultValues($class);
140
        $violations = [];
141
        foreach ($properties as $property) {
142 89
            if ($property->isStatic()) {
143
                continue;
144 89
            }
145 4
146
            $key = $property->getName();
147
            $alias = $this->getPropertyAnnotation($property, Alias::class);
148 89
            if (isset($alias)) {
149 1
                $key = $alias->value;
150
            }
151
152
            if (array_key_exists($key, $data) === false) {
153
                if ($property->isInitialized($object)) {
154
                    continue;
155 88
                }
156
157 86
                if (array_key_exists($property->getName(), $defaultValues)) {
158 86
                    $property->setValue($object, $defaultValues[$property->getName()]);
159 86
                    continue;
160
                }
161 86
162 1
                $violations[] = InvalidValueException::shouldBeProvided([...$path, $key]);
163
164
                continue;
165 85
            }
166
167 85
            try {
168 1
                $this->hydrateProperty($object, $property, $data[$key], [...$path, $key]);
169
            } catch (InvalidDataException $e) {
170 1
                $violations = [...$violations, ...$e->getExceptions()];
171 1
            } catch (InvalidValueException $e) {
172
                $violations[] = $e;
173
            }
174
        }
175 84
176 1
        if (!empty($violations)) {
177
            throw new InvalidDataException('Invalid data.', $violations);
178 1
        }
179 1
180
        return $object;
181
    }
182
183 83
    /**
184 83
     * Hydrates the given object with the given JSON
185 5
     *
186 5
     * @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...
187 2
     * @param string $json
188
     * @param int<0, max> $flags
189
     * @param int<1, 2147483647> $depth
190
     *
191 83
     * @return T
192 5
     *
193 4
     * @throws Exception\InvalidDataException
194
     *         If the given data is invalid.
195 4
     *
196 4
     * @throws Exception\UninitializableObjectException
197
     *         If the object cannot be initialized.
198
     *
199
     * @throws Exception\UntypedPropertyException
200 1
     *         If one of the object properties isn't typed.
201
     *
202
     * @throws Exception\UnsupportedPropertyTypeException
203 78
     *         If one of the object properties contains an unsupported type.
204
     *
205
     * @template T of object
206 59
     */
207
    public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512): object
208
    {
209
        // @codeCoverageIgnoreStart
210
        if (!extension_loaded('json')) {
211
            throw new LogicException('JSON extension is required.');
212
        } // @codeCoverageIgnoreEnd
213
214
        try {
215
            $data = json_decode($json, true, $depth, $flags | JSON_THROW_ON_ERROR);
216
        } catch (JsonException $e) {
217
            throw new InvalidDataException(sprintf('Invalid JSON: %s', $e->getMessage()));
218
        }
219
220
        if (!is_array($data)) {
221
            throw new InvalidDataException('JSON must be an object.');
222
        }
223
224
        return $this->hydrate($object, $data);
225
    }
226 4
227
    /**
228 4
     * Instantiates the given object
229 4
     *
230 4
     * @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...
231 1
     *
232
     * @return T
233 1
     *
234
     * @throws Exception\UninitializableObjectException
235
     *         If the given object cannot be instantiated.
236
     *
237 3
     * @template T of object
238
     */
239
    private function instantObject($object): object
240
    {
241
        if (is_object($object)) {
242
            return $object;
243
        }
244
245
        $class = new ReflectionClass($object);
246
        if (!$class->isInstantiable()) {
247
            throw new Exception\UninitializableObjectException(sprintf(
248
                'The class %s cannot be hydrated because it is an uninstantiable class.',
249
                $class->getName(),
250
            ));
251
        }
252
253 88
        /** @var T */
254
        return $class->newInstanceWithoutConstructor();
255 88
    }
256 1
257
    /**
258
     * Hydrates the given property with the given value
259 87
     *
260 1
     * @param object $object
261
     * @param ReflectionProperty $property
262
     * @param mixed $value
263
     * @param list<array-key> $path
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...
264
     *
265
     * @return void
266 86
     *
267 86
     * @throws InvalidValueException
268 86
     *         If the given value is invalid.
269 1
     *
270
     * @throws InvalidDataException
271 1
     *         If the given value is invalid.
272
     *
273
     * @throws Exception\UntypedPropertyException
274
     *         If the given property isn't typed.
275
     *
276 85
     * @throws Exception\UnsupportedPropertyTypeException
277
     *         If the given property contains an unsupported type.
278
     */
279
    private function hydrateProperty(
280
        object $object,
281
        ReflectionProperty $property,
282
        $value,
283
        array $path
284
    ): void {
285
        $property->setAccessible(true);
286 5
287
        $type = $this->getPropertyType($property);
288 5
        $typeName = $type->getName();
289 5
290 5
        if ($value === null) {
291 1
            $this->hydratePropertyWithNull($object, $property, $type, $path);
292
            return;
293
        }
294
        if ($typeName === 'bool') {
295 4
            $this->hydrateBooleanProperty($object, $property, $type, $value, $path);
296 1
            return;
297 1
        }
298 1
        if ($typeName === 'int') {
299
            $this->hydrateIntegerProperty($object, $property, $type, $value, $path);
300
            return;
301
        }
302 3
        if ($typeName === 'float') {
303
            $this->hydrateNumericProperty($object, $property, $type, $value, $path);
304
            return;
305
        }
306
        if ($typeName === 'string') {
307
            $this->hydrateStringProperty($object, $property, $value, $path);
308
            return;
309
        }
310
        if ($typeName === 'array') {
311
            $this->hydrateArrayProperty($object, $property, $value, $path);
312
            return;
313
        }
314
        if ($typeName === DateTimeImmutable::class) {
315
            $this->hydrateTimestampProperty($object, $property, $type, $value, $path);
316
            return;
317
        }
318
        if (is_subclass_of($typeName, BackedEnum::class)) {
319
            $this->hydrateEnumerableProperty($object, $property, $type, $typeName, $value, $path);
320
            return;
321
        }
322 78
        if (class_exists($typeName)) {
323
            $this->hydrateRelationshipProperty($object, $property, $typeName, $value, $path);
324
            return;
325
        }
326
327
        throw new Exception\UnsupportedPropertyTypeException(sprintf(
328
            'The property %s.%s contains an unsupported type %s.',
329
            $property->getDeclaringClass()->getName(),
330 78
            $property->getName(),
331 1
            $typeName,
332
        ));
333
    }
334 78
335 3
    /**
336 2
     * Hydrates the given property with null
337
     *
338
     * @param object $object
339 75
     * @param ReflectionProperty $property
340 11
     * @param ReflectionNamedType $type
341 10
     * @param list<array-key> $path
342
     *
343
     * @return void
344 64
     *
345 3
     * @throws InvalidValueException
346 2
     *         If the given value isn't valid.
347
     */
348
    private function hydratePropertyWithNull(
349 61
        object $object,
350 5
        ReflectionProperty $property,
351 4
        ReflectionNamedType $type,
352
        array $path
353
    ): void {
354 56
        if (!$type->allowsNull()) {
355 17
            throw InvalidValueException::shouldNotBeEmpty($path);
356 15
        }
357
358
        $property->setValue($object, null);
359 45
    }
360 2
361 1
    /**
362
     * Hydrates the given boolean property with the given value
363
     *
364 43
     * @param object $object
365 2
     * @param ReflectionProperty $property
366 1
     * @param ReflectionNamedType $type
367
     * @param mixed $value
368
     * @param list<array-key> $path
369 41
     *
370 4
     * @return void
371 3
     *
372
     * @throws InvalidValueException
373
     *         If the given value isn't valid.
374 37
     */
375 4
    private function hydrateBooleanProperty(
376 3
        object $object,
377
        ReflectionProperty $property,
378
        ReflectionNamedType $type,
379 33
        $value,
380 3
        array $path
381 1
    ): void {
382
        if (is_string($value)) {
383
            // As part of the support for HTML forms and other untyped data sources,
384 30
            // an empty string should not be cast to a boolean type, therefore,
385 15
            // such values should be treated as NULL.
386 11
            if (trim($value) === '') {
387
                $this->hydratePropertyWithNull($object, $property, $type, $path);
388
                return;
389 17
            }
390 7
391 6
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L273
392
            $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
393
        }
394 10
395 6
        if (!is_bool($value)) {
396 4
            throw InvalidValueException::shouldBeBoolean($path);
397
        }
398
399 6
        $property->setValue($object, $value);
400 5
    }
401 4
402
    /**
403
     * Hydrates the given integer property with the given value
404 1
     *
405
     * @param object $object
406 1
     * @param ReflectionProperty $property
407 1
     * @param ReflectionNamedType $type
408 1
     * @param mixed $value
409
     * @param list<array-key> $path
410
     *
411
     * @return void
412
     *
413
     * @throws InvalidValueException
414
     *         If the given value isn't valid.
415
     */
416
    private function hydrateIntegerProperty(
417
        object $object,
418
        ReflectionProperty $property,
419
        ReflectionNamedType $type,
420
        $value,
421
        array $path
422
    ): void {
423
        if (is_string($value)) {
424
            // As part of the support for HTML forms and other untyped data sources,
425 3
            // an empty string cannot be cast to an integer type, therefore,
426
            // such values should be treated as NULL.
427
            if (trim($value) === '') {
428
                $this->hydratePropertyWithNull($object, $property, $type, $path);
429
                return;
430
            }
431 3
432 1
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94
433
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197
434 1
            $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
435 1
        }
436
437
        if (!is_int($value)) {
438
            throw InvalidValueException::shouldBeInteger($path);
439 2
        }
440
441
        $property->setValue($object, $value);
442
    }
443
444
    /**
445
     * Hydrates the given numeric property with the given value
446
     *
447
     * @param object $object
448
     * @param ReflectionProperty $property
449
     * @param ReflectionNamedType $type
450
     * @param mixed $value
451
     * @param list<array-key> $path
452
     *
453
     * @return void
454
     *
455
     * @throws InvalidValueException
456 11
     *         If the given value isn't valid.
457
     */
458
    private function hydrateNumericProperty(
459
        object $object,
460
        ReflectionProperty $property,
461
        ReflectionNamedType $type,
462
        $value,
463 11
        array $path
464
    ): void {
465
        if (is_string($value)) {
466
            // As part of the support for HTML forms and other untyped data sources,
467 9
            // an empty string cannot be cast to a number type, therefore,
468
            // such values should be treated as NULL.
469 9
            if (trim($value) === '') {
470 1
                $this->hydratePropertyWithNull($object, $property, $type, $path);
471
                return;
472 1
            }
473 1
474
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L342
475
            $value = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE);
476
        }
477
478 10
        if (is_int($value)) {
479
            $value = (float) $value;
480
        }
481
482
        if (!is_float($value)) {
483
            throw InvalidValueException::shouldBeNumber($path);
484
        }
485
486
        $property->setValue($object, $value);
487
    }
488
489
    /**
490
     * Hydrates the given string property with the given value
491
     *
492
     * @param object $object
493
     * @param ReflectionProperty $property
494
     * @param mixed $value
495 3
     * @param list<array-key> $path
496
     *
497
     * @return void
498
     *
499
     * @throws InvalidValueException
500
     *         If the given value isn't valid.
501
     */
502 3
    private function hydrateStringProperty(
503
        object $object,
504
        ReflectionProperty $property,
505
        $value,
506
        array $path
507
    ): void {
508 2
        if (!is_string($value)) {
509
            throw InvalidValueException::shouldBeString($path);
510 2
        }
511 1
512
        $property->setValue($object, $value);
513 1
    }
514 1
515
    /**
516
     * Hydrates the given array property with the given value
517
     *
518
     * @param object $object
519 2
     * @param ReflectionProperty $property
520
     * @param mixed $value
521
     * @param list<array-key> $path
522
     *
523
     * @return void
524
     *
525
     * @throws InvalidValueException
526
     *         If the given value isn't valid.
527
     */
528
    private function hydrateArrayProperty(
529
        object $object,
530
        ReflectionProperty $property,
531
        $value,
532
        array $path
533
    ): void {
534
        $relationship = $this->getPropertyAnnotation($property, Relationship::class);
535
        if (isset($relationship)) {
536 5
            $this->hydrateRelationshipsProperty($object, $property, $relationship, $value, $path);
537
            return;
538
        }
539
540
        if (!is_array($value)) {
541
            throw InvalidValueException::shouldBeArray($path);
542
        }
543 5
544
        $property->setValue($object, $value);
545
    }
546
547 4
    /**
548
     * Hydrates the given timestamp property with the given value
549 4
     *
550 1
     * @param object $object
551
     * @param ReflectionProperty $property
552 1
     * @param ReflectionNamedType $type
553 1
     * @param mixed $value
554
     * @param list<array-key> $path
555
     *
556
     * @return void
557
     *
558 4
     * @throws InvalidValueException
559
     *         If the given value isn't valid.
560
     *
561
     * @throws Exception\UnsupportedPropertyTypeException
562
     *         If the given property doesn't contain the Format attribute.
563
     */
564
    private function hydrateTimestampProperty(
565
        object $object,
566
        ReflectionProperty $property,
567
        ReflectionNamedType $type,
568
        $value,
569
        array $path
570
    ): void {
571
        $format = $this->getPropertyAnnotation($property, Format::class);
572
        if (!isset($format)) {
573
            throw new Exception\UnsupportedPropertyTypeException(sprintf(
574
                'The property %1$s.%2$s must contain the attribute %3$s, ' .
575 17
                'for example: #[\%3$s(\DateTimeInterface::DATE_RFC3339)].',
576
                $property->getDeclaringClass()->getName(),
577
                $property->getName(),
578
                Format::class,
579
            ));
580
        }
581
582 17
        if (is_string($value)) {
583 2
            // As part of the support for HTML forms and other untyped data sources,
584
            // an instance of DateTimeImmutable should not be created from an empty string, therefore,
585 2
            // such values should be treated as NULL.
586 2
            if (trim($value) === '') {
587
                $this->hydratePropertyWithNull($object, $property, $type, $path);
588
                return;
589
            }
590 15
591
            if ($format->value === 'U') {
592
                // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94
593
                // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197
594
                $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
595
            }
596
        }
597
598
        if ($format->value === 'U' && !is_int($value)) {
599
            throw InvalidValueException::shouldBeInteger($path);
600
        }
601
        if ($format->value !== 'U' && !is_string($value)) {
602
            throw InvalidValueException::shouldBeString($path);
603
        }
604
605
        /** @var int|string $value */
606
607 2
        $timestamp = DateTimeImmutable::createFromFormat($format->value, (string) $value);
608
        if ($timestamp === false) {
609
            throw InvalidValueException::invalidTimestamp($path, $format->value);
610
        }
611
612
        $property->setValue($object, $timestamp);
613
    }
614 2
615 1
    /**
616
     * Hydrates the given enumerable property with the given value
617 1
     *
618 1
     * @param object $object
619
     * @param ReflectionProperty $property
620
     * @param ReflectionNamedType $type
621
     * @param class-string<BackedEnum> $enumName
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<BackedEnum> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<BackedEnum>.
Loading history...
622 1
     * @param mixed $value
623
     * @param list<array-key> $path
624
     *
625
     * @return void
626
     *
627
     * @throws InvalidValueException
628
     *         If the given value isn't valid.
629
     */
630
    private function hydrateEnumerableProperty(
631
        object $object,
632
        ReflectionProperty $property,
633
        ReflectionNamedType $type,
634
        string $enumName,
635
        $value,
636
        array $path
637
    ): void {
638
        $enumType = (string) (new ReflectionEnum($enumName))->getBackingType();
639 2
640
        if (is_string($value)) {
641
            // As part of the support for HTML forms and other untyped data sources,
642
            // an instance of BackedEnum should not be created from an empty string, therefore,
643
            // such values should be treated as NULL.
644
            if (trim($value) === '') {
645
                $this->hydratePropertyWithNull($object, $property, $type, $path);
646 2
                return;
647 1
            }
648
649 1
            if ($enumType === 'int') {
650 1
                // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94
651
                // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197
652
                $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
653
            }
654 1
        }
655
656
        if ($enumType === 'int' && !is_int($value)) {
657
            throw InvalidValueException::shouldBeInteger($path);
658
        }
659
        if ($enumType === 'string' && !is_string($value)) {
660
            throw InvalidValueException::shouldBeString($path);
661
        }
662
663
        /** @var int|string $value */
664
665
        try {
666
            $property->setValue($object, $enumName::from($value));
667
        } catch (ValueError $e) {
668
            throw InvalidValueException::invalidChoice($path, $enumName);
669
        }
670
    }
671 8
672
    /**
673
     * Hydrates the given relationship property with the given value
674
     *
675
     * @param object $object
676
     * @param ReflectionProperty $property
677
     * @param class-string $className
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
678
     * @param mixed $value
679 8
     * @param list<array-key> $path
680
     *
681 8
     * @return void
682 2
     *
683 2
     * @throws InvalidValueException
684
     *         If the given value isn't valid.
685
     *
686 6
     * @throws Exception\UnsupportedPropertyTypeException
687 2
     *         If the given property refers to a non-instantiable class.
688 2
     */
689
    private function hydrateRelationshipProperty(
690
        object $object,
691 4
        ReflectionProperty $property,
692 2
        string $className,
693 2
        $value,
694
        array $path
695
    ): void {
696 2
        $classReflection = new ReflectionClass($className);
697
        if (!$classReflection->isInstantiable()) {
698 2
            throw new Exception\UnsupportedPropertyTypeException(sprintf(
699 2
                'The property %s.%s refers to a non-instantiable class %s.',
700
                $property->getDeclaringClass()->getName(),
701
                $property->getName(),
702
                $classReflection->getName(),
703
            ));
704
        }
705
706
        if (!is_array($value)) {
707
            throw InvalidValueException::shouldBeArray($path);
708
        }
709
710
        $classInstance = $classReflection->newInstanceWithoutConstructor();
711
712
        $property->setValue($object, $this->hydrate($classInstance, $value, $path));
713
    }
714
715
    /**
716
     * Hydrates the given relationships property with the given value
717 3
     *
718
     * @param object $object
719
     * @param ReflectionProperty $property
720
     * @param Relationship $relationship
721
     * @param mixed $value
722
     * @param list<array-key> $path
723
     *
724 3
     * @return void
725 1
     *
726
     * @throws InvalidDataException
727 1
     *         If the given value isn't valid.
728 1
     *
729
     * @throws Exception\UnsupportedPropertyTypeException
730
     *         If the given property refers to a non-instantiable class.
731
     */
732
    private function hydrateRelationshipsProperty(
733 2
        object $object,
734
        ReflectionProperty $property,
735
        Relationship $relationship,
736 2
        $value,
737 1
        array $path
738 1
    ): void {
739
        $classReflection = new ReflectionClass($relationship->target);
740 1
        if (!$classReflection->isInstantiable()) {
741 1
            throw new Exception\UnsupportedPropertyTypeException(sprintf(
742
                'The property %s.%s refers to a non-instantiable class %s.',
743
                $property->getDeclaringClass()->getName(),
744
                $property->getName(),
745 1
                $classReflection->getName(),
746
            ));
747
        }
748
749
        if (!is_array($value)) {
750
            throw InvalidValueException::shouldBeArray($path);
751
        }
752
753
        $counter = 0;
754
        $violations = [];
755
        $classInstances = [];
756
        $classPrototype = $classReflection->newInstanceWithoutConstructor();
757
        foreach ($value as $key => $data) {
758
            if (isset($relationship->limit) && ++$counter > $relationship->limit) {
759
                $violations[] = InvalidValueException::redundantElement([...$path, $key], $relationship->limit);
0 ignored issues
show
Bug introduced by
It seems like $relationship->limit can also be of type null; however, parameter $limit of Sunrise\Hydrator\Excepti...ion::redundantElement() does only seem to accept integer, 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

759
                $violations[] = InvalidValueException::redundantElement([...$path, $key], /** @scrutinizer ignore-type */ $relationship->limit);
Loading history...
760
                break;
761
            }
762 15
763
            if (!is_array($data)) {
764
                $violations[] = InvalidValueException::shouldBeArray([...$path, $key]);
765
                continue;
766
            }
767
768
            try {
769
                $classInstances[$key] = $this->hydrate(clone $classPrototype, $data, [...$path, $key]);
770 15
            } catch (InvalidDataException $e) {
771 15
                $violations = [...$violations, ...$e->getExceptions()];
772
            }
773
        }
774 15
775 15
        if (!empty($violations)) {
776
            throw new InvalidDataException('Invalid data.', $violations);
777
        }
778 15
779 4
        $property->setValue($object, $classInstances);
780
    }
781
782 15
    /**
783 15
     * Gets a type from the given property
784 2
     *
785
     * @param ReflectionProperty $property
786 2
     *
787 2
     * @return ReflectionNamedType
788
     *
789
     * @throws Exception\UntypedPropertyException
790
     *         If the given property isn't typed.
791
     *
792 13
     * @throws Exception\UnsupportedPropertyTypeException
793 13
     *         If the given property contains an unsupported type.
794 2
     */
795 2
    private function getPropertyType(ReflectionProperty $property): ReflectionNamedType
796 2
    {
797
        $type = $property->getType();
798
799 2
        if (!isset($type)) {
800
            throw new Exception\UntypedPropertyException(sprintf(
801 2
                'The property %s.%s is not typed.',
802 2
                $property->getDeclaringClass()->getName(),
803 2
                $property->getName(),
804
            ));
805
        }
806
807 11
        if (!($type instanceof ReflectionNamedType)) {
808
            throw new Exception\UnsupportedPropertyTypeException(sprintf(
809
                'The property %s.%s contains an unsupported type %s.',
810
                $property->getDeclaringClass()->getName(),
811
                $property->getName(),
812
                (string) $type,
813
            ));
814
        }
815
816
        return $type;
817
    }
818
819
    /**
820
     * Gets an annotation from the given property
821
     *
822
     * @param ReflectionProperty $property
823
     * @param class-string<T> $annotationName
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>.
Loading history...
824 7
     *
825
     * @return T|null
826
     *
827
     * @template T of object
828
     */
829
    private function getPropertyAnnotation(ReflectionProperty $property, string $annotationName): ?object
830
    {
831
        if (PHP_MAJOR_VERSION >= 8) {
832 7
            /**
833
             * @psalm-var list<ReflectionAttribute> $annotations
834 7
             * @phpstan-var list<ReflectionAttribute<T>> $annotations
835 7
             * @psalm-suppress TooManyTemplateParams
836 1
             */
837 1
            $annotations = $property->getAttributes($annotationName);
838 1
839
            if (isset($annotations[0])) {
840
                /** @var T */
841 1
                return $annotations[0]->newInstance();
842
            }
843 1
        }
844 1
845 1
        if (isset($this->annotationReader)) {
846
            $annotations = $this->annotationReader->getPropertyAnnotations($property);
0 ignored issues
show
Bug introduced by
The method getPropertyAnnotations() 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

846
            /** @scrutinizer ignore-call */ 
847
            $annotations = $this->annotationReader->getPropertyAnnotations($property);

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...
847
            foreach ($annotations as $annotation) {
848
                if ($annotation instanceof $annotationName) {
849 6
                    return $annotation;
850
                }
851
            }
852
        }
853
854
        return null;
855
    }
856
857
    /**
858
     * Gets default values from the given class's constructor
859
     *
860
     * @param ReflectionClass<T> $class
861
     *
862
     * @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...
863
     *
864
     * @template T of object
865
     */
866 5
    private function getClassConstructorDefaultValues(ReflectionClass $class): array
867
    {
868
        $result = [];
869
        $constructor = $class->getConstructor();
870
        if (isset($constructor)) {
871
            foreach ($constructor->getParameters() as $parameter) {
872
                if ($parameter->isDefaultValueAvailable()) {
873 5
                    /** @psalm-suppress MixedAssignment */
874 1
                    $result[$parameter->getName()] = $parameter->getDefaultValue();
875
                }
876 1
            }
877 1
        }
878
879
        return $result;
880
    }
881
}
882