Skip to content

Commit 02d7362

Browse files
committed
[Object Mapper] component introduction
1 parent c9dc94b commit 02d7362

File tree

1 file changed

+346
-0
lines changed

1 file changed

+346
-0
lines changed

object-mapper.rst

+346
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
Object Mapper
2+
==============
3+
4+
Symfony provides a mapper to transform a given object to another one.
5+
This compoent is experimental.
6+
7+
Installation
8+
------------
9+
10+
Run this command to install the ``object-mapper`` before using it:
11+
12+
.. code-block:: terminal
13+
14+
$ composer require symfony/object-mapper
15+
16+
Using the ObjectMapper Service
17+
------------------------------
18+
19+
Once installed, the object mapper service can be injected in any service where
20+
you need it or it can be used in a controller::
21+
22+
// src/Controller/DefaultController.php
23+
namespace App\Controller;
24+
25+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
26+
use Symfony\Component\HttpFoundation\Response;
27+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
28+
29+
class DefaultController extends AbstractController
30+
{
31+
public function __invoke(ObjectMapperInterface $objectMapper): Response
32+
{
33+
// keep reading for usage examples
34+
}
35+
}
36+
37+
38+
Map an object to another one
39+
----------------------------
40+
41+
To map an object to another one use ``map``::
42+
43+
use App\Entity\Book;
44+
use App\ValueObject\Book as BookDto;
45+
46+
$book = $bookRepository->find(1);
47+
$mapper = new ObjectMapper();
48+
$mapper->map($book, BookDto::class);
49+
50+
51+
If you already have a target object, you can use its instance directly::
52+
53+
use App\Entity\Book;
54+
use App\ValueObject\Book as BookDto;
55+
56+
$bookDto = new BookDto(title: 'An updated title');
57+
$book = $bookRepository->find(1);
58+
$mapper = new ObjectMapper();
59+
$mapper->map($bookDto, $book);
60+
61+
The Object Mapper source can also be a `stdClass`::
62+
63+
use App\Entity\Book;
64+
65+
$bookDto = new \stdClass();
66+
$bookDto->title = 'An updated title';
67+
$mapper = new ObjectMapper();
68+
$mapper->map($bookDto, Book::class);
69+
70+
Configure the mapping target using attributes
71+
---------------------------------------------
72+
73+
The Object Mapper component includes a :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute to configure mapping
74+
behavior between objects. Use this attribute on a class to specify the
75+
target class::
76+
77+
// src/Dto/Source.php
78+
namespace App\Dto;
79+
80+
use Symfony\Component\ObjectMapper\Attributes\Map;
81+
82+
#[Map(target: Target::class)]
83+
class Source {}
84+
85+
Configure property mapping
86+
--------------------------
87+
88+
Use the :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute on properties to configure property mapping between
89+
objects. ``target`` changes the target property, ``if`` allows to
90+
conditionally map properties::
91+
92+
// src/Dto/Source.php
93+
namespace App\Dto;
94+
95+
use Symfony\Component\ObjectMapper\Attributes\Map;
96+
97+
class Source {
98+
#[Map(target: 'fullName')]
99+
public string $firstName;
100+
101+
// when we do not want to map the lastName we can use `false`
102+
#[Map(if: false)]
103+
public string $lastName;
104+
}
105+
106+
When a property is not present in the target class it will be ignored.
107+
108+
The condition mapping can also be configured as a service
109+
to do so implement a :class:`Symfony\\Component\\ObjectMapper\\ConditionCallableInterface`::
110+
111+
// src/ObjectMapper/ConditionNameCallable.php
112+
namespace App\ObjectMapper;
113+
114+
use App\Dto\Source;
115+
use Symfony\Component\ObjectMapper\ConditionCallableInterface;
116+
117+
/**
118+
* @implements ConditionCallableInterface<Source>
119+
*/
120+
final class ConditionNameCallable implements ConditionCallableInterface
121+
{
122+
public function __invoke(mixed $value, object $source): bool
123+
{
124+
return is_string($value);
125+
}
126+
}
127+
128+
// src/Dto/Source.php
129+
namespace App\Dto;
130+
131+
use App\ObjectMapper\ConditionNameCallable;
132+
use Symfony\Component\ObjectMapper\Attributes\Map;
133+
134+
class Source {
135+
#[Map(if: ConditionCallableInterface::class)]
136+
public mixed $status;
137+
}
138+
139+
Whe you have multiple mapping targets, you can also use the target class name as a condition for property mapping::
140+
141+
#[Map(target: B::class)]
142+
#[Map(target: C::class)]
143+
class A
144+
{
145+
// This will map to `foo` only when the target is of type B::class
146+
#[Map(target: 'somethingOnlyInB', transform: 'strtoupper', if: B::class)]
147+
public string $something = 'test';
148+
}
149+
150+
151+
Transform mapped values
152+
-----------------------
153+
154+
Use ``transform`` to call a static function or a
155+
:class:`Symfony\\Component\\ObjectMapper\\TransformCallableInterface`::
156+
157+
// src/ObjectMapper/TransformNameCallable.php
158+
namespace App\ObjectMapper;
159+
160+
use App\Dto\Source;
161+
use Symfony\Component\ObjectMapper\TransformCallableInterface;
162+
163+
/**
164+
* @implements TransformCallableInterface<Source>
165+
*/
166+
final class TransformNameCallable implements TransformCallableInterface
167+
{
168+
public function __invoke(mixed $value, object $source): mixed
169+
{
170+
return sprintf('%s %s', $source->firstName, $source->lastName);
171+
}
172+
}
173+
174+
// src/Dto/Source.php
175+
namespace App\Dto;
176+
177+
use App\ObjectMapper\TransformNameCallable;
178+
use Symfony\Component\ObjectMapper\Attributes\Map;
179+
180+
class Source {
181+
#[Map(target: 'fullName', transform: TransformNameCallable::class)]
182+
public string $firstName;
183+
}
184+
185+
We can also use a transformation mapping on a class, it should return the type of your mapping target::
186+
187+
// src/Dto/Source.php
188+
namespace App\Dto;
189+
190+
#[Map(transform: [Target::class, 'newInstance'])]
191+
class Source
192+
{
193+
public string $name = 'test';
194+
}
195+
196+
197+
// src/Dto/Target.php
198+
class Target
199+
{
200+
public ?string $name = null;
201+
202+
public function __construct(private readonly int $id)
203+
{
204+
}
205+
206+
public function getId(): int
207+
{
208+
return $this->id;
209+
}
210+
211+
public static function newInstance(): self
212+
{
213+
return new self(1);
214+
}
215+
}
216+
217+
218+
The ``if`` and ``transform`` parameters also accept static callbacks::
219+
220+
// src/Dto/Source.php
221+
namespace App\Dto;
222+
223+
use Symfony\Component\ObjectMapper\Attributes\Map;
224+
225+
class Source {
226+
#[Map(if: 'boolval', transform: 'ucfirst')]
227+
public ?string $lastName = null;
228+
}
229+
230+
The :class:`Symfony\\Component\\ObjectMapper\\Attribute\\Map` attribute works on
231+
classes and it can be repeated::
232+
233+
// src/Dto/Source.php
234+
namespace App\Dto;
235+
236+
use App\Dto\B;
237+
use App\Dto\C;
238+
use App\ObjectMapper\TransformNameCallable;
239+
use Symfony\Component\ObjectMapper\Attributes\Map;
240+
241+
#[Map(target: B::class, if: [Source::class, 'shouldMapToB'])]
242+
#[Map(target: C::class, if: [Source::class, 'shouldMapToC'])]
243+
class Source
244+
{
245+
/**
246+
* In case of a condition on a class, $value will be null
247+
*/
248+
public static function shouldMapToB(mixed $value, object $source): bool
249+
{
250+
return false;
251+
}
252+
253+
public static function shouldMapToC(mixed $value, object $source): bool
254+
{
255+
return true;
256+
}
257+
}
258+
259+
Provide mapping as a service
260+
----------------------------
261+
262+
The :class:`Symfony\\Component\\ObjectMapper\\ObjectMapperMetadataFactoryInterface` allows
263+
to change how mapping metadata is computed. With this interface we can create a
264+
`MapStruct`_ version of the Object Mapper::
265+
266+
// src/ObjectMapper/Metadata/MapStructMapperMetadataFactory.php
267+
namespace App\Metadata\ObjectMapper;
268+
269+
use Symfony\Component\ObjectMapper\Attribute\Map;
270+
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
271+
use Symfony\Component\ObjectMapper\Metadata\Mapping;
272+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
273+
274+
/**
275+
* A Metadata factory that implements the basics behind https://mapstruct.org/.
276+
*/
277+
final class MapStructMapperMetadataFactory implements ObjectMapperMetadataFactoryInterface
278+
{
279+
public function __construct(private readonly string $mapper)
280+
{
281+
if (!is_a($mapper, ObjectMapperInterface::class, true)) {
282+
throw new \RuntimeException(sprintf('Mapper should implement "%s".', ObjectMapperInterface::class));
283+
}
284+
}
285+
286+
public function create(object $object, ?string $property = null, array $context = []): array
287+
{
288+
$refl = new \ReflectionClass($this->mapper);
289+
$mapTo = [];
290+
$source = $property ?? $object::class;
291+
foreach (($property ? $refl->getMethod('map') : $refl)->getAttributes(Map::class) as $mappingAttribute) {
292+
$map = $mappingAttribute->newInstance();
293+
if ($map->source === $source) {
294+
$mapTo[] = new Mapping($map->source, $map->target, $map->if, $map->transform);
295+
296+
continue;
297+
}
298+
}
299+
300+
// Default is to map properties to a property of the same name
301+
if (!$mapTo && $property) {
302+
$mapTo[] = new Mapping($property, $property);
303+
}
304+
305+
return $mapTo;
306+
}
307+
}
308+
309+
With this metadata usage, the mapping definition can be written as a service::
310+
311+
// src/ObjectMapper/AToBMapper
312+
313+
namespace App\Metadata\ObjectMapper;
314+
315+
use App\Dto\Source;
316+
use App\Dto\Target;
317+
use Symfony\Component\ObjectMapper\Attributes\Map;
318+
use Symfony\Component\ObjectMapper\ObjectMapper;
319+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
320+
321+
322+
#[Map(source: Source::class, target: Target::class)]
323+
class AToBMapper implements ObjectMapperInterface
324+
{
325+
public function __construct(private readonly ObjectMapper $objectMapper)
326+
{
327+
}
328+
329+
#[Map(source: 'propertyA', target: 'propertyD')]
330+
#[Map(source: 'propertyB', if: false)]
331+
public function map(object $source, object|string|null $target = null): object
332+
{
333+
return $this->objectMapper->map($source, $target);
334+
}
335+
}
336+
337+
338+
The custom metadata is injected into our :class:`Symfony\\Component\\ObjectMapper\\ObjectMapperInterface`::
339+
340+
$a = new Source('a', 'b', 'c');
341+
$metadata = new MapStructMapperMetadataFactory(AToBMapper::class);
342+
$mapper = new ObjectMapper($metadata);
343+
$aToBMapper = new AToBMapper($mapper);
344+
$b = $aToBMapper->map($a);
345+
346+
.. _`MapStruct`: https://mapstruct.org/

0 commit comments

Comments
 (0)