├── composer-require-checker.json
├── src
├── Exception
│ ├── NonInstantiableException.php
│ ├── NonExistClassException.php
│ ├── AbstractClassException.php
│ ├── WrongConstructorArgumentsCountException.php
│ └── NonPublicConstructorException.php
├── DataInterface.php
├── Attribute
│ ├── SkipHydration.php
│ ├── Data
│ │ ├── DataAttributeInterface.php
│ │ └── DataAttributeResolverInterface.php
│ └── Parameter
│ │ ├── ParameterAttributeInterface.php
│ │ ├── Collection.php
│ │ ├── RightTrim.php
│ │ ├── Trim.php
│ │ ├── LeftTrim.php
│ │ ├── Di.php
│ │ ├── ParameterAttributeResolverInterface.php
│ │ ├── Data.php
│ │ ├── ToArrayOfStrings.php
│ │ ├── ToDateTime.php
│ │ ├── TrimResolver.php
│ │ ├── LeftTrimResolver.php
│ │ ├── RightTrimResolver.php
│ │ ├── ToString.php
│ │ ├── DiNotFoundException.php
│ │ ├── ToArrayOfStringsResolver.php
│ │ ├── DiResolver.php
│ │ ├── CollectionResolver.php
│ │ └── ToDateTimeResolver.php
├── AttributeHandling
│ ├── Exception
│ │ ├── AttributeResolverNonInstantiableException.php
│ │ └── UnexpectedAttributeException.php
│ ├── ResolverFactory
│ │ ├── AttributeResolverFactoryInterface.php
│ │ ├── ContainerAttributeResolverFactory.php
│ │ └── ReflectionAttributeResolverFactory.php
│ ├── DataAttributesHandler.php
│ ├── ParameterAttributeResolveContext.php
│ └── ParameterAttributesHandler.php
├── TypeCaster
│ ├── NoTypeCaster.php
│ ├── TypeCasterInterface.php
│ ├── TypeCastContext.php
│ ├── CompositeTypeCaster.php
│ ├── NullTypeCaster.php
│ ├── HydratorTypeCaster.php
│ ├── EnumTypeCaster.php
│ └── PhpNativeTypeCaster.php
├── ObjectFactory
│ ├── ObjectFactoryInterface.php
│ ├── ContainerObjectFactory.php
│ └── ReflectionObjectFactory.php
├── HydratorInterface.php
├── ObjectMap.php
├── Result.php
├── Internal
│ ├── ReflectionFilter.php
│ └── ConstructorArgumentsExtractor.php
├── ArrayData.php
└── Hydrator.php
├── config
└── di.php
├── rector.php
├── LICENSE.md
├── CHANGELOG.md
├── composer.json
└── README.md
/composer-require-checker.json:
--------------------------------------------------------------------------------
1 | {
2 | "symbol-whitelist": [
3 | "Yiisoft\\Router\\CurrentRoute",
4 | "IntlDateFormatter"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/src/Exception/NonInstantiableException.php:
--------------------------------------------------------------------------------
1 | Hydrator::class,
12 | AttributeResolverFactoryInterface::class => ContainerAttributeResolverFactory::class,
13 | ];
14 |
--------------------------------------------------------------------------------
/src/Exception/NonExistClassException.php:
--------------------------------------------------------------------------------
1 | getName(),
20 | ),
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Attribute/Data/DataAttributeInterface.php:
--------------------------------------------------------------------------------
1 | $reflectionClass
20 | * @psalm-return T
21 | */
22 | public function create(ReflectionClass $reflectionClass, array $constructorArguments): object;
23 | }
24 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ParameterAttributeInterface.php:
--------------------------------------------------------------------------------
1 | getDeclaringClass()->getName(),
20 | $constructor->getNumberOfRequiredParameters(),
21 | $countArguments,
22 | )
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/RightTrim.php:
--------------------------------------------------------------------------------
1 | reflection;
26 | }
27 |
28 | public function getReflectionType(): ?ReflectionType
29 | {
30 | return $this->reflection->getType();
31 | }
32 |
33 | public function getHydrator(): HydratorInterface
34 | {
35 | return $this->hydrator;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/Di.php:
--------------------------------------------------------------------------------
1 | id;
31 | }
32 |
33 | public function getResolver(): string
34 | {
35 | return DiResolver::class;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ParameterAttributeResolverInterface.php:
--------------------------------------------------------------------------------
1 | $reflectionClass
28 | * @psalm-return T
29 | */
30 | public function create(ReflectionClass $reflectionClass, array $constructorArguments): object
31 | {
32 | $class = $reflectionClass->getName();
33 | return $this->injector->make($class, $constructorArguments);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | paths([
14 | __DIR__ . '/src',
15 | __DIR__ . '/tests',
16 | ]);
17 |
18 | // register a single rule
19 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class);
20 |
21 | // define sets of rules
22 | $rectorConfig->sets([
23 | LevelSetList::UP_TO_PHP_81,
24 | ]);
25 |
26 | $rectorConfig->skip([
27 | ClosureToArrowFunctionRector::class,
28 | NewInInitializerRector::class,
29 | ReadOnlyPropertyRector::class,
30 | __DIR__ . '/tests/Support/Classes/SimpleClass.php',
31 | ]);
32 | };
33 |
--------------------------------------------------------------------------------
/src/TypeCaster/CompositeTypeCaster.php:
--------------------------------------------------------------------------------
1 | typeCasters = $typeCasters;
26 | }
27 |
28 | public function cast(mixed $value, TypeCastContext $context): Result
29 | {
30 | foreach ($this->typeCasters as $typeCaster) {
31 | $result = $typeCaster->cast($value, $context);
32 | if ($result->isResolved()) {
33 | return $result;
34 | }
35 | }
36 |
37 | return Result::fail();
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/HydratorInterface.php:
--------------------------------------------------------------------------------
1 | $class
35 | * @psalm-return T
36 | */
37 | public function create(string $class, array|DataInterface $data = []): object;
38 | }
39 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/Data.php:
--------------------------------------------------------------------------------
1 | getData()->getValue($this->name);
32 | }
33 |
34 | public function getResolver(): self
35 | {
36 | return $this;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Exception/NonPublicConstructorException.php:
--------------------------------------------------------------------------------
1 | getDeclaringClass()->getName(),
21 | $this->getConstructorType($constructor),
22 | ),
23 | );
24 | }
25 |
26 | private function getConstructorType(ReflectionMethod $constructor): string
27 | {
28 | if ($constructor->isPrivate()) {
29 | return 'private';
30 | }
31 |
32 | if ($constructor->isProtected()) {
33 | return 'protected';
34 | }
35 |
36 | throw new LogicException(
37 | 'Exception "NonPublicConstructorException" can be used only for non-public constructors.'
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ToArrayOfStrings.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
27 | return Result::fail();
28 | }
29 |
30 | $resolvedValue = $context->getResolvedValue();
31 | if (!is_string($resolvedValue)) {
32 | return Result::fail();
33 | }
34 |
35 | $characters = $attribute->characters ?? $this->characters;
36 |
37 | return Result::success(
38 | $characters === null ? trim($resolvedValue) : trim($resolvedValue, $characters)
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/LeftTrimResolver.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
27 | return Result::fail();
28 | }
29 |
30 | $resolvedValue = $context->getResolvedValue();
31 | if (!is_string($resolvedValue)) {
32 | return Result::fail();
33 | }
34 |
35 | $characters = $attribute->characters ?? $this->characters;
36 |
37 | return Result::success(
38 | $characters === null ? ltrim($resolvedValue) : ltrim($resolvedValue, $characters)
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/RightTrimResolver.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
27 | return Result::fail();
28 | }
29 |
30 | $resolvedValue = $context->getResolvedValue();
31 | if (!is_string($resolvedValue)) {
32 | return Result::fail();
33 | }
34 |
35 | $characters = $attribute->characters ?? $this->characters;
36 |
37 | return Result::success(
38 | $characters === null ? rtrim($resolvedValue) : rtrim($resolvedValue, $characters)
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ToString.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
31 | $resolvedValue = $context->getResolvedValue();
32 | if (is_scalar($resolvedValue) || null === $resolvedValue || $resolvedValue instanceof Stringable) {
33 | return Result::success((string) $resolvedValue);
34 | }
35 |
36 | return Result::success('');
37 | }
38 |
39 | return Result::fail();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright © 2008 by Yii Software ()
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in
12 | the documentation and/or other materials provided with the
13 | distribution.
14 | * Neither the name of Yii Software nor the names of its
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 | POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/src/ObjectMap.php:
--------------------------------------------------------------------------------
1 | |ObjectMap|null
30 | */
31 | public function getPath(string $name): string|array|self|null
32 | {
33 | return $this->map[$name] ?? null;
34 | }
35 |
36 | /**
37 | * Returns a list of property names for which mapping is set.
38 | *
39 | * @return string[] List of property names.
40 | * @psalm-return list
41 | */
42 | public function getNames(): array
43 | {
44 | return array_keys($this->map);
45 | }
46 |
47 | /**
48 | * Checks if a given property name exists in the mapping array.
49 | *
50 | * @param string $name The property name.
51 | * @return bool Whether the property name exists in the mapping array.
52 | */
53 | public function exists(string $name): bool
54 | {
55 | return array_key_exists($name, $this->map);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/AttributeHandling/ResolverFactory/ContainerAttributeResolverFactory.php:
--------------------------------------------------------------------------------
1 | getResolver();
34 | if (!is_string($resolver)) {
35 | return $resolver;
36 | }
37 |
38 | if (!$this->container->has($resolver)) {
39 | throw new AttributeResolverNonInstantiableException(
40 | sprintf(
41 | 'Class "%s" does not exist.',
42 | $resolver,
43 | ),
44 | );
45 | }
46 |
47 | return $this->container->get($resolver);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Result.php:
--------------------------------------------------------------------------------
1 | isResolved;
50 | }
51 |
52 | /**
53 | * Returns the resolved value.
54 | *
55 | * When the value is not resolved, this method returns `null`.
56 | * Since `null` can be a valid value as well, please use {@see isResolved()} to check
57 | * if the value is resolved or not.
58 | *
59 | * @return mixed The resolved value.
60 | */
61 | public function getValue(): mixed
62 | {
63 | return $this->value;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/DiNotFoundException.php:
--------------------------------------------------------------------------------
1 | getDeclaringClass() always returns not null in this case.
26 | */
27 | $className = $reflection->getDeclaringClass()->getName();
28 |
29 | if ($reflection instanceof ReflectionParameter) {
30 | $message = 'Constructor parameter "' . $reflection->getName() . '" of class "' . $className . '"';
31 | } else {
32 | $message = 'Class property "' . $className . '::$' . $reflection->getName() . '"';
33 | }
34 |
35 | $type = $reflection->getType();
36 | $message .= $type === null
37 | ? ' without type'
38 | : (' with type "' . $type . '"');
39 |
40 | $message .= ' not resolved.';
41 |
42 | parent::__construct($message, previous: $previous);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/ObjectFactory/ReflectionObjectFactory.php:
--------------------------------------------------------------------------------
1 | $reflectionClass
26 | * @psalm-return T
27 | */
28 | public function create(ReflectionClass $reflectionClass, array $constructorArguments): object
29 | {
30 | if ($reflectionClass->isAbstract()) {
31 | throw new AbstractClassException($reflectionClass);
32 | }
33 |
34 | $constructor = $reflectionClass->getConstructor();
35 | if ($constructor !== null) {
36 | if (!$constructor->isPublic()) {
37 | throw new NonPublicConstructorException($constructor);
38 | }
39 |
40 | $countArguments = count($constructorArguments);
41 | if ($constructor->getNumberOfRequiredParameters() > $countArguments) {
42 | throw new WrongConstructorArgumentsCountException($constructor, $countArguments);
43 | }
44 | }
45 |
46 | return $reflectionClass->newInstance(...$constructorArguments);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/TypeCaster/NullTypeCaster.php:
--------------------------------------------------------------------------------
1 | isAllowNull($context->getReflectionType())) {
27 | return Result::fail();
28 | }
29 |
30 | if (
31 | ($this->null && $value === null)
32 | || ($this->emptyString && $value === '')
33 | || ($this->emptyArray && $value === [])
34 | ) {
35 | return Result::success(null);
36 | }
37 |
38 | return Result::fail();
39 | }
40 |
41 | private function isAllowNull(?ReflectionType $type): bool
42 | {
43 | if ($type === null) {
44 | return true;
45 | }
46 |
47 | if ($type instanceof ReflectionNamedType) {
48 | return $type->allowsNull();
49 | }
50 |
51 | if ($type instanceof ReflectionUnionType) {
52 | /** @psalm-suppress RedundantConditionGivenDocblockType Needed for PHP less than 8.2 */
53 | foreach ($type->getTypes() as $subtype) {
54 | if ($subtype instanceof ReflectionNamedType && $type->allowsNull()) {
55 | return true;
56 | }
57 | }
58 | }
59 |
60 | return false;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Yii Hydrator Change Log
2 |
3 | ## 1.6.4 under development
4 |
5 | - no changes in this release.
6 |
7 | ## 1.6.3 December 16, 2025
8 |
9 | - Enh #113: Add PHP 8.5 support (@vjik).
10 |
11 | ## 1.6.2 September 25, 2025
12 |
13 | - Bug #111: Set property hook is no longer called during object hydration (@vjik)
14 |
15 | ## 1.6.1 August 08, 2025
16 |
17 | - Bug #110: Support `public private(set)` properties in parent class (@vjik)
18 |
19 | ## 1.6.0 March 14, 2025
20 |
21 | - New #63: Add nested mapping support via new `ObjectMap` class (@vjik)
22 | - Chg #103: Change PHP constraint in `composer.json` to `8.1 - 8.4` (@vjik)
23 | - Enh #99: Improve psalm annotation in `HydratorInterface::create()` method (@vjik)
24 |
25 | ## 1.5.0 September 17, 2024
26 |
27 | - New #96: Add `EnumTypeCaster` (@vjik)
28 | - Bug #95: Fix populating readonly properties from parent classes (@vjik)
29 |
30 | ## 1.4.0 August 23, 2024
31 |
32 | - New #94: Add `ToArrayOfStrings` parameter attribute (@vjik)
33 | - Enh #93: Add backed enumeration support to `Collection` (@vjik)
34 |
35 | ## 1.3.0 August 07, 2024
36 |
37 | - New #49: Add `Collection` PHP attribute (@arogachev)
38 | - New #49: Add hydrator dependency and `withHydrator()` method to `ParameterAttributesHandler` (@arogachev)
39 | - New #49: Add hydrator dependency and `getHydrator()` method to `ParameterAttributeResolveContext` (@arogachev)
40 | - Enh #85: Allow to hydrate non-initialized readonly properties (@vjik)
41 |
42 | ## 1.2.0 April 03, 2024
43 |
44 | - New #77: Add `ToDateTime` parameter attribute (@vjik)
45 | - New #79: Add `Trim`, `LeftTrim` and `RightTrim` parameter attributes (@vjik)
46 | - Enh #76: Raise the minimum version of PHP to 8.1 (@vjik)
47 |
48 | ## 1.1.0 February 09, 2024
49 |
50 | - New #74: Add `NullTypeCaster` (@vjik)
51 |
52 | ## 1.0.0 January 29, 2024
53 |
54 | - Initial release.
55 |
--------------------------------------------------------------------------------
/src/Internal/ReflectionFilter.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | public static function filterProperties(
23 | object $object,
24 | ReflectionClass $reflectionClass,
25 | array $propertyNamesToFilter = []
26 | ): array {
27 | $result = [];
28 |
29 | foreach ($reflectionClass->getProperties() as $property) {
30 | if ($property->isStatic()) {
31 | continue;
32 | }
33 |
34 | if ($property->isReadOnly() && $property->isInitialized($object)) {
35 | continue;
36 | }
37 | $propertyName = $property->getName();
38 | if (in_array($propertyName, $propertyNamesToFilter, true)) {
39 | continue;
40 | }
41 |
42 | if (!empty($property->getAttributes(SkipHydration::class))) {
43 | continue;
44 | }
45 |
46 | $result[$propertyName] = $property;
47 | }
48 | return $result;
49 | }
50 |
51 | /**
52 | * @param ReflectionParameter[] $parameters
53 | * @return array
54 | */
55 | public static function filterParameters(array $parameters): array
56 | {
57 | $result = [];
58 |
59 | foreach ($parameters as $parameter) {
60 | if (!empty($parameter->getAttributes(SkipHydration::class))) {
61 | continue;
62 | }
63 |
64 | $result[$parameter->getName()] = $parameter;
65 | }
66 | return $result;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/TypeCaster/HydratorTypeCaster.php:
--------------------------------------------------------------------------------
1 | getReflectionType();
23 | $hydrator = $context->getHydrator();
24 |
25 | if (!is_array($value)) {
26 | return Result::fail();
27 | }
28 |
29 | if ($type instanceof ReflectionNamedType) {
30 | return $this->castInternal($value, $type, $hydrator);
31 | }
32 |
33 | if (!$type instanceof ReflectionUnionType) {
34 | return Result::fail();
35 | }
36 |
37 | foreach ($type->getTypes() as $t) {
38 | if (!$t instanceof ReflectionNamedType) {
39 | continue;
40 | }
41 |
42 | $result = $this->castInternal($value, $t, $hydrator);
43 | if ($result->isResolved()) {
44 | return $result;
45 | }
46 | }
47 |
48 | return Result::fail();
49 | }
50 |
51 | private function castInternal(array $value, ReflectionNamedType $type, HydratorInterface $hydrator): Result
52 | {
53 | if ($type->isBuiltin()) {
54 | return Result::fail();
55 | }
56 |
57 | $class = $type->getName();
58 |
59 | try {
60 | $object = $hydrator->create($class, $value);
61 | } catch (NonInstantiableException) {
62 | return Result::fail();
63 | }
64 |
65 | return Result::success($object);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/AttributeHandling/DataAttributesHandler.php:
--------------------------------------------------------------------------------
1 | [] $reflectionAttributes
34 | */
35 | public function handle(ReflectionClass $reflectionClass, DataInterface $data): DataInterface
36 | {
37 | $reflectionAttributes = $reflectionClass->getAttributes(
38 | DataAttributeInterface::class,
39 | ReflectionAttribute::IS_INSTANCEOF
40 | );
41 |
42 | foreach ($reflectionAttributes as $reflectionAttribute) {
43 | $attribute = $reflectionAttribute->newInstance();
44 |
45 | $resolver = $this->attributeResolverFactory->create($attribute);
46 | if (!$resolver instanceof DataAttributeResolverInterface) {
47 | throw new RuntimeException(
48 | sprintf(
49 | 'Data attribute resolver "%s" must implement "%s".',
50 | get_debug_type($resolver),
51 | DataAttributeResolverInterface::class,
52 | ),
53 | );
54 | }
55 |
56 | $data = $resolver->prepareData($attribute, $data);
57 | }
58 |
59 | return $data;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ToArrayOfStringsResolver.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
24 | return Result::fail();
25 | }
26 |
27 | $resolvedValue = $context->getResolvedValue();
28 | if (is_iterable($resolvedValue)) {
29 | $array = array_map(
30 | $this->castValueToString(...),
31 | $resolvedValue instanceof Traversable ? iterator_to_array($resolvedValue) : $resolvedValue
32 | );
33 | } else {
34 | $value = $this->castValueToString($resolvedValue);
35 | /**
36 | * @var string[] $array We assume valid regular expression is used here, so `preg_split()` always returns
37 | * an array of strings.
38 | */
39 | $array = $attribute->splitResolvedValue
40 | ? preg_split('~' . $attribute->separator . '~u', $value)
41 | : [$value];
42 | }
43 |
44 | if ($attribute->trim) {
45 | $array = array_map(trim(...), $array);
46 | }
47 |
48 | if ($attribute->removeEmpty) {
49 | $array = array_filter(
50 | $array,
51 | static fn(string $value): bool => $value !== '',
52 | );
53 | }
54 |
55 | return Result::success($array);
56 | }
57 |
58 | private function castValueToString(mixed $value): string
59 | {
60 | return is_scalar($value) || $value instanceof Stringable ? (string) $value : '';
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Internal/ConstructorArgumentsExtractor.php:
--------------------------------------------------------------------------------
1 | ,1:array}
29 | */
30 | public function extract(?ReflectionMethod $constructor, DataInterface $data): array
31 | {
32 | $excludeParameterNames = [];
33 | $constructorArguments = [];
34 |
35 | if ($constructor === null) {
36 | return [$excludeParameterNames, $constructorArguments];
37 | }
38 |
39 | $reflectionParameters = ReflectionFilter::filterParameters($constructor->getParameters());
40 |
41 | foreach ($reflectionParameters as $parameterName => $parameter) {
42 | $resolveResult = Result::fail();
43 |
44 | if ($parameter->isPromoted()) {
45 | $excludeParameterNames[] = $parameterName;
46 | $resolveResult = $data->getValue($parameterName);
47 | }
48 |
49 | $attributesHandleResult = $this->parameterAttributesHandler->handle(
50 | $parameter,
51 | $resolveResult,
52 | $data,
53 | );
54 | if ($attributesHandleResult->isResolved()) {
55 | $resolveResult = $attributesHandleResult;
56 | }
57 |
58 | if ($resolveResult->isResolved()) {
59 | $typeCastedValue = $this->typeCaster->cast(
60 | $resolveResult->getValue(),
61 | new TypeCastContext($this->hydrator, $parameter),
62 | );
63 | if ($typeCastedValue->isResolved()) {
64 | $constructorArguments[$parameterName] = $typeCastedValue->getValue();
65 | }
66 | }
67 | }
68 |
69 | return [$excludeParameterNames, $constructorArguments];
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yiisoft/hydrator",
3 | "type": "library",
4 | "description": "Create and populate objects with type casting, mapping and dependencies resolving support.",
5 | "keywords": [
6 | "hydrator"
7 | ],
8 | "homepage": "https://www.yiiframework.com/",
9 | "license": "BSD-3-Clause",
10 | "support": {
11 | "issues": "https://github.com/yiisoft/hydrator/issues?state=open",
12 | "source": "https://github.com/yiisoft/hydrator",
13 | "forum": "https://www.yiiframework.com/forum/",
14 | "wiki": "https://www.yiiframework.com/wiki/",
15 | "irc": "ircs://irc.libera.chat:6697/yii",
16 | "chat": "https://t.me/yii3en"
17 | },
18 | "funding": [
19 | {
20 | "type": "opencollective",
21 | "url": "https://opencollective.com/yiisoft"
22 | },
23 | {
24 | "type": "github",
25 | "url": "https://github.com/sponsors/yiisoft"
26 | }
27 | ],
28 | "require": {
29 | "php": "8.1 - 8.5",
30 | "psr/container": "^2.0",
31 | "yiisoft/injector": "^1.1",
32 | "yiisoft/strings": "^2.3"
33 | },
34 | "require-dev": {
35 | "maglnet/composer-require-checker": "^4.7.1",
36 | "phpunit/phpunit": "^10.5.48",
37 | "rector/rector": "^2.1.2",
38 | "roave/infection-static-analysis-plugin": "^1.35",
39 | "spatie/phpunit-watcher": "^1.24",
40 | "vimeo/psalm": "^5.26.1 || ^6.13.1",
41 | "yiisoft/di": "^1.4",
42 | "yiisoft/dummy-provider": "^1.1.0",
43 | "yiisoft/test-support": "^3.0.2"
44 | },
45 | "suggest": {
46 | "ext-intl": "Allows using `ToDateTime` parameter attribute"
47 | },
48 | "autoload": {
49 | "psr-4": {
50 | "Yiisoft\\Hydrator\\": "src"
51 | }
52 | },
53 | "autoload-dev": {
54 | "psr-4": {
55 | "Yiisoft\\Hydrator\\Tests\\": "tests"
56 | }
57 | },
58 | "extra": {
59 | "config-plugin-options": {
60 | "source-directory": "config"
61 | },
62 | "config-plugin": {
63 | "di": "di.php"
64 | }
65 | },
66 | "config": {
67 | "sort-packages": true,
68 | "allow-plugins": {
69 | "infection/extension-installer": true,
70 | "composer/package-versions-deprecated": true
71 | }
72 | },
73 | "scripts": {
74 | "test": "phpunit --testdox --no-interaction",
75 | "test-watch": "phpunit-watcher watch"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/AttributeHandling/ParameterAttributeResolveContext.php:
--------------------------------------------------------------------------------
1 | parameter;
41 | }
42 |
43 | /**
44 | * Get whether the value for object property is resolved already.
45 | *
46 | * @return bool Whether the value for object property is resolved.
47 | */
48 | public function isResolved(): bool
49 | {
50 | return $this->resolveResult->isResolved();
51 | }
52 |
53 | /**
54 | * Get the resolved value.
55 | *
56 | * When value is not resolved returns `null`. But `null` can be is resolved value, use {@see isResolved()} for check
57 | * the value is resolved or not.
58 | *
59 | * @return mixed The resolved value.
60 | */
61 | public function getResolvedValue(): mixed
62 | {
63 | return $this->resolveResult->getValue();
64 | }
65 |
66 | /**
67 | * @return DataInterface Data to be used for resolving.
68 | */
69 | public function getData(): DataInterface
70 | {
71 | return $this->data;
72 | }
73 |
74 | public function getHydrator(): HydratorInterface
75 | {
76 | if ($this->hydrator === null) {
77 | throw new LogicException('Hydrator is not set in parameter attribute resolve context.');
78 | }
79 |
80 | return $this->hydrator;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/AttributeHandling/ResolverFactory/ReflectionAttributeResolverFactory.php:
--------------------------------------------------------------------------------
1 | getResolver();
22 | if (!is_string($resolver)) {
23 | return $resolver;
24 | }
25 |
26 | if (!class_exists($resolver)) {
27 | throw new AttributeResolverNonInstantiableException(
28 | sprintf(
29 | 'Class "%s" does not exist.',
30 | $resolver,
31 | ),
32 | );
33 | }
34 |
35 | $reflectionClass = new ReflectionClass($resolver);
36 | if ($reflectionClass->isAbstract()) {
37 | throw new AttributeResolverNonInstantiableException(
38 | sprintf(
39 | '"%s" is not instantiable because it is abstract.',
40 | $reflectionClass->getName(),
41 | ),
42 | );
43 | }
44 |
45 | $constructor = $reflectionClass->getConstructor();
46 | if ($constructor !== null) {
47 | if (!$constructor->isPublic()) {
48 | throw new AttributeResolverNonInstantiableException(
49 | sprintf(
50 | 'Class "%s" is not instantiable because of non-public constructor.',
51 | $constructor->getDeclaringClass()->getName(),
52 | ),
53 | );
54 | }
55 |
56 | if ($constructor->getNumberOfRequiredParameters() > 0) {
57 | throw new AttributeResolverNonInstantiableException(
58 | sprintf(
59 | 'Class "%s" cannot be instantiated because it has %d required parameters in constructor.',
60 | $constructor->getDeclaringClass()->getName(),
61 | $constructor->getNumberOfRequiredParameters(),
62 | )
63 | );
64 | }
65 | }
66 |
67 | return $reflectionClass->newInstance();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/DiResolver.php:
--------------------------------------------------------------------------------
1 | getParameter();
40 |
41 | $id = $attribute->getId();
42 | if ($id !== null) {
43 | try {
44 | return Result::success(
45 | $this->container->get($id)
46 | );
47 | } catch (NotFoundExceptionInterface $e) {
48 | throw new DiNotFoundException($parameter, $e);
49 | }
50 | }
51 |
52 | $type = $parameter->getType();
53 | if ($type instanceof ReflectionNamedType) {
54 | if (!$type->isBuiltin()) {
55 | try {
56 | return Result::success(
57 | $this->container->get($type->getName())
58 | );
59 | } catch (NotFoundExceptionInterface $e) {
60 | throw new DiNotFoundException($parameter, $e);
61 | }
62 | }
63 | } elseif ($type instanceof ReflectionUnionType) {
64 | foreach ($type->getTypes() as $type) {
65 | /** @psalm-suppress RedundantConditionGivenDocblockType Needed for PHP less than 8.2 */
66 | if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
67 | try {
68 | return Result::success(
69 | $this->container->get($type->getName())
70 | );
71 | } catch (NotFoundExceptionInterface) {
72 | }
73 | }
74 | }
75 | }
76 |
77 | throw new DiNotFoundException($parameter);
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/AttributeHandling/ParameterAttributesHandler.php:
--------------------------------------------------------------------------------
1 | hydrator === null) {
47 | throw new LogicException('Hydrator is not set in parameter attributes handler.');
48 | }
49 |
50 | $resolveResult ??= Result::fail();
51 | $data ??= new ArrayData();
52 |
53 | $reflectionAttributes = $parameter
54 | ->getAttributes(ParameterAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF);
55 |
56 | foreach ($reflectionAttributes as $reflectionAttribute) {
57 | $attribute = $reflectionAttribute->newInstance();
58 |
59 | $resolver = $this->attributeResolverFactory->create($attribute);
60 | if (!$resolver instanceof ParameterAttributeResolverInterface) {
61 | throw new RuntimeException(
62 | sprintf(
63 | 'Parameter attribute resolver "%s" must implement "%s".',
64 | get_debug_type($resolver),
65 | ParameterAttributeResolverInterface::class,
66 | ),
67 | );
68 | }
69 |
70 | $context = new ParameterAttributeResolveContext($parameter, $resolveResult, $data, $this->hydrator);
71 |
72 | $tryResolveResult = $resolver->getParameterValue($attribute, $context);
73 | if ($tryResolveResult->isResolved()) {
74 | $resolveResult = $tryResolveResult;
75 | }
76 | }
77 |
78 | return $resolveResult;
79 | }
80 |
81 | public function withHydrator(HydratorInterface $hydrator): self
82 | {
83 | $new = clone $this;
84 | $new->hydrator = $hydrator;
85 | return $new;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/TypeCaster/EnumTypeCaster.php:
--------------------------------------------------------------------------------
1 | getReflectionType();
26 |
27 | if ($type instanceof ReflectionNamedType) {
28 | return $this->castInternal($value, $type);
29 | }
30 |
31 | if (!$type instanceof ReflectionUnionType) {
32 | return Result::fail();
33 | }
34 |
35 | foreach ($type->getTypes() as $t) {
36 | if (!$t instanceof ReflectionNamedType) {
37 | continue;
38 | }
39 |
40 | $result = $this->castInternal($value, $t);
41 | if ($result->isResolved()) {
42 | return $result;
43 | }
44 | }
45 |
46 | return Result::fail();
47 | }
48 |
49 | private function castInternal(mixed $value, ReflectionNamedType $type): Result
50 | {
51 | $enumClass = $type->getName();
52 | if (!$this->isEnum($enumClass)) {
53 | return Result::fail();
54 | }
55 |
56 | if ($value instanceof $enumClass) {
57 | return Result::success($value);
58 | }
59 |
60 | if (!$this->isBackedEnum($enumClass)) {
61 | return Result::fail();
62 | }
63 |
64 | $enumValue = $this->isStringEnum($enumClass)
65 | ? $this->tryCastToString($value)
66 | : $this->tryCastToInt($value);
67 | if ($enumValue === null) {
68 | return Result::fail();
69 | }
70 |
71 | $enum = $enumClass::tryFrom($enumValue);
72 | if ($enum === null) {
73 | return Result::fail();
74 | }
75 |
76 | return Result::success($enum);
77 | }
78 |
79 | /**
80 | * @psalm-assert-if-true class-string $class
81 | */
82 | private function isEnum(string $class): bool
83 | {
84 | return is_a($class, UnitEnum::class, true);
85 | }
86 |
87 | /**
88 | * @psalm-param class-string $class
89 | * @psalm-assert-if-true class-string $class
90 | */
91 | private function isBackedEnum(string $class): bool
92 | {
93 | return is_a($class, BackedEnum::class, true);
94 | }
95 |
96 | /**
97 | * @psalm-param class-string $class
98 | */
99 | private function isStringEnum(string $class): bool
100 | {
101 | $reflection = new ReflectionEnum($class);
102 |
103 | /**
104 | * @var ReflectionNamedType $type
105 | */
106 | $type = $reflection->getBackingType();
107 |
108 | return $type->getName() === 'string';
109 | }
110 |
111 | private function tryCastToString(mixed $value): ?string
112 | {
113 | if (is_scalar($value) || $value === null || $value instanceof Stringable) {
114 | return (string) $value;
115 | }
116 | return null;
117 | }
118 |
119 | private function tryCastToInt(mixed $value): ?int
120 | {
121 | if (is_scalar($value) || $value === null) {
122 | return (int) $value;
123 | }
124 | if ($value instanceof Stringable) {
125 | return (int) (string) $value;
126 | }
127 | return null;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/CollectionResolver.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
28 | return Result::fail();
29 | }
30 |
31 | $resolvedValue = $context->getResolvedValue();
32 | if (!is_iterable($resolvedValue)) {
33 | return Result::fail();
34 | }
35 |
36 | if (is_a($attribute->className, BackedEnum::class, true)) {
37 | /**
38 | * @psalm-suppress ArgumentTypeCoercion Because class name is backed enumeration name.
39 | */
40 | $collection = $this->createCollectionOfBackedEnums($resolvedValue, $attribute->className);
41 | } else {
42 | $collection = $this->createCollectionOfObjects(
43 | $resolvedValue,
44 | $context->getHydrator(),
45 | $attribute->className
46 | );
47 | }
48 |
49 | return Result::success($collection);
50 | }
51 |
52 | /**
53 | * @psalm-param class-string $className
54 | * @return object[]
55 | */
56 | private function createCollectionOfObjects(
57 | iterable $resolvedValue,
58 | HydratorInterface $hydrator,
59 | string $className
60 | ): array {
61 | $collection = [];
62 | foreach ($resolvedValue as $item) {
63 | if (!is_array($item) && !$item instanceof DataInterface) {
64 | continue;
65 | }
66 |
67 | try {
68 | $collection[] = $hydrator->create($className, $item);
69 | } catch (NonInstantiableException) {
70 | continue;
71 | }
72 | }
73 | return $collection;
74 | }
75 |
76 | /**
77 | * @psalm-param class-string $className
78 | * @return BackedEnum[]
79 | */
80 | private function createCollectionOfBackedEnums(iterable $resolvedValue, string $className): array
81 | {
82 | $collection = [];
83 | $isStringBackedEnum = $this->isStringBackedEnum($className);
84 | foreach ($resolvedValue as $item) {
85 | if ($item instanceof $className) {
86 | $collection[] = $item;
87 | continue;
88 | }
89 |
90 | if (is_string($item) || is_int($item)) {
91 | $enum = $className::tryFrom($isStringBackedEnum ? (string) $item : (int) $item);
92 | if ($enum !== null) {
93 | $collection[] = $enum;
94 | }
95 | }
96 | }
97 | return $collection;
98 | }
99 |
100 | /**
101 | * @psalm-param class-string $className
102 | */
103 | private function isStringBackedEnum(string $className): bool
104 | {
105 | /** @var ReflectionNamedType $backingType */
106 | $backingType = (new ReflectionEnum($className))->getBackingType();
107 | return $backingType->getName() === 'string';
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/ArrayData.php:
--------------------------------------------------------------------------------
1 | |ObjectMap>
17 | */
18 | final class ArrayData implements DataInterface
19 | {
20 | private readonly ObjectMap $objectMap;
21 |
22 | /**
23 | * @param array $data Data to hydrate object from.
24 | * @param array|ObjectMap $map Object property names mapped to keys in the data array that hydrator will use when
25 | * hydrating an object.
26 | * @param bool $strict Whether to hydrate properties from the map only.
27 | *
28 | * @psalm-param ObjectMap|MapType $map
29 | */
30 | public function __construct(
31 | private readonly array $data = [],
32 | array|ObjectMap $map = [],
33 | private readonly bool $strict = false,
34 | ) {
35 | $this->objectMap = is_array($map) ? new ObjectMap($map) : $map;
36 | }
37 |
38 | public function getValue(string $name): Result
39 | {
40 | if ($this->strict && !$this->objectMap->exists($name)) {
41 | return Result::fail();
42 | }
43 |
44 | $path = $this->objectMap->getPath($name) ?? $name;
45 | if ($path instanceof ObjectMap) {
46 | return $this->getValueByObjectMap($this->data, $path);
47 | }
48 |
49 | return $this->getValueByPath($this->data, $path);
50 | }
51 |
52 | /**
53 | * Get an array given a map as resolved result.
54 | */
55 | private function getValueByObjectMap(array $data, ObjectMap $objectMap): Result
56 | {
57 | $arrayData = new self($data, $objectMap);
58 |
59 | $result = [];
60 | foreach ($objectMap->getNames() as $name) {
61 | $value = $arrayData->getValue($name);
62 | if ($value->isResolved()) {
63 | $result[$name] = $value->getValue();
64 | }
65 | }
66 |
67 | return Result::success($result);
68 | }
69 |
70 | /**
71 | * Get value from an array given a path.
72 | *
73 | * @param string|string[] $path Path to the value.
74 | *
75 | * @see StringHelper::parsePath()
76 | */
77 | private function getValueByPath(array $data, string|array $path): Result
78 | {
79 | if (is_string($path)) {
80 | $path = StringHelper::parsePath($path);
81 | }
82 |
83 | $result = Result::success($data);
84 | foreach ($path as $pathKey) {
85 | $currentValue = $result->getValue();
86 | if (!is_array($currentValue)) {
87 | return Result::fail();
88 | }
89 | $result = $this->getValueByKey($currentValue, $pathKey);
90 | if (!$result->isResolved()) {
91 | return $result;
92 | }
93 | }
94 |
95 | return $result;
96 | }
97 |
98 | /**
99 | * Get value from an array given a key.
100 | *
101 | * @param array $data Array to get value from.
102 | * @param string $pathKey Key to get value for.
103 | *
104 | * @return Result The result object.
105 | */
106 | private function getValueByKey(array $data, string $pathKey): Result
107 | {
108 | $found = false;
109 | $result = null;
110 | foreach ($data as $dataKey => $dataValue) {
111 | $dataKey = (string) $dataKey;
112 |
113 | if ($dataKey === $pathKey) {
114 | $found = true;
115 | $result = (is_array($dataValue) && is_array($result))
116 | ? array_merge($result, $dataValue)
117 | : $dataValue;
118 | continue;
119 | }
120 |
121 | $pathKeyWithDot = $pathKey . '.';
122 | if (str_starts_with($dataKey, $pathKeyWithDot)) {
123 | $found = true;
124 | $value = [
125 | substr($dataKey, strlen($pathKeyWithDot)) => $dataValue,
126 | ];
127 | $result = is_array($result)
128 | ? array_merge($result, $value)
129 | : $value;
130 | }
131 | }
132 |
133 | return $found ? Result::success($result) : Result::fail();
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/TypeCaster/PhpNativeTypeCaster.php:
--------------------------------------------------------------------------------
1 | getReflectionType();
29 |
30 | if ($type === null) {
31 | return Result::success($value);
32 | }
33 |
34 | if (!$type instanceof ReflectionNamedType && !$type instanceof ReflectionUnionType) {
35 | return Result::fail();
36 | }
37 |
38 | $types = $type instanceof ReflectionNamedType
39 | ? [$type]
40 | : array_filter(
41 | $type->getTypes(),
42 | static fn(mixed $type) => $type instanceof ReflectionNamedType,
43 | );
44 |
45 | /**
46 | * Find the best type name and value type match.
47 | * Example:
48 | * - when pass `42` to `int|string` type, `int` will be used;
49 | * - when pass `"42"` to `int|string` type, `string` will be used.
50 | */
51 | foreach ($types as $t) {
52 | if ($value === null && $t->allowsNull()) {
53 | return Result::success(null);
54 | }
55 | if (!$t->isBuiltin()) {
56 | continue;
57 | }
58 | switch ($t->getName()) {
59 | case 'string':
60 | if (is_string($value)) {
61 | return Result::success($value);
62 | }
63 | break;
64 |
65 | case 'int':
66 | if (is_int($value)) {
67 | return Result::success($value);
68 | }
69 | break;
70 |
71 | case 'float':
72 | if (is_float($value)) {
73 | return Result::success($value);
74 | }
75 | break;
76 |
77 | case 'bool':
78 | if (is_bool($value)) {
79 | return Result::success($value);
80 | }
81 | break;
82 |
83 | case 'array':
84 | if (is_array($value)) {
85 | return Result::success($value);
86 | }
87 | break;
88 | }
89 | }
90 |
91 | foreach ($types as $t) {
92 | if (!$t->isBuiltin()) {
93 | $class = $t->getName();
94 | if ($value instanceof $class) {
95 | return Result::success($value);
96 | }
97 | continue;
98 | }
99 | switch ($t->getName()) {
100 | case 'string':
101 | if (is_scalar($value) || $value === null || $value instanceof Stringable) {
102 | return Result::success((string) $value);
103 | }
104 | break;
105 |
106 | case 'int':
107 | if (is_bool($value) || is_float($value) || $value === null) {
108 | return Result::success((int) $value);
109 | }
110 | if ($value instanceof Stringable || is_string($value)) {
111 | return Result::success((int) NumericHelper::normalize($value));
112 | }
113 | break;
114 |
115 | case 'float':
116 | if (is_int($value) || is_bool($value) || $value === null) {
117 | return Result::success((float) $value);
118 | }
119 | if ($value instanceof Stringable || is_string($value)) {
120 | return Result::success((float) NumericHelper::normalize($value));
121 | }
122 | break;
123 |
124 | case 'bool':
125 | if (is_scalar($value) || $value === null || is_array($value) || is_object($value)) {
126 | return Result::success((bool) $value);
127 | }
128 | break;
129 | }
130 | }
131 |
132 | return Result::fail();
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Hydrator
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/hydrator)
10 | [](https://packagist.org/packages/yiisoft/hydrator)
11 | [](https://github.com/yiisoft/hydrator/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/hydrator)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/hydrator/master)
14 | [](https://github.com/yiisoft/hydrator/actions?query=workflow%3A%22static+analysis%22)
15 | [](https://shepherd.dev/github/yiisoft/hydrator)
16 | [](https://shepherd.dev/github/yiisoft/hydrator)
17 |
18 | The package provides a way to create and hydrate objects from a set of raw data.
19 |
20 | Features are:
21 |
22 | - supports properties of any visibility;
23 | - uses constructor arguments to create objects;
24 | - resolves dependencies when creating objects using [PSR-11](https://www.php-fig.org/psr/psr-11/) compatible DI container
25 | provided;
26 | - supports nested objects;
27 | - supports mapping;
28 | - allows fine-tuning hydration via PHP attributes.
29 |
30 | ## Requirements
31 |
32 | - PHP 8.1 - 8.5.
33 |
34 | ## Installation
35 |
36 | The package could be installed with [Composer](https://getcomposer.org):
37 |
38 | ```shell
39 | composer require yiisoft/hydrator
40 | ```
41 |
42 | ## General usage
43 |
44 | To hydrate existing object:
45 |
46 | ```php
47 | use Yiisoft\Hydrator\Hydrator;
48 |
49 | $hydrator = new Hydrator();
50 | $hydrator->hydrate($object, $data);
51 | ```
52 |
53 | To create a new object and fill it with the data:
54 |
55 | ```php
56 | use Yiisoft\Hydrator\Hydrator;
57 |
58 | $hydrator = new Hydrator();
59 | $object = $hydrator->create(MyClass::class, $data);
60 | ```
61 |
62 | To pass arguments to the constructor of a nested object, use nested array or dot-notation:
63 |
64 | ```php
65 | final class Engine
66 | {
67 | public function __construct(
68 | private string $name,
69 | ) {}
70 | }
71 |
72 | final class Car
73 | {
74 | public function __construct(
75 | private string $name,
76 | private Engine $engine,
77 | ) {}
78 | }
79 |
80 | // nested array
81 | $object = $hydrator->create(Car::class, [
82 | 'name' => 'Ferrari',
83 | 'engine' => [
84 | 'name' => 'V8',
85 | ]
86 | ]);
87 |
88 | // or dot-notation
89 | $object = $hydrator->create(Car::class, [
90 | 'name' => 'Ferrari',
91 | 'engine.name' => 'V8',
92 | ]);
93 | ```
94 |
95 | That would pass the `name` constructor argument of the `Car` object and create a new `Engine` object for `engine`
96 | argument passing `V8` as the `name` argument to its constructor.
97 |
98 | ## Documentation
99 |
100 | - Guide: [English](docs/guide/en/README.md), [Português - Brasil](docs/guide/pt-BR/README.md), [Русский](docs/guide/ru/README.md)
101 | - [Internals](docs/internals.md)
102 |
103 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.
104 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
105 |
106 | ## License
107 |
108 | The Yii Hydrator is free software. It is released under the terms of the BSD License.
109 | Please see [`LICENSE`](./LICENSE.md) for more information.
110 |
111 | Maintained by [Yii Software](https://www.yiiframework.com/).
112 |
113 | ## Support the project
114 |
115 | [](https://opencollective.com/yiisoft)
116 |
117 | ## Follow updates
118 |
119 | [](https://www.yiiframework.com/)
120 | [](https://twitter.com/yiiframework)
121 | [](https://t.me/yii3en)
122 | [](https://www.facebook.com/groups/yiitalk)
123 | [](https://yiiframework.com/go/slack)
124 |
--------------------------------------------------------------------------------
/src/Hydrator.php:
--------------------------------------------------------------------------------
1 | typeCaster = $typeCaster ?? new CompositeTypeCaster(
53 | new PhpNativeTypeCaster(),
54 | new HydratorTypeCaster(),
55 | );
56 |
57 | $attributeResolverFactory ??= new ReflectionAttributeResolverFactory();
58 | $this->dataAttributesHandler = new DataAttributesHandler($attributeResolverFactory);
59 | $this->parameterAttributesHandler = new ParameterAttributesHandler($attributeResolverFactory, $this);
60 |
61 | $this->objectFactory = $objectFactory ?? new ReflectionObjectFactory();
62 |
63 | $this->constructorArgumentsExtractor = new ConstructorArgumentsExtractor(
64 | $this,
65 | $this->parameterAttributesHandler,
66 | $this->typeCaster,
67 | );
68 | }
69 |
70 | public function hydrate(object $object, array|DataInterface $data = []): void
71 | {
72 | if (is_array($data)) {
73 | $data = new ArrayData($data);
74 | }
75 |
76 | $reflectionClass = new ReflectionClass($object);
77 |
78 | $data = $this->dataAttributesHandler->handle($reflectionClass, $data);
79 |
80 | $this->hydrateInternal(
81 | $object,
82 | $reflectionClass,
83 | ReflectionFilter::filterProperties($object, $reflectionClass),
84 | $data
85 | );
86 | }
87 |
88 | public function create(string $class, array|DataInterface $data = []): object
89 | {
90 | if (!class_exists($class)) {
91 | throw new NonExistClassException($class);
92 | }
93 |
94 | if (is_array($data)) {
95 | $data = new ArrayData($data);
96 | }
97 |
98 | $reflectionClass = new ReflectionClass($class);
99 | $constructor = $reflectionClass->getConstructor();
100 |
101 | $data = $this->dataAttributesHandler->handle($reflectionClass, $data);
102 |
103 | [$excludeProperties, $constructorArguments] = $this->constructorArgumentsExtractor->extract(
104 | $constructor,
105 | $data,
106 | );
107 |
108 | $object = $this->objectFactory->create($reflectionClass, $constructorArguments);
109 |
110 | $this->hydrateInternal(
111 | $object,
112 | $reflectionClass,
113 | ReflectionFilter::filterProperties($object, $reflectionClass, $excludeProperties),
114 | $data
115 | );
116 |
117 | return $object;
118 | }
119 |
120 | /**
121 | * @param array $reflectionProperties
122 | */
123 | private function hydrateInternal(
124 | object $object,
125 | ReflectionClass $reflectionClass,
126 | array $reflectionProperties,
127 | DataInterface $data,
128 | ): void {
129 | foreach ($reflectionProperties as $propertyName => $property) {
130 | $resolveResult = $data->getValue($propertyName);
131 |
132 | $attributesHandleResult = $this->parameterAttributesHandler->handle(
133 | $property,
134 | $resolveResult,
135 | $data,
136 | );
137 | if ($attributesHandleResult->isResolved()) {
138 | $resolveResult = $attributesHandleResult;
139 | }
140 |
141 | if ($resolveResult->isResolved()) {
142 | $result = $this->typeCaster->cast(
143 | $resolveResult->getValue(),
144 | new TypeCastContext($this, $property),
145 | );
146 | if ($result->isResolved()) {
147 | $this->setPropertyValue(
148 | $object,
149 | $this->preparePropertyToSetValue($reflectionClass, $property),
150 | $result->getValue(),
151 | );
152 | }
153 | }
154 | }
155 | }
156 |
157 | /**
158 | * @psalm-suppress UndefinedMethod
159 | */
160 | private function setPropertyValue(object $object, ReflectionProperty $property, mixed $value): void
161 | {
162 | PHP_VERSION_ID >= 80400
163 | ? $property->setRawValue($object, $value)
164 | : $property->setValue($object, $value);
165 | }
166 |
167 | /**
168 | * @psalm-suppress UndefinedMethod
169 | */
170 | private function preparePropertyToSetValue(
171 | ReflectionClass $class,
172 | ReflectionProperty $property,
173 | ): ReflectionProperty {
174 | if (
175 | (PHP_VERSION_ID < 80400 && $property->isReadOnly())
176 | || (PHP_VERSION_ID >= 80400 && $property->isPrivateSet())
177 | ) {
178 | $declaringClass = $property->getDeclaringClass();
179 | return $declaringClass->getName() === $class->getName()
180 | ? $property
181 | : $declaringClass->getProperty($property->getName());
182 | }
183 |
184 | return $property;
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/src/Attribute/Parameter/ToDateTimeResolver.php:
--------------------------------------------------------------------------------
1 | isResolved()) {
46 | return Result::fail();
47 | }
48 |
49 | $resolvedValue = $context->getResolvedValue();
50 | $shouldBeMutable = $this->shouldResultBeMutable($context);
51 |
52 | if ($resolvedValue instanceof DateTimeInterface) {
53 | return $this->createSuccessResult($resolvedValue, $shouldBeMutable);
54 | }
55 |
56 | $timeZone = $attribute->timeZone ?? $this->timeZone;
57 | if ($timeZone !== null) {
58 | $timeZone = new DateTimeZone($timeZone);
59 | }
60 |
61 | if (is_int($resolvedValue)) {
62 | return Result::success(
63 | $this->makeDateTimeFromTimestamp($resolvedValue, $timeZone, $shouldBeMutable)
64 | );
65 | }
66 |
67 | if (is_string($resolvedValue) && !empty($resolvedValue)) {
68 | $format = $attribute->format ?? $this->format;
69 | if (is_string($format) && str_starts_with($format, 'php:')) {
70 | return $this->parseWithPhpFormat($resolvedValue, substr($format, 4), $timeZone, $shouldBeMutable);
71 | }
72 | return $this->parseWithIntlFormat(
73 | $resolvedValue,
74 | $format,
75 | $attribute->dateType ?? $this->dateType,
76 | $attribute->timeType ?? $this->timeType,
77 | $timeZone,
78 | $attribute->locale ?? $this->locale,
79 | $shouldBeMutable,
80 | );
81 | }
82 |
83 | return Result::fail();
84 | }
85 |
86 | /**
87 | * @psalm-param non-empty-string $resolvedValue
88 | */
89 | private function parseWithPhpFormat(
90 | string $resolvedValue,
91 | string $format,
92 | ?DateTimeZone $timeZone,
93 | bool $shouldBeMutable,
94 | ): Result {
95 | $date = $shouldBeMutable
96 | ? DateTime::createFromFormat($format, $resolvedValue, $timeZone)
97 | : DateTimeImmutable::createFromFormat($format, $resolvedValue, $timeZone);
98 | if ($date === false) {
99 | return Result::fail();
100 | }
101 |
102 | $errors = DateTimeImmutable::getLastErrors();
103 | if (!empty($errors['warning_count'])) {
104 | return Result::fail();
105 | }
106 |
107 | // If no time was provided in the format string set time to 0
108 | if (!strpbrk($format, 'aAghGHisvuU')) {
109 | $date = $date->setTime(0, 0);
110 | }
111 |
112 | return Result::success($date);
113 | }
114 |
115 | /**
116 | * @psalm-param non-empty-string $resolvedValue
117 | * @psalm-param IntlDateFormatterFormat $dateType
118 | * @psalm-param IntlDateFormatterFormat $timeType
119 | */
120 | private function parseWithIntlFormat(
121 | string $resolvedValue,
122 | ?string $format,
123 | int $dateType,
124 | int $timeType,
125 | ?DateTimeZone $timeZone,
126 | ?string $locale,
127 | bool $shouldBeMutable,
128 | ): Result {
129 | $formatter = $format === null
130 | ? new IntlDateFormatter($locale, $dateType, $timeType, $timeZone)
131 | : new IntlDateFormatter(
132 | $locale,
133 | IntlDateFormatter::NONE,
134 | IntlDateFormatter::NONE,
135 | $timeZone,
136 | pattern: $format
137 | );
138 | $formatter->setLenient(false);
139 | $timestamp = $formatter->parse($resolvedValue);
140 | return is_int($timestamp)
141 | ? Result::success($this->makeDateTimeFromTimestamp($timestamp, $timeZone, $shouldBeMutable))
142 | : Result::fail();
143 | }
144 |
145 | private function makeDateTimeFromTimestamp(
146 | int $timestamp,
147 | ?DateTimeZone $timeZone,
148 | bool $shouldBeMutable
149 | ): DateTimeInterface {
150 | /**
151 | * @psalm-suppress InvalidNamedArgument Psalm bug: https://github.com/vimeo/psalm/issues/10872
152 | */
153 | return $shouldBeMutable
154 | ? (new DateTime(timezone: $timeZone))->setTimestamp($timestamp)
155 | : (new DateTimeImmutable(timezone: $timeZone))->setTimestamp($timestamp);
156 | }
157 |
158 | private function createSuccessResult(DateTimeInterface $date, bool $shouldBeMutable): Result
159 | {
160 | if ($shouldBeMutable) {
161 | return Result::success(
162 | $date instanceof DateTime ? $date : DateTime::createFromInterface($date)
163 | );
164 | }
165 | return Result::success(
166 | $date instanceof DateTimeImmutable ? $date : DateTimeImmutable::createFromInterface($date)
167 | );
168 | }
169 |
170 | private function shouldResultBeMutable(ParameterAttributeResolveContext $context): bool
171 | {
172 | $type = $context->getParameter()->getType();
173 |
174 | if ($type instanceof ReflectionNamedType && $type->getName() === DateTime::class) {
175 | return true;
176 | }
177 |
178 | if ($type instanceof ReflectionUnionType) {
179 | $hasMutable = false;
180 | /**
181 | * @psalm-suppress RedundantConditionGivenDocblockType Need for PHP 8.1
182 | */
183 | foreach ($type->getTypes() as $subType) {
184 | if ($subType instanceof ReflectionNamedType) {
185 | switch ($subType->getName()) {
186 | case DateTime::class:
187 | $hasMutable = true;
188 | break;
189 | case DateTimeImmutable::class:
190 | case DateTimeInterface::class:
191 | return false;
192 | }
193 | }
194 | }
195 | return $hasMutable;
196 | }
197 |
198 | return false;
199 | }
200 | }
201 |
--------------------------------------------------------------------------------