Passed
Pull Request — main (#13)
by Anatoly
11:45
created

Hydrator::hydratePropertyWithInterval()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 3

Importance

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

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