From 53b5788574fd8b4fce0657653b420e6977df78a3 Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:33:52 +0700 Subject: [PATCH] feat: Constructor arguments --- CHANGELOG.md | 1 + .../ClassNotInstantiableException.php | 25 ++++ .../IncompleteConstructorArgument.php | 29 ++++ .../InstantiationFailureException.php | 57 ++++++++ src/Exception/InvalidClassException.php | 25 ++++ src/Transformer/ObjectToObjectTransformer.php | 137 +++++++++++++----- ...bjectWithConstructorAndMoreArgumentDto.php | 53 +++++++ .../ObjectWithConstructorAndPropertiesDto.php | 60 ++++++++ .../Constructor/ObjectWithConstructorDto.php | 45 ++++++ .../ObjectWithPrivateConstructorDto.php | 55 +++++++ ...larPropertiesAndAdditionalNullProperty.php | 19 +++ tests/IntegrationTest/ConstructorTest.php | 71 +++++++++ 12 files changed, 541 insertions(+), 36 deletions(-) create mode 100644 src/Exception/ClassNotInstantiableException.php create mode 100644 src/Exception/IncompleteConstructorArgument.php create mode 100644 src/Exception/InstantiationFailureException.php create mode 100644 src/Exception/InvalidClassException.php create mode 100644 tests/Fixtures/Constructor/ObjectWithConstructorAndMoreArgumentDto.php create mode 100644 tests/Fixtures/Constructor/ObjectWithConstructorAndPropertiesDto.php create mode 100644 tests/Fixtures/Constructor/ObjectWithConstructorDto.php create mode 100644 tests/Fixtures/Constructor/ObjectWithPrivateConstructorDto.php create mode 100644 tests/Fixtures/Scalar/ObjectWithScalarPropertiesAndAdditionalNullProperty.php create mode 100644 tests/IntegrationTest/ConstructorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdf3c4..fed09e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add a caching layer for `TypeResolver` * `TraversableToTraversableTransformer` now accepts `Generator` as a target type +* Constructor arguments ## 0.5.3 diff --git a/src/Exception/ClassNotInstantiableException.php b/src/Exception/ClassNotInstantiableException.php new file mode 100644 index 0000000..f87500f --- /dev/null +++ b/src/Exception/ClassNotInstantiableException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Exception; + +class ClassNotInstantiableException extends NotMappableValueException +{ + /** + * @param class-string $class + */ + public function __construct(string $class) + { + parent::__construct(sprintf('Trying to instantiate class "%s", but this class is not instantiable.', $class)); + } +} diff --git a/src/Exception/IncompleteConstructorArgument.php b/src/Exception/IncompleteConstructorArgument.php new file mode 100644 index 0000000..f827f90 --- /dev/null +++ b/src/Exception/IncompleteConstructorArgument.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Exception; + +class IncompleteConstructorArgument extends NotMappableValueException +{ + /** + * @param class-string $targetClass + */ + public function __construct( + object $source, + string $targetClass, + string $property, + \Throwable $previous = null + ) { + parent::__construct(sprintf('Trying to instantiate target class "%s", but its constructor requires the property "%s", which is missing from the source "%s".', $targetClass, $property, \get_debug_type($source)), 0, $previous); + } +} diff --git a/src/Exception/InstantiationFailureException.php b/src/Exception/InstantiationFailureException.php new file mode 100644 index 0000000..61e619d --- /dev/null +++ b/src/Exception/InstantiationFailureException.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Exception; + +class InstantiationFailureException extends NotMappableValueException +{ + /** + * @param array $constructorArguments + */ + public function __construct( + object $source, + string $targetClass, + array $constructorArguments, + \Throwable $previous + ) { + if (count($constructorArguments) === 0) { + parent::__construct(sprintf( + 'Trying to map the source object of type "%s", but failed to instantiate the target object "%s" with no constructor argument.', + \get_debug_type($source), + $targetClass, + ), 0, $previous); + } else { + parent::__construct(sprintf( + 'Trying to map the source object of type "%s", but failed to instantiate the target object "%s" using constructor arguments: %s.', + \get_debug_type($source), + $targetClass, + self::formatConstructorArguments($constructorArguments) + ), 0, $previous); + } + + } + + /** + * @param array $constructorArguments + */ + private static function formatConstructorArguments(array $constructorArguments): string + { + $formattedArguments = []; + /** @var mixed $argumentValue */ + foreach ($constructorArguments as $argumentName => $argumentValue) { + $formattedArguments[] = sprintf('%s: %s', $argumentName, \get_debug_type($argumentValue)); + } + + return implode(', ', $formattedArguments); + } +} diff --git a/src/Exception/InvalidClassException.php b/src/Exception/InvalidClassException.php new file mode 100644 index 0000000..dd27f24 --- /dev/null +++ b/src/Exception/InvalidClassException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Exception; + +use Rekalogika\Mapper\Util\TypeUtil; +use Symfony\Component\PropertyInfo\Type; + +class InvalidClassException extends UnexpectedValueException +{ + public function __construct(Type $type) + { + parent::__construct(sprintf('Trying to map to class "%s", but this is not a valid class, interface, or enum.', TypeUtil::getDebugType($type))); + } +} diff --git a/src/Transformer/ObjectToObjectTransformer.php b/src/Transformer/ObjectToObjectTransformer.php index 6c756f5..e988a00 100644 --- a/src/Transformer/ObjectToObjectTransformer.php +++ b/src/Transformer/ObjectToObjectTransformer.php @@ -18,13 +18,18 @@ use Rekalogika\Mapper\Contracts\TransformerInterface; use Rekalogika\Mapper\Contracts\TypeMapping; use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException; +use Rekalogika\Mapper\Exception\ClassNotInstantiableException; +use Rekalogika\Mapper\Exception\IncompleteConstructorArgument; +use Rekalogika\Mapper\Exception\InstantiationFailureException; use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Exception\InvalidClassException; use Rekalogika\Mapper\MainTransformer; use Rekalogika\Mapper\ObjectCache\ObjectCache; use Rekalogika\Mapper\ObjectCache\ObjectCacheFactoryInterface; use Rekalogika\Mapper\TypeResolver\TypeResolverInterface; use Rekalogika\Mapper\Util\TypeCheck; use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; @@ -91,17 +96,11 @@ public function transform( return $source; } - // list properties - - $sourceProperties = $this->listSourceAttributes($sourceType, $context); - $writableTargetProperties = $this - ->listTargetWritableAttributes($targetType, $context); - // initialize target, add to cache after initialization if (null === $target) { $objectCache->preCache($source, $targetType); - $target = $this->initialize($targetType); + $target = $this->instantiateTarget($source, $targetType, $context); } else { if (!is_object($target)) { throw new InvalidArgumentException(sprintf('The target must be an object, "%s" given.', get_debug_type($target))); @@ -110,64 +109,130 @@ public function transform( $objectCache->saveTarget($source, $targetType, $target); + // list properties + + $sourceProperties = $this->listSourceAttributes($sourceType, $context); + $writableTargetProperties = $this + ->listTargetWritableAttributes($targetType, $context); + // calculate applicable properties $propertiesToMap = array_intersect($sourceProperties, $writableTargetProperties); // map properties - foreach ($propertiesToMap as $property) { - /** @var array|null */ - $targetPropertyTypes = $this->propertyTypeExtractor->getTypes($targetClass, $property, $context); + foreach ($propertiesToMap as $propertyName) { + assert(is_object($target)); - if (null === $targetPropertyTypes || count($targetPropertyTypes) === 0) { - throw new InvalidArgumentException(sprintf('Cannot get type of target property "%s::$%s".', $targetClass, $property)); - } - - /** @var mixed */ - $sourcePropertyValue = $this->propertyAccessor->getValue($source, $property); /** @var mixed */ - $targetPropertyValue = $this->propertyAccessor->getValue($target, $property); - - /** @var mixed */ - $targetPropertyValue = $this->mainTransformer?->transform( - source: $sourcePropertyValue, - target: $targetPropertyValue, - targetType: $targetPropertyTypes, + $targetPropertyValue = $this->resolveTargetPropertyValue( + source: $source, + target: $target, + propertyName: $propertyName, + targetClass: $targetClass, context: $context ); - $this->propertyAccessor->setValue($target, $property, $targetPropertyValue); + $this->propertyAccessor->setValue($target, $propertyName, $targetPropertyValue); } return $target; } + /** + * @param class-string $targetClass + * @param array $context + * @return mixed + */ + private function resolveTargetPropertyValue( + object $source, + ?object $target, + string $propertyName, + string $targetClass, + array $context, + ): mixed { + /** @var array|null */ + $targetPropertyTypes = $this->propertyTypeExtractor->getTypes($targetClass, $propertyName, $context); + + if (null === $targetPropertyTypes || count($targetPropertyTypes) === 0) { + throw new InvalidArgumentException(sprintf('Cannot get type of target property "%s::$%s".', $targetClass, $propertyName)); + } + + /** @var mixed */ + $sourcePropertyValue = $this->propertyAccessor->getValue($source, $propertyName); + + if ($target !== null) { + /** @var mixed */ + $targetPropertyValue = $this->propertyAccessor->getValue($target, $propertyName); + } else { + $targetPropertyValue = null; + } + + /** @var mixed */ + $targetPropertyValue = $this->mainTransformer?->transform( + source: $sourcePropertyValue, + target: $targetPropertyValue, + targetType: $targetPropertyTypes, + context: $context + ); + + return $targetPropertyValue; + } + public function getSupportedTransformation(): iterable { yield new TypeMapping(TypeFactory::object(), TypeFactory::object()); } /** + * @param array $context * @todo support constructor initialization */ - protected function initialize(Type $targetType): object - { - $class = $targetType->getClassName(); + protected function instantiateTarget( + object $source, + Type $targetType, + array $context + ): object { + $targetClass = $targetType->getClassName(); - if (null === $class || !\class_exists($class)) { - throw new InvalidArgumentException('Cannot get class name from target type.'); + if (null === $targetClass || !\class_exists($targetClass)) { + throw new InvalidClassException($targetType); } - // $initializableTargetProperties = $this->listTargetInitializableAttributes($targetClass); + $reflectionClass = new \ReflectionClass($targetClass); - // $writableAndNotInitializableTargetProperties = array_diff( - // $writableTargetProperties, - // $initializableTargetProperties - // ); + if (!$reflectionClass->isInstantiable()) { + throw new ClassNotInstantiableException($targetClass); + } - return (new \ReflectionClass($class)) - ->newInstanceWithoutConstructor(); + $initializableTargetProperties = $this + ->listTargetInitializableAttributes($targetClass, $context); + + $constructorArguments = []; + + foreach ($initializableTargetProperties as $propertyName) { + try { + /** @var mixed */ + $targetPropertyValue = $this->resolveTargetPropertyValue( + source: $source, + target: null, + propertyName: $propertyName, + targetClass: $targetClass, + context: $context + ); + } catch (NoSuchPropertyException $e) { + throw new IncompleteConstructorArgument($source, $targetClass, $propertyName, $e); + } + + /** @psalm-suppress MixedAssignment */ + $constructorArguments[$propertyName] = $targetPropertyValue; + } + + try { + return $reflectionClass->newInstanceArgs($constructorArguments); + } catch (\TypeError $e) { + throw new InstantiationFailureException($source, $targetClass, $constructorArguments, $e); + } } /** diff --git a/tests/Fixtures/Constructor/ObjectWithConstructorAndMoreArgumentDto.php b/tests/Fixtures/Constructor/ObjectWithConstructorAndMoreArgumentDto.php new file mode 100644 index 0000000..00c1b81 --- /dev/null +++ b/tests/Fixtures/Constructor/ObjectWithConstructorAndMoreArgumentDto.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Constructor; + +class ObjectWithConstructorAndMoreArgumentDto +{ + public function __construct( + private int $a, + private string $b, + private bool $c, + private float $d, + // uses object to prevent casting + private \Stringable $e, + ) { + } + + public function getA(): int + { + return $this->a; + } + + public function getB(): string + { + return $this->b; + } + + public function isC(): bool + { + return $this->c; + } + + public function getD(): float + { + return $this->d; + } + + // work around mapping null to object, now property info sees it as a string + public function getE(): string + { + return (string) $this->e; + } +} diff --git a/tests/Fixtures/Constructor/ObjectWithConstructorAndPropertiesDto.php b/tests/Fixtures/Constructor/ObjectWithConstructorAndPropertiesDto.php new file mode 100644 index 0000000..830b7f0 --- /dev/null +++ b/tests/Fixtures/Constructor/ObjectWithConstructorAndPropertiesDto.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Constructor; + +class ObjectWithConstructorAndPropertiesDto +{ + public function __construct( + private int $a = 1, + private string $b = 'string', + ) { + } + + private ?bool $c = null; + private ?float $d = null; + + public function getA(): int + { + return $this->a; + } + + public function getB(): string + { + return $this->b; + } + + public function isC(): ?bool + { + return $this->c; + } + + public function getD(): ?float + { + return $this->d; + } + + public function setC(?bool $c): self + { + $this->c = $c; + + return $this; + } + + public function setD(?float $d): self + { + $this->d = $d; + + return $this; + } +} diff --git a/tests/Fixtures/Constructor/ObjectWithConstructorDto.php b/tests/Fixtures/Constructor/ObjectWithConstructorDto.php new file mode 100644 index 0000000..a0654e3 --- /dev/null +++ b/tests/Fixtures/Constructor/ObjectWithConstructorDto.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Constructor; + +class ObjectWithConstructorDto +{ + public function __construct( + private int $a, + private string $b, + private bool $c, + private float $d, + ) { + } + + public function getA(): int + { + return $this->a; + } + + public function getB(): string + { + return $this->b; + } + + public function isC(): bool + { + return $this->c; + } + + public function getD(): float + { + return $this->d; + } +} diff --git a/tests/Fixtures/Constructor/ObjectWithPrivateConstructorDto.php b/tests/Fixtures/Constructor/ObjectWithPrivateConstructorDto.php new file mode 100644 index 0000000..d12fe08 --- /dev/null +++ b/tests/Fixtures/Constructor/ObjectWithPrivateConstructorDto.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Constructor; + +class ObjectWithPrivateConstructorDto +{ + public static function create(): self + { + return new self( + a: 1, + b: 'string', + c: true, + d: 1.1, + ); + } + + private function __construct( + private int $a, + private string $b, + private bool $c, + private float $d, + ) { + } + + public function getA(): int + { + return $this->a; + } + + public function getB(): string + { + return $this->b; + } + + public function isC(): bool + { + return $this->c; + } + + public function getD(): float + { + return $this->d; + } +} diff --git a/tests/Fixtures/Scalar/ObjectWithScalarPropertiesAndAdditionalNullProperty.php b/tests/Fixtures/Scalar/ObjectWithScalarPropertiesAndAdditionalNullProperty.php new file mode 100644 index 0000000..4ac9ec4 --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithScalarPropertiesAndAdditionalNullProperty.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Scalar; + +class ObjectWithScalarPropertiesAndAdditionalNullProperty extends ObjectWithScalarProperties +{ + public ?string $e = null; +} diff --git a/tests/IntegrationTest/ConstructorTest.php b/tests/IntegrationTest/ConstructorTest.php new file mode 100644 index 0000000..2d1b538 --- /dev/null +++ b/tests/IntegrationTest/ConstructorTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\IntegrationTest; + +use Rekalogika\Mapper\Exception\ClassNotInstantiableException; +use Rekalogika\Mapper\Exception\IncompleteConstructorArgument; +use Rekalogika\Mapper\Exception\InstantiationFailureException; +use Rekalogika\Mapper\Tests\Common\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\Constructor\ObjectWithConstructorAndMoreArgumentDto; +use Rekalogika\Mapper\Tests\Fixtures\Constructor\ObjectWithConstructorAndPropertiesDto; +use Rekalogika\Mapper\Tests\Fixtures\Constructor\ObjectWithConstructorDto; +use Rekalogika\Mapper\Tests\Fixtures\Constructor\ObjectWithPrivateConstructorDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesAndAdditionalNullProperty; + +class ConstructorTest extends AbstractIntegrationTest +{ + public function testConstructor(): void + { + $source = new ObjectWithScalarProperties(); + $target = $this->mapper->map($source, ObjectWithConstructorDto::class); + + $this->assertSame(1, $target->getA()); + $this->assertSame('string', $target->getB()); + $this->assertTrue($target->isC()); + $this->assertSame(1.1, $target->getD()); + } + + public function testPrivateConstructor(): void + { + $source = new ObjectWithScalarProperties(); + $this->expectException(ClassNotInstantiableException::class); + $this->mapper->map($source, ObjectWithPrivateConstructorDto::class); + } + + public function testConstructorAndProperties(): void + { + $source = new ObjectWithScalarProperties(); + $target = $this->mapper->map($source, ObjectWithConstructorAndPropertiesDto::class); + + $this->assertSame(1, $target->getA()); + $this->assertSame('string', $target->getB()); + $this->assertTrue($target->isC()); + $this->assertSame(1.1, $target->getD()); + } + + public function testMissingSourceProperty(): void + { + $this->expectException(IncompleteConstructorArgument::class); + $source = new ObjectWithScalarProperties(); + $this->mapper->map($source, ObjectWithConstructorAndMoreArgumentDto::class); + } + + public function testNullSourcePropertyAndNotNullTargetProperty(): void + { + $this->expectException(InstantiationFailureException::class); + $source = new ObjectWithScalarPropertiesAndAdditionalNullProperty(); + $this->mapper->map($source, ObjectWithConstructorAndMoreArgumentDto::class); + } +}