Passed
Pull Request — main (#8)
by Anatoly
02:31
created

Hydrator::hydrateProperty()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 17
c 2
b 0
f 0
dl 0
loc 32
rs 9.3888
ccs 18
cts 18
cp 1
cc 5
nc 5
nop 5
crap 5
1
<?php declare(strict_types=1);
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Fenric <[email protected]>
7
 * @copyright Copyright (c) 2021, Anatoly Fenric
8
 * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/hydrator
10
 */
11
12
namespace Sunrise\Hydrator;
13
14
/**
15
 * Import classes
16
 */
17
use Doctrine\Common\Annotations\SimpleAnnotationReader;
18
use Sunrise\Hydrator\Annotation\Alias;
19
use InvalidArgumentException;
20
use ReflectionClass;
21
use ReflectionProperty;
22
use ReflectionNamedType;
23
use ReflectionUnionType;
24
25
/**
26
 * Import functions
27
 */
28
use function array_key_exists;
29
use function class_exists;
30
use function ctype_digit;
31
use function filter_var;
32
use function is_array;
33
use function is_bool;
34
use function is_float;
35
use function is_int;
36
use function is_object;
37
use function is_string;
38
use function is_subclass_of;
39
use function json_decode;
40
use function json_last_error;
41
use function json_last_error_msg;
42
use function sprintf;
43
use function strtotime;
44
45
/**
46
 * Import constants
47
 */
48
use const FILTER_NULL_ON_FAILURE;
49
use const FILTER_VALIDATE_BOOLEAN;
50
use const FILTER_VALIDATE_FLOAT;
51
use const FILTER_VALIDATE_INT;
52
use const JSON_ERROR_NONE;
53
use const PHP_MAJOR_VERSION;
54
55
/**
56
 * Hydrator
57
 */
58
class Hydrator implements HydratorInterface
59
{
60
61
    /**
62
     * @var array<string, string>
63
     */
64
    private const PROPERTY_HYDRATOR_MAP = [
65
        'bool' => 'hydratePropertyWithBooleanValue',
66
        'int' => 'hydratePropertyWithIntegerNumber',
67
        'float' => 'hydratePropertyWithNumber',
68
        'string' => 'hydratePropertyWithString',
69
        'array' => 'hydratePropertyWithArray',
70
        'object' => 'hydratePropertyWithObject',
71
        'DateTime' => 'hydratePropertyWithTimestamp',
72
        'DateTimeImmutable' => 'hydratePropertyWithTimestamp',
73
    ];
74
75
    /**
76
     * @var SimpleAnnotationReader|null
77
     */
78
    private $annotationReader = null;
79
80
    /**
81
     * Enables support for annotations
82
     *
83
     * @return self
84
     */
85 4
    public function useAnnotations() : self
86
    {
87 4
        if (isset($this->annotationReader)) {
88 1
            return $this;
89
        }
90
91 4
        if (class_exists(SimpleAnnotationReader::class)) {
92 4
            $this->annotationReader = /** @scrutinizer ignore-deprecated */ new SimpleAnnotationReader();
93 4
            $this->annotationReader->addNamespace('Sunrise\Hydrator\Annotation');
94
        }
95
96 4
        return $this;
97
    }
98
99
    /**
100
     * {@inheritdoc}
101
     *
102
     * @throws Exception\UntypedPropertyException
103
     *         If one of the object properties isn't typed.
104
     *
105
     * @throws Exception\UnsupportedPropertyTypeException
106
     *         If one of the object properties contains an unsupported type.
107
     *
108
     * @throws Exception\MissingRequiredValueException
109
     *         If the given data doesn't contain required value.
110
     *
111
     * @throws Exception\InvalidValueException
112
     *         If the given data contains an invalid value.
113
     */
114 26
    public function hydrate($object, array $data) : object
115
    {
116 26
        $object = $this->initializeObject($object);
117
118 24
        $class = new ReflectionClass($object);
119 24
        $properties = $class->getProperties();
120 24
        foreach ($properties as $property) {
121
            // statical properties cannot be hydrated...
122 24
            if ($property->isStatic()) {
123 1
                continue;
124
            }
125
126 24
            $property->setAccessible(true);
127
128 24
            if (!$property->hasType()) {
129 1
                throw new Exception\UntypedPropertyException(sprintf(
130 1
                    'The %s.%s property is not typed.',
131 1
                    $class->getShortName(),
132 1
                    $property->getName()
133
                ));
134
            }
135
136 23
            if ($property->getType() instanceof ReflectionUnionType) {
137 1
                throw new Exception\UnsupportedPropertyTypeException(sprintf(
138 1
                    'The %s.%s property contains an union type that is not supported.',
139 1
                    $class->getShortName(),
140 1
                    $property->getName()
141
                ));
142
            }
143
144 22
            $key = $property->getName();
145 22
            if (!array_key_exists($key, $data)) {
146 3
                $alias = $this->getPropertyAlias($property);
147 3
                if (isset($alias)) {
148 2
                    $key = $alias->value;
149
                }
150
            }
151
152 22
            if (!array_key_exists($key, $data)) {
153 2
                if (!$property->isInitialized($object)) {
154 1
                    throw new Exception\MissingRequiredValueException($property, sprintf(
155 1
                        'The %s.%s property is required.',
156 1
                        $class->getShortName(),
157 1
                        $property->getName()
158
                    ));
159
                }
160
161 1
                continue;
162
            }
163
164 21
            $this->hydrateProperty($object, $class, $property, $property->getType(), $data[$key]);
165
        }
166
167 9
        return $object;
168
    }
169
170
    /**
171
     * {@inheritdoc}
172
     *
173
     * @throws InvalidArgumentException
174
     *         If the given JSON cannot be decoded.
175
     */
176 2
    public function hydrateWithJson($object, string $json) : object
177
    {
178 2
        json_decode(''); // reset previous error...
179 2
        $data = (array) json_decode($json, true);
180 2
        if (JSON_ERROR_NONE <> json_last_error()) {
181 1
            throw new InvalidArgumentException(sprintf(
182 1
                'Unable to decode JSON: %s',
183 1
                json_last_error_msg()
184
            ));
185
        }
186
187 1
        return $this->hydrate($object, $data);
188
    }
189
190
    /**
191
     * Initializes the given object
192
     *
193
     * @param object|class-string $object
0 ignored issues
show
Documentation Bug introduced by
The doc comment object|class-string at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in object|class-string.
Loading history...
194
     *
195
     * @return object
196
     *
197
     * @throws InvalidArgumentException
198
     *         If the given object cannot be initialized.
199
     */
200 26
    private function initializeObject($object) : object
201
    {
202 26
        if (is_object($object)) {
203 1
            return $object;
204
        }
205
206 26
        if (!is_string($object) || !class_exists($object)) {
207 1
            throw new InvalidArgumentException(sprintf(
208 1
                'The method %s::hydrate() expects an object or name of an existing class.',
209
                __CLASS__
210 1
            ));
211
        }
212
213 25
        $class = new ReflectionClass($object);
214 25
        $constructor = $class->getConstructor();
215 25
        if (isset($constructor) && $constructor->getNumberOfRequiredParameters() > 0) {
216 1
            throw new InvalidArgumentException(sprintf(
217 1
                'The object %s cannot be hydrated because its constructor has required parameters.',
218 1
                $class->getName()
219
            ));
220
        }
221
222 24
        return $class->newInstance();
223
    }
224
225
    /**
226
     * Gets an alias for the given property
227
     *
228
     * @param ReflectionProperty $property
229
     *
230
     * @return Alias|null
231
     */
232 3
    private function getPropertyAlias(ReflectionProperty $property) : ?Alias
233
    {
234 3
        if (PHP_MAJOR_VERSION >= 8) {
235 3
            $attributes = $property->getAttributes(Alias::class);
236 3
            if (isset($attributes[0])) {
237 1
                return $attributes[0]->newInstance();
238
            }
239
        }
240
241 3
        if (isset($this->annotationReader)) {
242 2
            $annotation = $this->annotationReader->getPropertyAnnotation($property, Alias::class);
0 ignored issues
show
Bug introduced by
The method getPropertyAnnotation() 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

242
            /** @scrutinizer ignore-call */ 
243
            $annotation = $this->annotationReader->getPropertyAnnotation($property, Alias::class);

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...
243 2
            if (isset($annotation)) {
244 1
                return $annotation;
245
            }
246
        }
247
248 2
        return null;
249
    }
250
251
    /**
252
     * Hydrates the given property with the given value
253
     *
254
     * @param object $object
255
     * @param ReflectionClass $class
256
     * @param ReflectionProperty $property
257
     * @param ReflectionNamedType $type
258
     * @param mixed $value
259
     *
260
     * @return void
261
     *
262
     * @throws Exception\InvalidValueException
263
     *         If the given value isn't valid.
264
     *
265
     * @throws Exception\UnsupportedPropertyTypeException
266
     *         If the given property contains an unsupported type.
267
     */
268 21
    private function hydrateProperty(
269
        object $object,
270
        ReflectionClass $class,
271
        ReflectionProperty $property,
272
        ReflectionNamedType $type,
273
        $value
274
    ) : void {
275 21
        if (null === $value) {
276 2
            $this->hydratePropertyWithNull($object, $class, $property, $type);
277 1
            return;
278
        }
279
280 20
        if (isset(self::PROPERTY_HYDRATOR_MAP[$type->getName()])) {
281 16
            $this->{self::PROPERTY_HYDRATOR_MAP[$type->getName()]}($object, $class, $property, $type, $value);
282 9
            return;
283
        }
284
285 5
        if (is_subclass_of($type->getName(), ObjectCollectionInterface::class)) {
286 3
            $this->hydratePropertyWithManyAssociations($object, $class, $property, $type, $value);
287 1
            return;
288
        }
289
290 3
        if (class_exists($type->getName())) {
291 2
            $this->hydratePropertyWithOneAssociation($object, $class, $property, $type, $value);
292 1
            return;
293
        }
294
295 1
        throw new Exception\UnsupportedPropertyTypeException(sprintf(
296 1
            'The %s.%s property contains an unsupported type %s.',
297 1
            $class->getShortName(),
298 1
            $property->getName(),
299 1
            $type->getName()
300
        ));
301
    }
302
303
    /**
304
     * Hydrates the given property with null
305
     *
306
     * @param object $object
307
     * @param ReflectionClass $class
308
     * @param ReflectionProperty $property
309
     * @param ReflectionNamedType $type
310
     *
311
     * @return void
312
     *
313
     * @throws Exception\InvalidValueException
314
     *         If the given value isn't valid.
315
     */
316 2
    private function hydratePropertyWithNull(
317
        object $object,
318
        ReflectionClass $class,
319
        ReflectionProperty $property,
320
        ReflectionNamedType $type
321
    ) : void {
322 2
        if (!$type->allowsNull()) {
323 1
            throw new Exception\InvalidValueException($property, sprintf(
324 1
                'The %s.%s property cannot accept null.',
325 1
                $class->getShortName(),
326 1
                $property->getName()
327
            ));
328
        }
329
330 1
        $property->setValue($object, null);
331 1
    }
332
333
    /**
334
     * Hydrates the given property with the given boolean value
335
     *
336
     * @param object $object
337
     * @param ReflectionClass $class
338
     * @param ReflectionProperty $property
339
     * @param ReflectionNamedType $type
340
     * @param mixed $value
341
     *
342
     * @return void
343
     *
344
     * @throws Exception\InvalidValueException
345
     *         If the given value isn't valid.
346
     */
347 3
    private function hydratePropertyWithBooleanValue(
348
        object $object,
349
        ReflectionClass $class,
350
        ReflectionProperty $property,
351
        ReflectionNamedType $type,
352
        $value
353
    ) : void {
354 3
        if (!is_bool($value)) {
355
            // if the value isn't boolean, then we will use filter_var, because it will give us the ability to specify
356
            // boolean values as strings. this behavior is great for html forms. details at:
357
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L273
358 2
            $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
359
360 2
            if (!isset($value)) {
361 1
                throw new Exception\InvalidValueException($property, sprintf(
362 1
                    'The %s.%s property accepts a boolean value only.',
363 1
                    $class->getShortName(),
364 1
                    $property->getName()
365
                ));
366
            }
367
        }
368
369 2
        $property->setValue($object, $value);
370 2
    }
371
372
    /**
373
     * Hydrates the given property with the given integer number
374
     *
375
     * @param object $object
376
     * @param ReflectionClass $class
377
     * @param ReflectionProperty $property
378
     * @param ReflectionNamedType $type
379
     * @param mixed $value
380
     *
381
     * @return void
382
     *
383
     * @throws Exception\InvalidValueException
384
     *         If the given value isn't valid.
385
     */
386 3
    private function hydratePropertyWithIntegerNumber(
387
        object $object,
388
        ReflectionClass $class,
389
        ReflectionProperty $property,
390
        ReflectionNamedType $type,
391
        $value
392
    ) : void {
393 3
        if (!is_int($value)) {
394
            // it's senseless to convert the value type if it's not a number, so we will use filter_var to correct
395
            // converting the value type to int. also remember that string numbers must be between PHP_INT_MIN and
396
            // PHP_INT_MAX, otherwise the result will be null. this behavior is great for html forms. details at:
397
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197
398
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94
399 2
            $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
400
401 2
            if (!isset($value)) {
402 1
                throw new Exception\InvalidValueException($property, sprintf(
403 1
                    'The %s.%s property accepts an integer number only.',
404 1
                    $class->getShortName(),
405 1
                    $property->getName()
406
                ));
407
            }
408
        }
409
410 2
        $property->setValue($object, $value);
411 2
    }
412
413
    /**
414
     * Hydrates the given property with the given number
415
     *
416
     * @param object $object
417
     * @param ReflectionClass $class
418
     * @param ReflectionProperty $property
419
     * @param ReflectionNamedType $type
420
     * @param mixed $value
421
     *
422
     * @return void
423
     *
424
     * @throws Exception\InvalidValueException
425
     *         If the given value isn't valid.
426
     */
427 3
    private function hydratePropertyWithNumber(
428
        object $object,
429
        ReflectionClass $class,
430
        ReflectionProperty $property,
431
        ReflectionNamedType $type,
432
        $value
433
    ) : void {
434 3
        if (!is_float($value)) {
435
            // it's senseless to convert the value type if it's not a number, so we will use filter_var to correct
436
            // converting the value type to float. this behavior is great for html forms. details at:
437
            // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L342
438 2
            $value = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE);
439
440 2
            if (!isset($value)) {
441 1
                throw new Exception\InvalidValueException($property, sprintf(
442 1
                    'The %s.%s property accepts a number only.',
443 1
                    $class->getShortName(),
444 1
                    $property->getName()
445
                ));
446
            }
447
        }
448
449 2
        $property->setValue($object, $value);
450 2
    }
451
452
    /**
453
     * Hydrates the given property with the given string
454
     *
455
     * @param object $object
456
     * @param ReflectionClass $class
457
     * @param ReflectionProperty $property
458
     * @param ReflectionNamedType $type
459
     * @param mixed $value
460
     *
461
     * @return void
462
     *
463
     * @throws Exception\InvalidValueException
464
     *         If the given value isn't valid.
465
     */
466 4
    private function hydratePropertyWithString(
467
        object $object,
468
        ReflectionClass $class,
469
        ReflectionProperty $property,
470
        ReflectionNamedType $type,
471
        $value
472
    ) : void {
473 4
        if (!is_string($value)) {
474 1
            throw new Exception\InvalidValueException($property, sprintf(
475 1
                'The %s.%s property accepts a string only.',
476 1
                $class->getShortName(),
477 1
                $property->getName()
478
            ));
479
        }
480
481 3
        $property->setValue($object, $value);
482 3
    }
483
484
    /**
485
     * Hydrates the given property with the given array
486
     *
487
     * @param object $object
488
     * @param ReflectionClass $class
489
     * @param ReflectionProperty $property
490
     * @param ReflectionNamedType $type
491
     * @param mixed $value
492
     *
493
     * @return void
494
     *
495
     * @throws Exception\InvalidValueException
496
     *         If the given value isn't valid.
497
     */
498 2
    private function hydratePropertyWithArray(
499
        object $object,
500
        ReflectionClass $class,
501
        ReflectionProperty $property,
502
        ReflectionNamedType $type,
503
        $value
504
    ) : void {
505 2
        if (!is_array($value)) {
506 1
            throw new Exception\InvalidValueException($property, sprintf(
507 1
                'The %s.%s property accepts an array only.',
508 1
                $class->getShortName(),
509 1
                $property->getName()
510
            ));
511
        }
512
513 1
        $property->setValue($object, $value);
514 1
    }
515
516
    /**
517
     * Hydrates the given property with the given object
518
     *
519
     * @param object $object
520
     * @param ReflectionClass $class
521
     * @param ReflectionProperty $property
522
     * @param ReflectionNamedType $type
523
     * @param mixed $value
524
     *
525
     * @return void
526
     *
527
     * @throws Exception\InvalidValueException
528
     *         If the given value isn't valid.
529
     */
530 2
    private function hydratePropertyWithObject(
531
        object $object,
532
        ReflectionClass $class,
533
        ReflectionProperty $property,
534
        ReflectionNamedType $type,
535
        $value
536
    ) : void {
537 2
        if (!is_object($value)) {
538 1
            throw new Exception\InvalidValueException($property, sprintf(
539 1
                'The %s.%s property accepts an object only.',
540 1
                $class->getShortName(),
541 1
                $property->getName()
542
            ));
543
        }
544
545 1
        $property->setValue($object, $value);
546 1
    }
547
548
    /**
549
     * Hydrates the given property with the given timestamp
550
     *
551
     * @param object $object
552
     * @param ReflectionClass $class
553
     * @param ReflectionProperty $property
554
     * @param ReflectionNamedType $type
555
     * @param mixed $value
556
     *
557
     * @return void
558
     *
559
     * @throws Exception\InvalidValueException
560
     *         If the given value isn't valid.
561
     */
562 5
    private function hydratePropertyWithTimestamp(
563
        object $object,
564
        ReflectionClass $class,
565
        ReflectionProperty $property,
566
        ReflectionNamedType $type,
567
        $value
568
    ) : void {
569 5
        $prototype = $type->getName();
570
571 5
        if (is_int($value) || ctype_digit($value)) {
572 2
            $property->setValue($object, (new $prototype)->setTimestamp((int) $value));
573 2
            return;
574
        }
575
576 3
        if (is_string($value) && false !== strtotime($value)) {
577 2
            $property->setValue($object, new $prototype($value));
578 2
            return;
579
        }
580
581 1
        throw new Exception\InvalidValueException($property, sprintf(
582 1
            'The %s.%s property accepts a valid date-time string or a timestamp only.',
583 1
            $class->getShortName(),
584 1
            $property->getName()
585
        ));
586
    }
587
588
    /**
589
     * Hydrates the given property with the given many associations
590
     *
591
     * @param object $object
592
     * @param ReflectionClass $class
593
     * @param ReflectionProperty $property
594
     * @param ReflectionNamedType $type
595
     * @param mixed $value
596
     *
597
     * @return void
598
     *
599
     * @throws Exception\InvalidValueException
600
     *         If the given value isn't valid.
601
     */
602 3
    private function hydratePropertyWithManyAssociations(
603
        object $object,
604
        ReflectionClass $class,
605
        ReflectionProperty $property,
606
        ReflectionNamedType $type,
607
        $value
608
    ) : void {
609 3
        if (!is_array($value)) {
610 1
            throw new Exception\InvalidValueException($property, sprintf(
611 1
                'The %s.%s property accepts an array only.',
612 1
                $class->getShortName(),
613 1
                $property->getName()
614
            ));
615
        }
616
617 2
        $prototype = $type->getName();
618 2
        $collection = new $prototype();
619 2
        foreach ($value as $key => $item) {
620 2
            if (!is_array($item)) {
621 1
                throw new Exception\InvalidValueException($property, sprintf(
622 1
                    'The %s.%s property accepts an array with arrays only.',
623 1
                    $class->getShortName(),
624 1
                    $property->getName()
625
                ));
626
            }
627
628 1
            $collection->add($key, $this->hydrate($collection->getItemClassName(), $item));
629
        }
630
631 1
        $property->setValue($object, $collection);
632 1
    }
633
634
    /**
635
     * Hydrates the given property with the given one association
636
     *
637
     * @param object $object
638
     * @param ReflectionClass $class
639
     * @param ReflectionProperty $property
640
     * @param ReflectionNamedType $type
641
     * @param mixed $value
642
     *
643
     * @return void
644
     *
645
     * @throws Exception\InvalidValueException
646
     *         If the given value isn't valid.
647
     */
648 2
    private function hydratePropertyWithOneAssociation(
649
        object $object,
650
        ReflectionClass $class,
651
        ReflectionProperty $property,
652
        ReflectionNamedType $type,
653
        $value
654
    ) : void {
655 2
        if (!is_array($value)) {
656 1
            throw new Exception\InvalidValueException($property, sprintf(
657 1
                'The %s.%s property accepts an array only.',
658 1
                $class->getShortName(),
659 1
                $property->getName()
660
            ));
661
        }
662
663 1
        $property->setValue($object, $this->hydrate($type->getName(), $value));
664 1
    }
665
}
666