automapper icon indicating copy to clipboard operation
automapper copied to clipboard

feature proposal: NoOp transformer

Open landure opened this issue 8 months ago • 4 comments

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.

landure avatar Apr 24 '25 09:04 landure

I'm not sure this is really needed you can already achieve that by using a custom transformer that does nothing or even better just using the expression language :

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]
    #[MapTo(transformer: 'source.category')]
    public ?Category $category = null,
}

joelwurtz avatar Apr 24 '25 12:04 joelwurtz

Hi,

thank you for your reply. I've three remarks on your reply:

  • creating a transformer that does nothing is an answer, and it's the origin of this NoOp proposal, but it would be nice if Automapper proposed this feature natively.
  • the expression language is Symfony only, and might have a bigger performance impact than a dedicated transformer.
  • Please add to the documentation the Expression example you've just given

landure avatar Apr 24 '25 14:04 landure

creating a transformer that does nothing is an answer, and it's the origin of this NoOp proposal, but it would be nice if Automapper proposed this feature natively.

It does ? It's not that explicit but like you said it can be added in the documentation

the expression language is Symfony only, and might have a bigger performance impact than a dedicated transformer.

Expression language is used as a component for simple case like the one i mentioned (no need for the framework), Symfony, the framework, is only required to use custom service inside expression (which is not the case here)

There is no performance impact compared to a dedicated transformer, the expression language is compiled and directly writed as an AST in the generated mapper, which means my example would generate this code in the mapper :

$target->category = $source->category;

Please add to the documentation the Expression example you've just given

It's hard to think of all uses case people can use with expression language or our library, but feel free to open a pull request to add your use case in the documentation

joelwurtz avatar Apr 24 '25 14:04 joelwurtz

I've tried using the transformer option. It works.

I have some remarks.

  1. It requires to explicitly use the getter in the expression (#[MapFrom(transformer: 'source.getCategory()')]) when the source property is private. This doesn't take advantage of AutoMapper detection features.
  2. It requires using both MapFrom and MapTo attributes for the mapping to be truly bidirectional.
  3. It doesn't allow for uninitialized properties in source.

The #[NoOp] attribute proposal use AutoMapper standard ways to get and set data.

declare(strict_types=1);

namespace App\Form\Model;

use App\Entity\BlogPost;
use App\Entity\Category;
use AutoMapper\Attribute\MapFrom;
use AutoMapper\Attribute\Mapper;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;

#[Mapper(source: BlogPostModel::class, target: BlogPost::class)]
final class BlogPostModel
{     
    #[Assert\NotNull]
    #[MapFrom(transformer: 'source.getCategory()')]
    #[MapTo(transformer: 'source.category')]
    public ?Category $category = null,
}

landure avatar May 15 '25 09:05 landure