Passed
Pull Request — main (#8)
by Anatoly
03:14
created

Hydrator::initializeObject()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 10.5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 13
c 1
b 0
f 0
dl 0
loc 23
ccs 7
cts 14
cp 0.5
rs 9.2222
cc 6
nc 4
nop 1
crap 10.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
     * Constructor of the class
82
     */
83 2
    public function __construct()
84
    {
85 2
        if (PHP_MAJOR_VERSION < 8) {
86
            // @codeCoverageIgnoreStart
87
            $this->useAnnotations();
88
            // @codeCoverageIgnoreEnd
89
        }
90 2
    }
91
92
    /**
93
     * Enables support for annotations
94
     *
95
     * @return self
96
     */
97
    public function useAnnotations() : self
98
    {
99
        if (isset($this->annotationReader)) {
100
            return $this;
101
        }
102
103
        if (class_exists(SimpleAnnotationReader::class)) {
104
            $this->annotationReader = /** @scrutinizer ignore-deprecated */ new SimpleAnnotationReader();
105
            $this->annotationReader->addNamespace(Annotation::class);
106
        }
107
108
        return $this;
109
    }
110
111
    /**
112
     * {@inheritdoc}
113
     *
114
     * @throws Exception\UntypedPropertyException
115
     *         If one of the object properties isn't typed.
116
     *
117
     * @throws Exception\UnsupportedPropertyTypeException
118
     *         If one of the object properties contains an unsupported type.
119
     *
120
     * @throws Exception\MissingRequiredValueException
121
     *         If the given data doesn't contain required value.
122
     *
123
     * @throws Exception\InvalidValueException
124
     *         If the given data contains an invalid value.
125
     */
126 1
    public function hydrate($object, array $data) : object
127
    {
128 1
        $object = $this->initializeObject($object);
129
130 1
        $class = new ReflectionClass($object);
131 1
        $properties = $class->getProperties();
132 1
        foreach ($properties as $property) {
133
            // statical properties cannot be hydrated...
134 1
            if ($property->isStatic()) {
135 1
                continue;
136
            }
137
138 1
            $property->setAccessible(true);
139
140 1
            if (!$property->hasType()) {
141
                throw new Exception\UntypedPropertyException(sprintf(
142
                    'The <%s.%s> property is not typed.',
143
                    $class->getShortName(),
144
                    $property->getName()
145
                ));
146
            }
147
148 1
            if ($property->getType() instanceof ReflectionUnionType) {
149
                throw new Exception\UnsupportedPropertyTypeException(sprintf(
150
                    'The <%s.%s> property contains an union type that is not supported.',
151
                    $class->getShortName(),
152
                    $property->getName()
153
                ));
154
            }
155
156 1
            $key = $property->getName();
157 1
            if (!array_key_exists($key, $data)) {
158 1
                $alias = $this->getPropertyAlias($property);
159 1
                if (isset($alias)) {
160 1
                    $key = $alias->value;
161
                }
162
            }
163
164 1
            if (!array_key_exists($key, $data)) {
165 1
                if (!$property->isInitialized($object)) {
166
                    throw new Exception\MissingRequiredValueException(sprintf(
167
                        'The <%s.%s> property is required.',
168
                        $class->getShortName(),
169
                        $property->getName()
170
                    ));
171
                }
172
173 1
                continue;
174
            }
175
176 1
            $this->hydrateProperty($object, $class, $property, $property->getType(), $data[$key]);
177
        }
178
179 1
        return $object;
180
    }
181
182
    /**
183
     * {@inheritdoc}
184
     *
185
     * @throws InvalidArgumentException
186
     *         If the given JSON cannot be decoded.
187
     */
188
    public function hydrateWithJson($object, string $json) : object
189
    {
190
        json_decode(''); // reset previous error...
191
        $data = (array) json_decode($json, true);
192
        if (JSON_ERROR_NONE <> json_last_error()) {
193
            throw new InvalidArgumentException(sprintf(
194
                'Unable to decode JSON: %s',
195
                json_last_error_msg()
196
            ));
197
        }
198
199
        return $this->hydrate($object, $data);
200
    }
201
202
    /**
203
     * Initializes the given object
204
     *
205
     * @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...
206
     *
207
     * @return object
208
     *
209
     * @throws InvalidArgumentException
210
     *         If the given object cannot be initialized.
211
     */
212 1
    private function initializeObject($object) : object
213
    {
214 1
        if (is_object($object)) {
215
            return $object;
216
        }
217
218 1
        if (!is_string($object) || !class_exists($object)) {
219
            throw new InvalidArgumentException(sprintf(
220
                'The method %s::hydrate() expects an object or name of an existing class.',
221
                __CLASS__
222
            ));
223
        }
224
225 1
        $class = new ReflectionClass($object);
226 1
        $constructor = $class->getConstructor();
227 1
        if (isset($constructor) && $constructor->getNumberOfRequiredParameters() > 0) {
228
            throw new InvalidArgumentException(sprintf(
229
                'The object %s cannot be hydrated because its constructor has required parameters.',
230
                $class->getName()
231
            ));
232
        }
233
234 1
        return $class->newInstance();
235
    }
236
237
    /**
238
     * Gets an alias for the given property
239
     *
240
     * @param ReflectionProperty $property
241
     *
242
     * @return Alias|null
243
     */
244 1
    private function getPropertyAlias(ReflectionProperty $property) : ?Alias
245
    {
246 1
        if (PHP_MAJOR_VERSION >= 8) {
247 1
            $attributes = $property->getAttributes(Alias::class);
248 1
            if (isset($attributes[0])) {
249 1
                return $attributes[0]->newInstance();
250
            }
251
        }
252
253 1
        if (isset($this->annotationReader)) {
254
            $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

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