diff --git a/src/Attribute/InheritanceMap.php b/src/Attribute/InheritanceMap.php index d7c5573..9963d51 100644 --- a/src/Attribute/InheritanceMap.php +++ b/src/Attribute/InheritanceMap.php @@ -31,13 +31,4 @@ public function getMap(): array { return $this->map; } - - /** - * @param class-string $sourceClass - * @return class-string|null - */ - public function getTargetClassFromSourceClass(string $sourceClass): ?string - { - return $this->map[$sourceClass] ?? null; - } } diff --git a/src/Transformer/Exception/SourceClassNotInInheritanceMapException.php b/src/Transformer/Exception/ClassNotInInheritanceMapException.php similarity index 71% rename from src/Transformer/Exception/SourceClassNotInInheritanceMapException.php rename to src/Transformer/Exception/ClassNotInInheritanceMapException.php index a8e0f77..ab19f62 100644 --- a/src/Transformer/Exception/SourceClassNotInInheritanceMapException.php +++ b/src/Transformer/Exception/ClassNotInInheritanceMapException.php @@ -13,10 +13,9 @@ namespace Rekalogika\Mapper\Transformer\Exception; -use Rekalogika\Mapper\Attribute\InheritanceMap; use Rekalogika\Mapper\Context\Context; -class SourceClassNotInInheritanceMapException extends NotMappableValueException +class ClassNotInInheritanceMapException extends NotMappableValueException { /** * @param class-string $sourceClass @@ -29,9 +28,9 @@ public function __construct( ) { parent::__construct( message: sprintf( - 'Trying to map to a class with an inheritance map, but source class "%s" is not found in the "%s" attribute of the target class "%s".', + 'Trying to map source class "%s" to target class "%s" using an inheritance map, but the target class "%s" is missing from the inheritance map.', $sourceClass, - InheritanceMap::class, + $targetClass, $targetClass ), context: $context, diff --git a/src/Transformer/InheritanceMapTransformer.php b/src/Transformer/InheritanceMapTransformer.php index 89a4f4a..ddd3d79 100644 --- a/src/Transformer/InheritanceMapTransformer.php +++ b/src/Transformer/InheritanceMapTransformer.php @@ -21,7 +21,8 @@ use Rekalogika\Mapper\Transformer\Contracts\MainTransformerAwareTrait; use Rekalogika\Mapper\Transformer\Contracts\TransformerInterface; use Rekalogika\Mapper\Transformer\Contracts\TypeMapping; -use Rekalogika\Mapper\Transformer\Exception\SourceClassNotInInheritanceMapException; +use Rekalogika\Mapper\Transformer\Exception\ClassNotInInheritanceMapException; +use Rekalogika\Mapper\Util\AttributeUtil; use Rekalogika\Mapper\Util\TypeFactory; use Symfony\Component\PropertyInfo\Type; @@ -54,10 +55,24 @@ public function transform( throw new InvalidArgumentException('Target type must be specified.', context: $context); } - # target must be an interface or class + # source and target must be an interface or class + $sourceClass = $targetType->getClassName(); $targetClass = $targetType->getClassName(); + if ( + $sourceClass === null + || ( + !\class_exists($sourceClass) + && !\interface_exists($sourceClass) + ) + ) { + throw new InvalidArgumentException( + \sprintf('Source class "%s" does not exist.', $sourceClass ?? 'null'), + context: $context + ); + } + if ( $targetClass === null || ( @@ -73,25 +88,29 @@ public function transform( # gets the inheritance map - $attributes = (new \ReflectionClass($targetClass)) - ->getAttributes(InheritanceMap::class); + $inheritanceMap = $this->getMapFromTargetClass($targetClass); - if (\count($attributes) === 0) { - throw new InvalidArgumentException( - \sprintf('Target class "%s" must have inheritance map.', $targetClass), - context: $context - ); - } - $inheritanceMap = $attributes[0]->newInstance(); + if ($inheritanceMap === null) { + $inheritanceMap = $this->getMapFromSourceClass($sourceClass); + + if ($inheritanceMap === null) { + throw new InvalidArgumentException( + \sprintf('Either source class "%s" or target class "%s" must have inheritance map.', $sourceClass, $targetClass), + context: $context + ); + } + + $inheritanceMap = \array_flip($inheritanceMap); + } # gets the target class from the inheritance map $sourceClass = \get_class($source); - $targetClassInMap = $inheritanceMap->getTargetClassFromSourceClass($sourceClass); + $targetClassInMap = $inheritanceMap[$sourceClass] ?? null; if ($targetClassInMap === null) { - throw new SourceClassNotInInheritanceMapException($sourceClass, $targetClass); + throw new ClassNotInInheritanceMapException($sourceClass, $targetClass); } # pass the transformation back to the main transformer @@ -117,6 +136,50 @@ public function transform( return $result; } + /** + * @param class-string $class + * @return null|array + */ + private function getMapFromTargetClass(string $class): array|null + { + $attributes = AttributeUtil::getAttributes(new \ReflectionClass($class)); + + foreach ($attributes as $attribute) { + if ($attribute->getName() !== InheritanceMap::class) { + continue; + } + + /** @var InheritanceMap $inheritanceMap */ + $inheritanceMap = $attribute->newInstance(); + + return $inheritanceMap->getMap(); + } + + return null; + } + + /** + * @param class-string $class + * @return null|array + */ + private function getMapFromSourceClass(string $class): array|null + { + $attributes = AttributeUtil::getAttributesIncludingParents(new \ReflectionClass($class)); + + foreach ($attributes as $attribute) { + if ($attribute->getName() !== InheritanceMap::class) { + continue; + } + + /** @var InheritanceMap $inheritanceMap */ + $inheritanceMap = $attribute->newInstance(); + + return array_flip($inheritanceMap->getMap()); + } + + return null; + } + public function getSupportedTransformation(): iterable { yield new TypeMapping( @@ -124,5 +187,11 @@ public function getSupportedTransformation(): iterable TypeFactory::objectOfClass(InheritanceMap::class), true ); + + yield new TypeMapping( + TypeFactory::objectOfClass(InheritanceMap::class), + TypeFactory::object(), + true + ); } } diff --git a/src/Util/AttributeUtil.php b/src/Util/AttributeUtil.php new file mode 100644 index 0000000..b23014d --- /dev/null +++ b/src/Util/AttributeUtil.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Util; + +use Rekalogika\Mapper\Attribute\MapperAttributeInterface; + +class AttributeUtil +{ + /** + * @var array>> + */ + private static array $attributesIncludingParentsCache = []; + + /** + * @param \ReflectionClass $class + * @return array> + */ + public static function getAttributesIncludingParents(\ReflectionClass $class): array + { + $className = $class->getName(); + + if (isset(self::$attributesIncludingParentsCache[$className])) { + return self::$attributesIncludingParentsCache[$className]; + } + + $attributes = []; + + while ($class !== false) { + $attributes = array_merge( + $attributes, + self::getAttributes($class) + ); + + $class = $class->getParentClass(); + } + + foreach (class_implements($className) as $interface) { + $interface = new \ReflectionClass($interface); + + $attributes = array_merge( + $attributes, + self::getAttributes($interface) + ); + } + + return self::$attributesIncludingParentsCache[$className] = $attributes; + } + + /** + * @var array>> + */ + private static array $attributes = []; + + /** + * @param \ReflectionClass $class + * @return array> + */ + public static function getAttributes(\ReflectionClass $class): array + { + $className = $class->getName(); + + if (isset(self::$attributes[$className])) { + return self::$attributes[$className]; + } + + $attributes = []; + + $attributes = $class->getAttributes( + MapperAttributeInterface::class, + \ReflectionAttribute::IS_INSTANCEOF + ); + + return self::$attributes[$className] = $attributes; + } +} diff --git a/src/Util/TypeUtil.php b/src/Util/TypeUtil.php index d78b523..eb47f77 100644 --- a/src/Util/TypeUtil.php +++ b/src/Util/TypeUtil.php @@ -376,17 +376,14 @@ private static function getAttributesFromType( return []; } - $attributes = (new \ReflectionClass($class)) - ->getAttributes( - MapperAttributeInterface::class, - \ReflectionAttribute::IS_INSTANCEOF - ); - - $attributeTypes = []; - - foreach ($attributes as $attribute) { - $attributeTypes[] = TypeFactory::objectOfClass($attribute->getName()); - } + $reflectionClass = new \ReflectionClass($class); + $attributes = AttributeUtil::getAttributesIncludingParents($reflectionClass); + + $attributeTypes = array_map( + fn (\ReflectionAttribute $attribute) + => TypeFactory::objectOfClass($attribute->getName()), + $attributes + ); return $attributeTypes; } diff --git a/tests/IntegrationTest/InheritanceTest.php b/tests/IntegrationTest/InheritanceTest.php index 60ccf47..18ae04d 100644 --- a/tests/IntegrationTest/InheritanceTest.php +++ b/tests/IntegrationTest/InheritanceTest.php @@ -14,6 +14,7 @@ namespace Rekalogika\Mapper\Tests\IntegrationTest; use Rekalogika\Mapper\Tests\Common\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\Inheritance\AbstractClass; use Rekalogika\Mapper\Tests\Fixtures\Inheritance\ConcreteClassA; use Rekalogika\Mapper\Tests\Fixtures\Inheritance\ConcreteClassC; use Rekalogika\Mapper\Tests\Fixtures\InheritanceDto\AbstractClassDto; @@ -22,9 +23,9 @@ use Rekalogika\Mapper\Tests\Fixtures\InheritanceDto\ImplementationADto; use Rekalogika\Mapper\Tests\Fixtures\InheritanceDto\InterfaceDto; use Rekalogika\Mapper\Tests\Fixtures\InheritanceDto\InterfaceWithoutMapDto; +use Rekalogika\Mapper\Transformer\Exception\ClassNotInInheritanceMapException; use Rekalogika\Mapper\Transformer\Exception\ClassNotInstantiableException; use Rekalogika\Mapper\Transformer\Exception\NotAClassException; -use Rekalogika\Mapper\Transformer\Exception\SourceClassNotInInheritanceMapException; class InheritanceTest extends AbstractIntegrationTest { @@ -38,6 +39,13 @@ public function testMapToAbstractClass(): void $this->assertInstanceOf(ConcreteClassADto::class, $result); $this->assertSame('propertyInA', $result->propertyInA); $this->assertSame('propertyInParent', $result->propertyInParent); + + // map back + + $result = $this->mapper->map($result, AbstractClass::class); + $this->assertInstanceOf(ConcreteClassA::class, $result); + $this->assertSame('propertyInA', $result->propertyInA); + $this->assertSame('propertyInParent', $result->propertyInParent); } public function testMapToAbstractClassWithoutMap(): void @@ -50,7 +58,7 @@ public function testMapToAbstractClassWithoutMap(): void public function testMapToAbstractClassWithMissingSourceClassInMap(): void { $concreteClassC = new ConcreteClassC(); - $this->expectException(SourceClassNotInInheritanceMapException::class); + $this->expectException(ClassNotInInheritanceMapException::class); $result = $this->mapper->map($concreteClassC, AbstractClassDto::class); } @@ -75,7 +83,7 @@ public function testMapToInterfaceWithoutMap(): void public function testMapToInterfaceWithMissingSourceClassInMap(): void { $concreteClassC = new ConcreteClassC(); - $this->expectException(SourceClassNotInInheritanceMapException::class); + $this->expectException(ClassNotInInheritanceMapException::class); $result = $this->mapper->map($concreteClassC, InterfaceDto::class); } }