diff --git a/object-mapper.rst b/object-mapper.rst new file mode 100644 index 00000000000..3cd958aab79 --- /dev/null +++ b/object-mapper.rst @@ -0,0 +1,346 @@ +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: + +.. code-block:: terminal + + $ 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 + */ + 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 + */ + 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); + +.. _`MapStruct`: https://mapstruct.org/