- 
                Notifications
    You must be signed in to change notification settings 
- Fork 20
Description
Hi,
thank you for your work,
by default, automapper creates a clone of mapped objects properties.
This is an issue when mapping doctrine entities (typical use case: mapping an entity with one to many relationships to a symfony form model). The mapped entity object isn't managed by doctrine.
Using the MaxDepth attribute might bes an option in some cases, but doesn't allow fine control of the mapped values.
This feature proposal introduces a "No Operation" transformer that plop the original object property value in the target class property.
Implementation proposal
Here is a possible (and working) but probably incomplete implementation
A NoOp attribute to identify properties to map as is (without recursion):
<?php
declare(strict_types=1);
namespace AutoMapper\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class NoOp
{
}A transformer that copy the property if it has a NoOp attribute, and the source and target property types (classes) are identical:
<?php
declare(strict_types=1);
namespace AutoMapper\Transformer;
use AutoMapper\Attribute\NoOp;
use AutoMapper\Metadata\MapperMetadata;
use AutoMapper\Metadata\SourcePropertyMetadata;
use AutoMapper\Metadata\TargetPropertyMetadata;
use AutoMapper\Metadata\TypesMatching;
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface;
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerSupportInterface;
use Symfony\Component\PropertyInfo\Type;
final readonly class NoOpTransformer implements PropertyTransformerInterface, PropertyTransformerSupportInterface
{
    #[\Override]
    public function supports(
        TypesMatching $types,
        SourcePropertyMetadata $source,
        TargetPropertyMetadata $target,
        MapperMetadata $mapperMetadata,
    ): bool {
        $sourceUniqueType = $types->getSourceUniqueType();
        if (!$sourceUniqueType instanceof Type) {
            return false;
        }
        $targetUniqueType = $types->getTargetUniqueType($sourceUniqueType);
        if (!$targetUniqueType instanceof Type) {
            return false;
        }
        $isSameClass = $this->isSameClass($sourceUniqueType, $targetUniqueType);
        $hasSourceNoOpAttribute = $this->hasNoOpAttribute($source, $mapperMetadata);
        $hasTargetNoOpAttribute = $this->hasNoOpAttribute($target, $mapperMetadata);
        return $isSameClass && ($hasSourceNoOpAttribute || $hasTargetNoOpAttribute);
    }
    #[\Override]
    public function transform(mixed $value, object|array $source, array $context): mixed
    {
        return $value;
    }
    private function isSameClass(Type $source, Type $target): bool
    {
        $sourceClassName = $source->getClassName();
        $targetClassName = $target->getClassName();
        return $sourceClassName === $targetClassName
            && null !== $sourceClassName
            && null !== $targetClassName;
    }
    private function hasNoOpAttribute(
        SourcePropertyMetadata|TargetPropertyMetadata $propertyMetadata,
        MapperMetadata $mapperMetadata,
    ): bool {
        $propertyAttributes = $this->getMappedPropertyAttributes($propertyMetadata, $mapperMetadata);
        foreach ($propertyAttributes as $attribute) {
            if (NoOp::class === $attribute->getName()) {
                return true;
            }
        }
        return false;
    }
    /**
     * @return \ReflectionAttribute<object>[]
     */
    private function getMappedPropertyAttributes(
        SourcePropertyMetadata|TargetPropertyMetadata $propertyMetadata,
        MapperMetadata $mapperMetadata,
    ): array {
        $propertyName = $propertyMetadata->property;
        $reflectionClass = match (true) {
            $propertyMetadata instanceof SourcePropertyMetadata => $mapperMetadata->sourceReflectionClass,
            $propertyMetadata instanceof TargetPropertyMetadata => $mapperMetadata->targetReflectionClass,
            default => throw new \LogicException('Invalid property metadata type'),
        };
        if (!$reflectionClass instanceof \ReflectionClass) {
            return [];
        }
        return $this->getPropertyAttributes($reflectionClass, $propertyName);
    }
    /**
     * @return \ReflectionAttribute<object>[]
     */
    private function getPropertyAttributes(
        \ReflectionClass $reflectionClass,
        string $propertyName,
    ): array {
        $reflectionProperty = $this->getReflectionProperty($reflectionClass, $propertyName);
        if ($reflectionProperty instanceof \ReflectionProperty) {
            return $reflectionProperty->getAttributes();
        }
        return [];
    }
    private function getReflectionProperty(
        \ReflectionClass $reflectionClass,
        string $propertyName,
    ): ?\ReflectionProperty {
        if (!$reflectionClass->hasProperty($propertyName)) {
            return null;
        }
        return $reflectionClass->getProperty($propertyName);
    }
}Usage
Consider a BlogPost entity with a mandatory Category property:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity()]
class BlogPost
{
    public function __construct(
        #[ORM\ManyToOne()]
        private Category $category;
    ) {
    }
}The corresponding Form model is:
declare(strict_types=1);
namespace App\Form\Model;
use App\Entity\BlogPost;
use App\Entity\Category;
use AutoMapper\Attribute\Mapper;
use AutoMapper\Attribute\NoOp;
use Symfony\Component\Validator\Constraints as Assert;
#[Mapper(source: BlogPostModel::class, target: BlogPost::class)]
final class BlogPostModel
{     
        #[Assert\NotNull]
        #[NoOp]
        public ?Category $category = null,
}When using Automapper to map the entity to the form model, this allows using Symfony EntityType field in the form type without issue, since the Category object in the form model is identical to the one in the Entity and is registered in Doctrine.