├── 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 | Yii 4 | 5 |

Yii Hydrator

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/hydrator/v)](https://packagist.org/packages/yiisoft/hydrator) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/hydrator/downloads)](https://packagist.org/packages/yiisoft/hydrator) 11 | [![Build status](https://github.com/yiisoft/hydrator/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/hydrator/actions/workflows/build.yml) 12 | [![Code Coverage](https://codecov.io/gh/yiisoft/hydrator/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/hydrator) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fhydrator%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/hydrator/master) 14 | [![static analysis](https://github.com/yiisoft/hydrator/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/hydrator/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/hydrator/coverage.svg)](https://shepherd.dev/github/yiisoft/hydrator) 16 | [![psalm-level](https://shepherd.dev/github/yiisoft/hydrator/level.svg)](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 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 116 | 117 | ## Follow updates 118 | 119 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 120 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 121 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 122 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 123 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](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 | --------------------------------------------------------------------------------