Passed
Push — main ( 6c9276...76dcd0 )
by Anatoly
06:53 queued 02:07
created

Hydrator::hydrate()   B

Complexity

Conditions 11
Paths 34

Size

Total Lines 54
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 11

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 29
c 2
b 0
f 0
dl 0
loc 54
ccs 27
cts 27
cp 1
rs 7.3166
cc 11
nc 34
nop 4
crap 11

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Generator;
17
use JsonException;
18
use LogicException;
19
use ReflectionClass;
20
use ReflectionNamedType;
21
use ReflectionProperty;
22
use Sunrise\Hydrator\Annotation\Alias;
23
use Sunrise\Hydrator\Annotation\Context;
24
use Sunrise\Hydrator\Annotation\Ignore;
25
use Sunrise\Hydrator\AnnotationReader\BuiltinAnnotationReader;
26
use Sunrise\Hydrator\AnnotationReader\DoctrineAnnotationReader;
27
use Sunrise\Hydrator\AnnotationReader\NullAnnotationReader;
28
use Sunrise\Hydrator\Dictionary\BuiltinType;
29
use Sunrise\Hydrator\Exception\InvalidDataException;
30
use Sunrise\Hydrator\Exception\InvalidObjectException;
31
use Sunrise\Hydrator\Exception\InvalidValueException;
32
use Sunrise\Hydrator\TypeConverter\ArrayAccessTypeConverter;
33
use Sunrise\Hydrator\TypeConverter\ArrayTypeConverter;
34
use Sunrise\Hydrator\TypeConverter\BackedEnumTypeConverter;
35
use Sunrise\Hydrator\TypeConverter\BooleanTypeConverter;
36
use Sunrise\Hydrator\TypeConverter\IntegerTypeConverter;
37
use Sunrise\Hydrator\TypeConverter\MixedTypeConverter;
38
use Sunrise\Hydrator\TypeConverter\MyclabsEnumTypeConverter;
39
use Sunrise\Hydrator\TypeConverter\NumberTypeConverter;
40
use Sunrise\Hydrator\TypeConverter\ObjectTypeConverter;
41
use Sunrise\Hydrator\TypeConverter\RamseyUuidTypeConverter;
42
use Sunrise\Hydrator\TypeConverter\StringTypeConverter;
43
use Sunrise\Hydrator\TypeConverter\SymfonyUidTypeConverter;
44
use Sunrise\Hydrator\TypeConverter\TimestampTypeConverter;
45
use Sunrise\Hydrator\TypeConverter\TimezoneTypeConverter;
46
use TypeError;
47
48
use function array_key_exists;
49
use function class_exists;
50
use function extension_loaded;
51
use function gettype;
52
use function is_array;
53
use function is_object;
54
use function is_string;
55
use function json_decode;
56
use function sprintf;
57
use function usort;
58
59
use const JSON_BIGINT_AS_STRING;
60
use const JSON_THROW_ON_ERROR;
61
use const PHP_MAJOR_VERSION;
62
use const PHP_VERSION_ID;
63
64
/**
65
 * Hydrator
66
 */
