Passed
Push — main ( 066d74...a98deb )
by Anatoly
04:16 queued 15s
created

Hydrator::initObject()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5.583

Importance

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