Skip to content

Latest commit

 

History

History
346 lines (256 loc) · 9.95 KB

object-mapper.rst

File metadata and controls

346 lines (256 loc) · 9.95 KB

Object Mapper

Symfony provides a mapper to transform a given object to another one. This compoent is experimental.

Installation

Run this command to install the object-mapper before using it:

$ composer require symfony/object-mapper

Using the ObjectMapper Service

Once installed, the object mapper service can be injected in any service where you need it or it can be used in a controller:

// src/Controller/DefaultController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

class DefaultController extends AbstractController
{
    public function __invoke(ObjectMapperInterface $objectMapper): Response
    {
        // keep reading for usage examples
    }
}

Map an object to another one

To map an object to another one use map:

use App\Entity\Book;
use App\ValueObject\Book as BookDto;

$book = $bookRepository->find(1);
$mapper = new ObjectMapper();
$mapper->map($book, BookDto::class);

If you already have a target object, you can use its instance directly:

use App\Entity\Book;
use App\ValueObject\Book as BookDto;

$bookDto = new BookDto(title: 'An updated title');
$book = $bookRepository->find(1);
$mapper = new ObjectMapper();
$mapper->map($bookDto, $book);

The Object Mapper source can also be a stdClass:

use App\Entity\Book;

$bookDto = new \stdClass();
$bookDto->title = 'An updated title';
$mapper = new ObjectMapper();
$mapper->map($bookDto, Book::class);

Configure the mapping target using attributes

The Object Mapper component includes a :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute to configure mapping behavior between objects. Use this attribute on a class to specify the target class:

// src/Dto/Source.php
namespace App\Dto;

use Symfony\Component\ObjectMapper\Attributes\Map;

#[Map(target: Target::class)]
class Source {}

Configure property mapping

Use the :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute on properties to configure property mapping between objects. target changes the target property, if allows to conditionally map properties:

// src/Dto/Source.php
namespace App\Dto;

use Symfony\Component\ObjectMapper\Attributes\Map;

class Source {
    #[Map(target: 'fullName')]
    public string $firstName;

    // when we do not want to map the lastName we can use `false`
    #[Map(if: false)]
    public string $lastName;
}

When a property is not present in the target class it will be ignored.

The condition mapping can also be configured as a service to do so implement a :class:`Symfony\\Component\\ObjectMapper\\ConditionCallableInterface`:

// src/ObjectMapper/ConditionNameCallable.php
namespace App\ObjectMapper;

use App\Dto\Source;
use Symfony\Component\ObjectMapper\ConditionCallableInterface;

/**
 * @implements ConditionCallableInterface<Source>
 */
final class ConditionNameCallable implements ConditionCallableInterface
{
    public function __invoke(mixed $value, object $source): bool
    {
        return is_string($value);
    }
}

// src/Dto/Source.php
namespace App\Dto;

use App\ObjectMapper\ConditionNameCallable;
use Symfony\Component\ObjectMapper\Attributes\Map;

class Source {
    #[Map(if: ConditionCallableInterface::class)]
    public mixed $status;
}

Whe you have multiple mapping targets, you can also use the target class name as a condition for property mapping:

#[Map(target: B::class)]
#[Map(target: C::class)]
class A
{
    // This will map to `foo` only when the target is of type B::class
    #[Map(target: 'somethingOnlyInB', transform: 'strtoupper', if: B::class)]
    public string $something = 'test';
}

Transform mapped values

Use transform to call a static function or a :class:`Symfony\\Component\\ObjectMapper\\TransformCallableInterface`:

// src/ObjectMapper/TransformNameCallable.php
namespace App\ObjectMapper;

use App\Dto\Source;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

/**
 * @implements TransformCallableInterface<Source>
 */
final class TransformNameCallable implements TransformCallableInterface
{
    public function __invoke(mixed $value, object $source): mixed
    {
        return sprintf('%s %s', $source->firstName, $source->lastName);
    }
}

// src/Dto/Source.php
namespace App\Dto;

use App\ObjectMapper\TransformNameCallable;
use Symfony\Component\ObjectMapper\Attributes\Map;

class Source {
    #[Map(target: 'fullName', transform: TransformNameCallable::class)]
    public string $firstName;
}

