Passed
Push — main ( 9f35d8...57da41 )
by Anatoly
11:13 queued 05:32
created

Hydrator::instantObject()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5.0187

Importance

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