mark-gerarts / automapper-plus-bundle

A symfony bundle for AutoMapper+
MIT License
59 stars 11 forks source link

Adding Doctrine Support #12

Open leroy0211 opened 5 years ago

leroy0211 commented 5 years ago

What about adding Doctrine Support with some additional operations? For example:

mark-gerarts commented 5 years ago

Hi @leroy0211! Yes, this would be pretty useful. For this to work we need to be able to map from and to scalars. From there it would simply be a matter of registering a mapping that uses some entity repository. However, mapping with scalars is currently not possible, as I always started from the idea of mapping from and to an object.

That being said, I think it might be possible to work this into the 2.0 branch. As a matter in fact, I was able to create a small POC by just expanding the allowed data types (see the feature/scalar-mapping branch, more specifically this commit).

This then works using custom constructor factories:

$config = new AutoMapperConfig();

// This only works on feature/scalar-mappings
$config->registerMapping(DataType::INTEGER, Post::class)
    ->beConstructedUsing(function (int $id): ?Post {
        return $this->postRepository->find($id);
    })
    ->withDefaultOperation(Operation::ignore());
$config->registerMapping(Post::class, DataType::INTEGER)
    ->beConstructedUsing(function (Post $post): int {
        return $post->getId();
    });

$mapper = new AutoMapper($config);

$mapper->map(1, Post::class);
$mapper->map($this->postRepository->find(1), DataType::INTEGER);

This isn't really clean of course, expanding the API of the config would be better. This API is currently built around defining operations for properties, whereas here we are defining a single operation for the entire object. We could use something like $config->registerScalarMapping(...) or $config->registerMapping(...)->mapUsing(<callable>) (ideas are welcome :)).

I'll have to think about it a bit. I'm having a busy few months ahead, so I don't know how much time I'm going to be able to spend on this, but I'll see what I can do :)

leroy0211 commented 5 years ago

I've come up with a different approach by using MappingOperations, because I like to map A relation's id or other attribute to a Dto's scalar value.

For example, a Shop which has Stock, and that Stock comes from multiple Locations. I only want to configure a shop's stock identifier, but want to display the location names.

For example (as json):

{
   "name": "My Physical shop",
   "address": {
       "street": "Lorem ipsum"
   },
   "stockId": 5,   <-- This is an entity id
   "stockLocations": ["Warehouse 1", "Store stock", "Store warehouse"]  <-- These are the location names
}

So I've come up with 4 MappingOperations for this to work. However, to convert the integer to an entity, you need a repository or an EntityManager as dependency. The Entity to property MappingOpertions can take any property, not only just the identifiers.

For the entity to a property mapping I've created:

class MapPropertyFromEntity extends DefaultMappingOperation
{
    /** @var string */
    private $valueProperty;

    public function __construct(string $valueProperty)
    {
        $this->valueProperty = $valueProperty;
    }

    protected function getSourceValue($source, string $propertyName)
    {
        $entity = $this->propertyReader->getProperty(
            $source,
            $this->getSourcePropertyName($propertyName)
        );

        return $this->propertyReader->getProperty($entity, $this->valueProperty);
    }
}

And for a Collection I've created:

class MapPropertyFromCollection extends DefaultMappingOperation
{
    private $valueProperty;

    public function __construct($valueProperty)
    {
        $this->valueProperty = $valueProperty;
    }

    protected function getSourceValue($source, string $propertyName)
    {
        $values = [];

        foreach ($this->propertyReader->getProperty($source, $propertyName) ?: [] as $item) {
            $values[] = $this->getPropertyReader()->getProperty($item, $this->valueProperty);
        }

        return $values;
    }
}

For the reverse version (mapping id's to entities or a collection of entities) I've created:

class MapIdToEntity extends DefaultMappingOperation
{
    /**
     * @var EntityManagerInterface
     */
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    protected function setDestinationValue($destination, string $propertyName, $value): void
    {
        if (empty($value)) {
            return;
        }

        $metadata = $this->entityManager->getClassMetadata(get_class($destination));
        $destinationClass = $metadata->getAssociationTargetClass($propertyName);

        $entity = $this->entityManager->find($destinationClass, $value);

        if (null === $entity) {
            return;
        }

        $metadata->setFieldValue($destination, $propertyName, $entity);
    }
}

AND

class MapIdsToCollection extends DefaultMappingOperation
{
    /**
     * @var EntityManagerInterface
     */
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * {@inheritdoc}
     */
    protected function setDestinationValue($destination, string $propertyName, $value): void
    {
        if (empty($value)) {
            return;
        }

        $metadata = $this->entityManager->getClassMetadata(get_class($destination));
        $destinationClass = $metadata->getAssociationTargetClass($propertyName);

        $items = $this->getEntities($value, $destinationClass);

        if ($metadata->isCollectionValuedAssociation($propertyName)) {
            $metadata->setFieldValue($destination, $propertyName, new ArrayCollection(iterator_to_array($items)));
        } else {
            parent::setDestinationValue($destination, $propertyName, iterator_to_array($items));
        }
    }

    private function getEntities($value, string $className): \Generator
    {
        foreach ($value as $item) {
            if ($entity = $this->entityManager->find($className, $item)) {
                yield $entity;
            }
        }
    }
}