67
class Hydrator implements HydratorInterface
68
{
69
70
    /**
71
     * @var array<string, mixed>
72
     */
73
    private array $context;
74
75
    /**
76
     * @var AnnotationReaderInterface
77
     */
78
    private AnnotationReaderInterface $annotationReader;
79
80
    /**
81
     * @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...
82
     */
83
    private array $typeConverters = [];
84
85
    /**
86
     * Constructor of the class
87
     *
88
     * @param array<string, mixed> $context
89
     */
90 545
    public function __construct(array $context = [])
91
    {
92 545
        $this->context = $context;
93
94 545
        $this->annotationReader = PHP_MAJOR_VERSION >= 8 ?
95 545
            new BuiltinAnnotationReader() :
96
            new NullAnnotationReader();
97
98 545
        $this->addTypeConverter(...self::defaultTypeConverters());
99
    }
100
101
    /**
102
     * Sets the given annotation reader to the hydrator
103
     *
104
     * @param AnnotationReaderInterface|\Doctrine\Common\Annotations\Reader $annotationReader
105
     *
106
     * @return self
107
     *
108
     * @since 3.0.0
109
     */
110
    public function setAnnotationReader($annotationReader): self
111
    {
112
        // BC with previous versions...
113
        if ($annotationReader instanceof \Doctrine\Common\Annotations\Reader) {
114
            $annotationReader = new DoctrineAnnotationReader($annotationReader);
115
        }
116
117
        $this->annotationReader = $annotationReader;
118
        foreach ($this->typeConverters as $typeConverter) {
119
            if ($typeConverter instanceof AnnotationReaderAwareInterface) {
120
                $typeConverter->setAnnotationReader($annotationReader);
121
            }
122
        }
123
124
        return $this;
125
    }
126
127
    /**
128
     * Sets the doctrine's default annotation reader to the hydrator
129
     *
130
     * @return self
131
     *
132
     * @since 3.0.0
133
     *
134
     * @deprecated 3.2.0 Use the {@see setAnnotationReader()} method
135
     *                   with the {@see DoctrineAnnotationReader::default()} attribute.
136
     */
137
    public function useDefaultAnnotationReader(): self
138
    {
139
        return $this->setAnnotationReader(DoctrineAnnotationReader::default());
140
    }
141
142
    /**
143
     * Adds the given type converter(s) to the hydrator
144
     *
145
     * @param TypeConverterInterface ...$typeConverters
146
     *
147
     * @return self
148
     *
149
     * @since 3.1.0
150
     */
151 545
    public function addTypeConverter(TypeConverterInterface ...$typeConverters): self
152
    {
153 545
        foreach ($typeConverters as $typeConverter) {
154 545
            if ($typeConverter instanceof AnnotationReaderAwareInterface) {
155 545
                $typeConverter->setAnnotationReader($this->annotationReader);
156
            }
157 545
            if ($typeConverter instanceof HydratorAwareInterface) {
158 545
                $typeConverter->setHydrator($this);
159
            }
160
161 545
            $this->typeConverters[] = $typeConverter;
162
        }
163
164
        // phpcs:ignore Generic.Files.LineLength
165 545
        usort($this->typeConverters, static fn(TypeConverterInterface $a, TypeConverterInterface $b): int => $b->getWeight() <=> $a->getWeight());
166
167 545
        return $this;
168
    }
169
170
    /**
171
     * @inheritDoc
172
     */
173 510
    public function castValue($value, Type $type, array $path = [], array $context = [])
174
    {
175 510
        if ($value === null) {
176 32
            if ($type->allowsNull()) {
177 15
                return null;
178
            }
179
180 17
            throw InvalidValueException::mustNotBeEmpty($path);
181
        }
182
183
        // phpcs:ignore Generic.Files.LineLength
184 482
        $context = ($this->annotationReader->getAnnotations(Context::class, $type->getHolder())->current()->value ?? []) + $context + $this->context;
185
186 482
        foreach ($this->typeConverters as $typeConverter) {
187 482
            $result = $typeConverter->castValue($value, $type, $path, $context);
188 482
            if ($result->valid()) {
189 366
                return $result->current();
190
            }
191
        }
192
193 5
        throw InvalidObjectException::unsupportedType($type);
194
    }
195
196
    /**
197
     * @inheritDoc
198
     */
199 543
    public function hydrate($object, array $data, array $path = [], array $context = []): object
200
    {
201 543
        [$object, $class] = $this->instantObject($object);
202 542
        $properties = $class->getProperties();
203 542
        $constructorDefaultValues = $this->getClassConstructorDefaultValues($class);
204
205 542
        $violations = [];
206 542
        foreach ($properties as $property) {
207
            // @codeCoverageIgnoreStart
208
            if (PHP_VERSION_ID < 80100) {
209
                /** @psalm-suppress UnusedMethodCall */
210
                $property->setAccessible(true);
211
            } // @codeCoverageIgnoreEnd
212
213 542
            if ($property->isStatic()) {
214 1
                continue;
215
            }
216
217 541
            if ($this->annotationReader->getAnnotations(Ignore::class, $property)->valid()) {
218 1
                continue;
219
            }
220
221
            // phpcs:ignore Generic.Files.LineLength
222 540
            $key = $this->annotationReader->getAnnotations(Alias::class, $property)->current()->value ?? $property->getName();
223
224 540
            if (array_key_exists($key, $data) === false) {
225 36
                if ($property->isInitialized($object)) {
226 15
                    continue;
227
                }
228
229 21
                if (array_key_exists($property->getName(), $constructorDefaultValues)) {
230 1
                    $property->setValue($object, $constructorDefaultValues[$property->getName()]);
231 1
                    continue;
232
                }
233
234 20
                $violations[] = InvalidValueException::mustBeProvided([...$path, $key]);
235 20
                continue;
236
            }
237
238
            try {
239
                // phpcs:ignore Generic.Files.LineLength
240 510
                $property->setValue($object, $this->castValue($data[$key], $this->getPropertyType($property), [...$path, $key], $context));
241 130
            } catch (InvalidValueException $e) {
242 119
                $violations[] = $e;
243 22
            } catch (InvalidDataException $e) {
244 16
                $violations = [...$violations, ...$e->getExceptions()];
245
            }
246
        }
247
248 536
        if (!empty($violations)) {
249 141
            throw new InvalidDataException('Invalid data', $violations);
250
        }
251
252 396
        return $object;
253
    }
254
255
    /**
256
     * @inheritDoc
257
     */
258
    // phpcs:ignore Generic.Files.LineLength
259 3
    public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512, array $path = [], array $context = []): object
260
    {
261
        // @codeCoverageIgnoreStart
262
        if (!extension_loaded('json')) {
263
            throw new LogicException('The JSON extension is required.');
264
        } // @codeCoverageIgnoreEnd
265
266
        try {
267 3
            $data = json_decode($json, true, $depth, $flags | JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
268 1
        } catch (JsonException $e) {
269
            // phpcs:ignore Generic.Files.LineLength
270 1
            throw new InvalidDataException(sprintf('The JSON is invalid and couldn‘t be decoded due to: %s', $e->getMessage()));
271
        }
272
273 2
        if (!is_array($data)) {
274 1
            throw new InvalidDataException('The JSON must be in the form of an array or an object.');
275
        }
276
277 1
        return $this->hydrate($object, $data, $path, $context);
278
    }
279
280
    /**
281
     * Instantiates the given object
282
     *
283
     * @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...
284
     *
285
     * @return array{0: T, 1: ReflectionClass}
286
     *
287
     * @throws InvalidObjectException If the object couldn't be instantiated.
288
     *
289
     * @template T of object
290
     */
291 543
    private function instantObject($object): array
292
    {
293 543
        if (is_object($object)) {
294 385
            return [$object, new ReflectionClass($object)];
295
        }
296
297
        /** @psalm-suppress DocblockTypeContradiction */
298 239
        if (!is_string($object)) {
299
            // phpcs:ignore Generic.Files.LineLength
300
            throw new TypeError(sprintf('Argument #1 ($object) must be of type object or string, %s given', gettype($object)));
301
        }
302
303 239
        if (!class_exists($object)) {
304
            throw InvalidObjectException::uninstantiableObject($object);
305
        }
306
307 239
        $class = new ReflectionClass($object);
308 239
        if (!$class->isInstantiable()) {
309 1
            throw InvalidObjectException::uninstantiableObject($class->getName());
310
        }
311
312 238
        return [$class->newInstanceWithoutConstructor(), $class];
313
    }
314
315
    /**
316
     * Gets the given property's type
317
     *
318
     * @param ReflectionProperty $property
319
     *
320
     * @return Type
321
     */
322 510
    private function getPropertyType(ReflectionProperty $property): Type
323
    {
324 510
        $type = $property->getType();
325 510
        if ($type === null) {
326 1
            return new Type($property, BuiltinType::MIXED, true);
327
        }
328
329 509
        if ($type instanceof ReflectionNamedType) {
330 508
            return new Type($property, $type->getName(), $type->allowsNull());
331
        }
332
333 1
        return new Type($property, (string) $type, $type->allowsNull());
334
    }
335
336
    /**
337
     * Gets default values from the given class's constructor
338
     *
339
     * @param ReflectionClass<T> $class
340
     *
341
     * @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...
342
     *
343
     * @template T of object
344
     */
345 542
    private function getClassConstructorDefaultValues(ReflectionClass $class): array
346
    {
347 542
        $constructor = $class->getConstructor();
348 542
        if ($constructor === null) {
349 541
            return [];
350
        }
351
352 1
        $result = [];
353 1
        foreach ($constructor->getParameters() as $parameter) {
354 1
            if ($parameter->isDefaultValueAvailable()) {
355
                /** @psalm-suppress MixedAssignment */
356 1
                $result[$parameter->getName()] = $parameter->getDefaultValue();
357
            }
358
        }
359
360 1
        return $result;
361
    }
362
363
    /**
364
     * Gets the default type converters for this environment
365
     *
366
     * @return Generator<int, TypeConverterInterface>
367
     */
368 545
    private static function defaultTypeConverters(): Generator
369
    {
370 545
        yield new MixedTypeConverter();
371 545
        yield new BooleanTypeConverter();
372 545
        yield new IntegerTypeConverter();
373 545
        yield new NumberTypeConverter();
374 545
        yield new StringTypeConverter();
375 545
        yield new TimestampTypeConverter();
376 545
        yield new TimezoneTypeConverter();
377 545
        yield new ArrayTypeConverter();
378 545
        yield new ArrayAccessTypeConverter();
379 545
        yield new ObjectTypeConverter();
380
381 545
        if (PHP_MAJOR_VERSION >= 8) {
382 545
            yield new BackedEnumTypeConverter();
383
        }
384 545
        if (class_exists(\MyCLabs\Enum\Enum::class)) {
385 545
            yield new MyclabsEnumTypeConverter();
386
        }
387 545
        if (class_exists(\Ramsey\Uuid\UuidInterface::class)) {
388
            yield new RamseyUuidTypeConverter();
389
        }
390 545
        if (class_exists(\Symfony\Component\Uid\AbstractUid::class)) {
391 545
            yield new SymfonyUidTypeConverter();
392
        }
393
    }
394
}
395