Skip to content

Commit 3488fd9

Browse files
committed
[AutoMapper] Object to Object mapper component
1 parent f3c9644 commit 3488fd9

File tree

19 files changed

+494
-0
lines changed

19 files changed

+494
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.gitattributes export-ignore
4+
/.gitignore export-ignore
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper\Attributes;
4+
5+
use Attribute;
6+
7+
/**
8+
* Condition whether to map a property.
9+
*
10+
* @experimental
11+
* @author Antoine Bluchet <soyuka@gmail.com>
12+
*/
13+
#[Attribute(Attribute::TARGET_PROPERTY)]
14+
final class MapIf
15+
{
16+
/**
17+
* @param string|callable(mixed $value, object $object): bool $if
18+
*/
19+
public function __construct(public readonly string|callable $if)
20+
{
21+
}
22+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper\Attributes;
4+
5+
use Attribute;
6+
7+
/**
8+
* Configures a class or a property to map to.
9+
*
10+
* @experimental
11+
* @author Antoine Bluchet <soyuka@gmail.com>
12+
*/
13+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
14+
final class MapTo
15+
{
16+
public function __construct(public readonly string $to)
17+
{
18+
}
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper\Attributes;
4+
5+
use Attribute;
6+
7+
/**
8+
* Transforms the mapped value with a function.
9+
*
10+
* @experimental
11+
* @author Antoine Bluchet <soyuka@gmail.com>
12+
*/
13+
#[Attribute(Attribute::TARGET_PROPERTY)]
14+
final class MapWith
15+
{
16+
/**
17+
* @param string|callable(mixed $value, object $object): bool $with
18+
*/
19+
public function __construct(public readonly callable|string $with)
20+
{
21+
}
22+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper;
4+
5+
use Psr\Container\ContainerInterface;
6+
use Symfony\Component\AutoMapper\Exception\RuntimeException;
7+
use Symfony\Component\AutoMapper\Attributes\MapIf;
8+
use Symfony\Component\AutoMapper\Attributes\MapTo;
9+
use Symfony\Component\AutoMapper\Attributes\MapWith;
10+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
11+
12+
/**
13+
* Object to object mapper.
14+
*
15+
* @experimental
16+
* @author Antoine Bluchet <soyuka@gmail.com>
17+
*/
18+
final class AutoMapper implements AutoMapperInterface
19+
{
20+
public function __construct(private readonly ?PropertyAccessorInterface $propertyAccessor = null, private readonly ?ContainerInterface $serviceLocator = null)
21+
{
22+
}
23+
24+
/**
25+
* {@inheritdoc}
26+
*/
27+
public function map(object $object, object|string $to = null): mixed
28+
{
29+
$refl = new \ReflectionClass($object);
30+
31+
if (!$to) {
32+
$to = $this->getAttribute($refl, MapTo::class, true)->to;
33+
}
34+
35+
$arguments = [];
36+
if (is_object($to)) {
37+
$toRefl = new \ReflectionClass(get_class($to));
38+
$mapped = $to;
39+
} else {
40+
$toRefl = new \ReflectionClass($to);
41+
$mapped = $toRefl->newInstanceWithoutConstructor();
42+
}
43+
44+
$constructor = $toRefl->getConstructor();
45+
foreach ($constructor?->getParameters() ?? [] as $parameter) {
46+
$arguments[$parameter->getName()] = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
47+
}
48+
49+
foreach ($refl->getProperties() as $property) {
50+
if ($property->isStatic() {
51+
continue;
52+
}
53+
$propertyName = $property->getName();
54+
$mapTo = $this->getAttribute($property, MapTo::class)?->to ?? $propertyName;
55+
if (!$toRefl->hasProperty($mapTo)) {
56+
continue;
57+
}
58+
59+
$value = $this->propertyAccessor ? $this->propertyAccessor->getValue($object, $propertyName) : $object->{$propertyName};
60+
$mapIf = $this->getCallable($this->getAttribute($property, MapIf::class)?->if);
61+
if (is_callable($mapIf) && false === $this->call($mapIf, $value, $object)) {
62+
continue;
63+
}
64+
65+
$mapWith = $this->getCallable($this->getAttribute($property, MapWith::class)?->with);
66+
if (is_callable($mapWith)) {
67+
$value = $this->call($mapWith, $value, $object);
68+
}
69+
70+
if (is_object($value) && $to = $this->getAttribute(new \ReflectionClass($value), MapTo::class)?->to) {
71+
$value = $this->map($value, $to);
72+
}
73+
74+
if (array_key_exists($mapTo, $arguments)) {
75+
$arguments[$mapTo] = $value;
76+
} else {
77+
$this->propertyAccessor ? $this->propertyAccessor->setValue($mapped, $mapTo, $value) : ($mapped->{$mapTo} = $value);
78+
}
79+
}
80+
81+
$constructor->invokeArgs($mapped, $arguments);
82+
83+
return $mapped;
84+
}
85+
86+
private function call(callable $fn, mixed $value, object $object): mixed
87+
{
88+
$refl = new \ReflectionFunction(\Closure::fromCallable($fn));
89+
$withParameters = $refl->getParameters();
90+
$withArgs = [$value];
91+
92+
// Let's not send object if we don't need to, gives the ability to call native functions
93+
foreach ($withParameters as $parameter) {
94+
if ($parameter->getName() === 'object') {
95+
$withArgs['object'] = $object;
96+
break;
97+
}
98+
}
99+
100+
return call_user_func_array($fn, $withArgs);
101+
}
102+
103+
private function getCallable(string|callable|null $fn): ?callable
104+
{
105+
if ($this->serviceLocator && is_string($fn)) {
106+
return $this->serviceLocator->get($fn);
107+
}
108+
109+
return $fn;
110+
}
111+
112+
/**
113+
* @param class-string $name
114+
*/
115+
private function getAttribute(mixed $refl, string $name, bool $throw = false): mixed
116+
{
117+
$a = $refl->getAttributes($name)[0] ?? null;
118+
119+
if ($throw && !$a) {
120+
throw new RuntimeException(sprintf('Attribute of type "%s" expected on "%s.', $name, $refl->getName()));
121+
}
122+
123+
return $a ? $a->newInstance() : $a;
124+
}
125+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper;
4+
5+
/**
6+
* Object to object mapper.
7+
*
8+
* @experimental
9+
* @author Antoine Bluchet <soyuka@gmail.com>
10+
*/
11+
interface AutoMapperInterface {
12+
/**
13+
* @param object $object The object to map from
14+
* @param null|class-string|object $to The object or class to map to
15+
*/
16+
public function map(object $object, object|string|null $to = null): mixed;
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
6.4
5+
---
6+
7+
* Automapper component
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper\Exception;
4+
5+
/**
6+
* @experimental
7+
* @author Antoine Bluchet <soyuka@gmail.com>
8+
*/
9+
interface ExceptionInterface extends \Throwable
10+
{
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Symfony\Component\AutoMapper\Exception;
4+
5+
/**
6+
* @experimental
7+
* @author Antoine Bluchet <soyuka@gmail.com>
8+
*/
9+
class RuntimeException extends \RuntimeException implements ExceptionInterface
10+
{
11+
}

0 commit comments

Comments
 (0)