We can also use a transformation mapping on a class, it should return the type of your mapping target:

// src/Dto/Source.php
namespace App\Dto;

#[Map(transform: [Target::class, 'newInstance'])]
class Source
{
    public string $name = 'test';
}


// src/Dto/Target.php
class Target
{
    public ?string $name = null;

    public function __construct(private readonly int $id)
    {
    }

    public function getId(): int
    {
        return $this->id;
    }

    public static function newInstance(): self
    {
        return new self(1);
    }
}

The if and transform parameters also accept static callbacks:

// src/Dto/Source.php
namespace App\Dto;

use Symfony\Component\ObjectMapper\Attributes\Map;

class Source {
    #[Map(if: 'boolval', transform: 'ucfirst')]
    public ?string $lastName = null;
}

The :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute works on classes and it can be repeated:

// src/Dto/Source.php
namespace App\Dto;

use App\Dto\B;
use App\Dto\C;
use App\ObjectMapper\TransformNameCallable;
use Symfony\Component\ObjectMapper\Attributes\Map;

#[Map(target: B::class, if: [Source::class, 'shouldMapToB'])]
#[Map(target: C::class, if: [Source::class, 'shouldMapToC'])]
class Source
{
    /**
     * In case of a condition on a class, $value will be null
     */
    public static function shouldMapToB(mixed $value, object $source): bool
    {
        return false;
    }

    public static function shouldMapToC(mixed $value, object $source): bool
    {
        return true;
    }
}

Provide mapping as a service

The :class:`Symfony\\Component\\ObjectMapper\\ObjectMapperMetadataFactoryInterface` allows to change how mapping metadata is computed. With this interface we can create a MapStruct version of the Object Mapper:

// src/ObjectMapper/Metadata/MapStructMapperMetadataFactory.php
namespace App\Metadata\ObjectMapper;

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
use Symfony\Component\ObjectMapper\Metadata\Mapping;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

/**
 * A Metadata factory that implements the basics behind https://mapstruct.org/.
 */
final class MapStructMapperMetadataFactory implements ObjectMapperMetadataFactoryInterface
{
    public function __construct(private readonly string $mapper)
    {
        if (!is_a($mapper, ObjectMapperInterface::class, true)) {
            throw new \RuntimeException(sprintf('Mapper should implement "%s".', ObjectMapperInterface::class));
        }
    }

    public function create(object $object, ?string $property = null, array $context = []): array
    {
        $refl = new \ReflectionClass($this->mapper);
        $mapTo = [];
        $source = $property ?? $object::class;
        foreach (($property ? $refl->getMethod('map') : $refl)->getAttributes(Map::class) as $mappingAttribute) {
            $map = $mappingAttribute->newInstance();
            if ($map->source === $source) {
                $mapTo[] = new Mapping($map->source, $map->target, $map->if, $map->transform);

                continue;
            }
        }

        // Default is to map properties to a property of the same name
        if (!$mapTo && $property) {
            $mapTo[] = new Mapping($property, $property);
        }

        return $mapTo;
    }
}

With this metadata usage, the mapping definition can be written as a service:

// src/ObjectMapper/AToBMapper

namespace App\Metadata\ObjectMapper;

use App\Dto\Source;
use App\Dto\Target;
use Symfony\Component\ObjectMapper\Attributes\Map;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;


#[Map(source: Source::class, target: Target::class)]
class AToBMapper implements ObjectMapperInterface
{
    public function __construct(private readonly ObjectMapper $objectMapper)
    {
    }

    #[Map(source: 'propertyA', target: 'propertyD')]
    #[Map(source: 'propertyB', if: false)]
    public function map(object $source, object|string|null $target = null): object
    {
        return $this->objectMapper->map($source, $target);
    }
}

The custom metadata is injected into our :class:`Symfony\\Component\\ObjectMapper\\ObjectMapperInterface`:

$a = new Source('a', 'b', 'c');
$metadata = new MapStructMapperMetadataFactory(AToBMapper::class);
$mapper = new ObjectMapper($metadata);
$aToBMapper = new AToBMapper($mapper);
$b = $aToBMapper->map($a);