Passed
Pull Request — main (#28)
by Anatoly
04:31
created

Hydrator::setAnnotationReader()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 14
ccs 8
cts 8
cp 1
rs 10
cc 3
nc 3
nop 1
crap 3
1
<?php
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Nekhay <[email protected]>
7
 * @copyright Copyright (c) 2021, Anatoly Nekhay
8
 * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/hydrator
10
 */
11
12
declare(strict_types=1);
13
14
namespace Sunrise\Hydrator;
15
16
use JsonException;
17
use LogicException;
18
use ReflectionClass;
19
use ReflectionNamedType;
20
use ReflectionProperty;
21
use SimdJsonException;
0 ignored issues
show
Bug introduced by
The type SimdJsonException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
use Sunrise\Hydrator\Annotation\Alias;
23
use Sunrise\Hydrator\Annotation\Ignore;
24
use Sunrise\Hydrator\Exception\InvalidDataException;
25
use Sunrise\Hydrator\Exception\InvalidValueException;
26
use Sunrise\Hydrator\TypeConverter\ArrayTypeConverter;
27
use Sunrise\Hydrator\TypeConverter\BackedEnumTypeConverter;
28
use Sunrise\Hydrator\TypeConverter\BoolTypeConverter;
29
use Sunrise\Hydrator\TypeConverter\FloatTypeConverter;
30
use Sunrise\Hydrator\TypeConverter\IntTypeConverter;
31
use Sunrise\Hydrator\TypeConverter\RelationshipTypeConverter;
32
use Sunrise\Hydrator\TypeConverter\StringTypeConverter;
33
use Sunrise\Hydrator\TypeConverter\TimestampTypeConverter;
34
use Sunrise\Hydrator\TypeConverter\TimezoneTypeConverter;
35
use Sunrise\Hydrator\TypeConverter\UidTypeConverter;
36
37
use function array_key_exists;
38
use function extension_loaded;
39
use function is_array;
40
use function is_object;
41
use function json_decode;
42
use function simdjson_decode;
0 ignored issues
show
introduced by
The function simdjson_decode was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
43
use function sprintf;
44
use function usort;
45
46
use const JSON_THROW_ON_ERROR;
47
use const PHP_MAJOR_VERSION;
48
use const PHP_VERSION_ID;
49
50
/**
51
 * Hydrator
52
 */
53
class Hydrator implements HydratorInterface
54
{
55
56
    /**
57
     * @var AnnotationReaderInterface
58
     */
59
    private AnnotationReaderInterface $annotationReader;
60
61
    /**
62
     * @var list<TypeConverterInterface>
0 ignored issues
show
Bug introduced by
The type Sunrise\Hydrator\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

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