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

Hydrator::getConstructorDefaultValues()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

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