feature proposal: NoOp transformer
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.
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,
}
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
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
I've tried using the transformer option. It works.
I have some remarks.
- 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. - It requires using both
MapFromandMapToattributes for the mapping to be truly bidirectional. - 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,
}