Skip to content

feature proposal: NoOp transformer #270

@landure

Description

@landure

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions