From be78e12ca5a0e6678769ae78399e2636f4f45f07 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 2 Dec 2025 17:40:57 +0100 Subject: [PATCH 1/2] wip --- composer.json | 1 + src/ObjectMapper/ObjectMapper.php | 28 + tests/ObjectMapper/Fixtures/A.php | 48 ++ tests/ObjectMapper/Fixtures/B.php | 26 + tests/ObjectMapper/Fixtures/C.php | 22 + .../Fixtures/ClassWithoutTarget.php | 16 + tests/ObjectMapper/Fixtures/D.php | 19 + .../Fixtures/DeeperRecursion/Recursive.php | 21 + .../Fixtures/DeeperRecursion/RecursiveDto.php | 18 + .../Fixtures/DeeperRecursion/Relation.php | 20 + .../Fixtures/DeeperRecursion/RelationDto.php | 17 + .../Fixtures/DefaultLazy/OrderSource.php | 12 + .../Fixtures/DefaultLazy/OrderTarget.php | 9 + .../Fixtures/DefaultLazy/UserSource.php | 11 + .../Fixtures/DefaultLazy/UserTarget.php | 8 + .../DefaultValueStdClass/TargetDto.php | 20 + .../Fixtures/EmbeddedMapping/Address.php | 9 + .../Fixtures/EmbeddedMapping/User.php | 14 + .../Fixtures/EmbeddedMapping/UserDto.php | 17 + .../Fixtures/Flatten/TargetUser.php | 19 + tests/ObjectMapper/Fixtures/Flatten/User.php | 26 + .../Fixtures/Flatten/UserProfile.php | 29 + .../Fixtures/HydrateObject/SourceOnly.php | 23 + .../Fixtures/InitializedConstructor/A.php | 17 + .../Fixtures/InitializedConstructor/B.php | 31 + .../Fixtures/InitializedConstructor/C.php | 22 + .../Fixtures/InitializedConstructor/D.php | 25 + .../Fixtures/InstanceCallback/A.php | 20 + .../Fixtures/InstanceCallback/B.php | 31 + .../InstanceCallbackWithArguments/A.php | 19 + .../InstanceCallbackWithArguments/B.php | 27 + tests/ObjectMapper/Fixtures/LazyFoo.php | 38 + .../Fixtures/MapStruct/AToBMapper.php | 30 + tests/ObjectMapper/Fixtures/MapStruct/Map.php | 19 + .../MapStructMapperMetadataFactory.php | 53 ++ .../Fixtures/MapStruct/Source.php | 19 + .../Fixtures/MapStruct/Target.php | 19 + .../Fixtures/MapTargetToSource/A.php | 19 + .../Fixtures/MapTargetToSource/B.php | 22 + .../Fixtures/MultipleTargetProperty/A.php | 26 + .../Fixtures/MultipleTargetProperty/B.php | 17 + .../Fixtures/MultipleTargetProperty/C.php | 19 + .../Fixtures/MultipleTargets/A.php | 33 + .../Fixtures/MultipleTargets/B.php | 16 + .../Fixtures/MultipleTargets/C.php | 19 + tests/ObjectMapper/Fixtures/MyProxy.php | 17 + .../Fixtures/PartialInput/FinalInput.php | 11 + .../Fixtures/PartialInput/PartialInput.php | 14 + .../Fixtures/PromotedConstructor/Source.php | 21 + .../Fixtures/PromotedConstructor/Target.php | 21 + .../Source.php | 24 + .../Target.php | 26 + .../ReadOnlyPromotedPropertyA.php | 15 + .../ReadOnlyPromotedPropertyAMapped.php | 14 + .../ReadOnlyPromotedPropertyB.php | 14 + .../ReadOnlyPromotedPropertyBMapped.php | 13 + tests/ObjectMapper/Fixtures/Recursion/AB.php | 21 + tests/ObjectMapper/Fixtures/Recursion/Dto.php | 17 + .../ServiceLoadedValue/LoadedValue.php | 19 + .../ServiceLoadedValue/LoadedValueService.php | 29 + .../ServiceLoadedValue/LoadedValueTarget.php | 19 + .../ServiceLoadedValueTransformer.php | 33 + .../ServiceLoadedValue/ValueToMap.php | 11 + .../ServiceLoadedValue/ValueToMapRelation.php | 13 + .../Fixtures/ServiceLocator/A.php | 21 + .../Fixtures/ServiceLocator/B.php | 17 + .../ServiceLocator/ConditionCallable.php | 25 + .../ServiceLocator/TransformCallable.php | 25 + .../Fixtures/TargetTransform/SourceEntity.php | 17 + .../Fixtures/TargetTransform/TargetDto.php | 29 + .../TransformCollectionA.php | 13 + .../TransformCollectionB.php | 9 + .../TransformCollectionC.php | 15 + .../TransformCollectionD.php | 11 + tests/ObjectMapper/ObjectMapperTest.php | 657 ++++++++++++++++++ 75 files changed, 2165 insertions(+) create mode 100644 src/ObjectMapper/ObjectMapper.php create mode 100644 tests/ObjectMapper/Fixtures/A.php create mode 100644 tests/ObjectMapper/Fixtures/B.php create mode 100644 tests/ObjectMapper/Fixtures/C.php create mode 100644 tests/ObjectMapper/Fixtures/ClassWithoutTarget.php create mode 100644 tests/ObjectMapper/Fixtures/D.php create mode 100644 tests/ObjectMapper/Fixtures/DeeperRecursion/Recursive.php create mode 100644 tests/ObjectMapper/Fixtures/DeeperRecursion/RecursiveDto.php create mode 100644 tests/ObjectMapper/Fixtures/DeeperRecursion/Relation.php create mode 100644 tests/ObjectMapper/Fixtures/DeeperRecursion/RelationDto.php create mode 100644 tests/ObjectMapper/Fixtures/DefaultLazy/OrderSource.php create mode 100644 tests/ObjectMapper/Fixtures/DefaultLazy/OrderTarget.php create mode 100644 tests/ObjectMapper/Fixtures/DefaultLazy/UserSource.php create mode 100644 tests/ObjectMapper/Fixtures/DefaultLazy/UserTarget.php create mode 100644 tests/ObjectMapper/Fixtures/DefaultValueStdClass/TargetDto.php create mode 100644 tests/ObjectMapper/Fixtures/EmbeddedMapping/Address.php create mode 100644 tests/ObjectMapper/Fixtures/EmbeddedMapping/User.php create mode 100644 tests/ObjectMapper/Fixtures/EmbeddedMapping/UserDto.php create mode 100644 tests/ObjectMapper/Fixtures/Flatten/TargetUser.php create mode 100644 tests/ObjectMapper/Fixtures/Flatten/User.php create mode 100644 tests/ObjectMapper/Fixtures/Flatten/UserProfile.php create mode 100644 tests/ObjectMapper/Fixtures/HydrateObject/SourceOnly.php create mode 100644 tests/ObjectMapper/Fixtures/InitializedConstructor/A.php create mode 100644 tests/ObjectMapper/Fixtures/InitializedConstructor/B.php create mode 100644 tests/ObjectMapper/Fixtures/InitializedConstructor/C.php create mode 100644 tests/ObjectMapper/Fixtures/InitializedConstructor/D.php create mode 100644 tests/ObjectMapper/Fixtures/InstanceCallback/A.php create mode 100644 tests/ObjectMapper/Fixtures/InstanceCallback/B.php create mode 100644 tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/A.php create mode 100644 tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/B.php create mode 100644 tests/ObjectMapper/Fixtures/LazyFoo.php create mode 100644 tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php create mode 100644 tests/ObjectMapper/Fixtures/MapStruct/Map.php create mode 100644 tests/ObjectMapper/Fixtures/MapStruct/MapStructMapperMetadataFactory.php create mode 100644 tests/ObjectMapper/Fixtures/MapStruct/Source.php create mode 100644 tests/ObjectMapper/Fixtures/MapStruct/Target.php create mode 100644 tests/ObjectMapper/Fixtures/MapTargetToSource/A.php create mode 100644 tests/ObjectMapper/Fixtures/MapTargetToSource/B.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargetProperty/A.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargetProperty/B.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargetProperty/C.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargets/A.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargets/B.php create mode 100644 tests/ObjectMapper/Fixtures/MultipleTargets/C.php create mode 100644 tests/ObjectMapper/Fixtures/MyProxy.php create mode 100644 tests/ObjectMapper/Fixtures/PartialInput/FinalInput.php create mode 100644 tests/ObjectMapper/Fixtures/PartialInput/PartialInput.php create mode 100644 tests/ObjectMapper/Fixtures/PromotedConstructor/Source.php create mode 100644 tests/ObjectMapper/Fixtures/PromotedConstructor/Target.php create mode 100644 tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Source.php create mode 100644 tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Target.php create mode 100644 tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyA.php create mode 100644 tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyAMapped.php create mode 100644 tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyB.php create mode 100644 tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyBMapped.php create mode 100644 tests/ObjectMapper/Fixtures/Recursion/AB.php create mode 100644 tests/ObjectMapper/Fixtures/Recursion/Dto.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValue.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueService.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueTarget.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/ServiceLoadedValueTransformer.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMap.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMapRelation.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLocator/A.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLocator/B.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLocator/ConditionCallable.php create mode 100644 tests/ObjectMapper/Fixtures/ServiceLocator/TransformCallable.php create mode 100644 tests/ObjectMapper/Fixtures/TargetTransform/SourceEntity.php create mode 100644 tests/ObjectMapper/Fixtures/TargetTransform/TargetDto.php create mode 100644 tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionA.php create mode 100644 tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionB.php create mode 100644 tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionC.php create mode 100644 tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionD.php create mode 100644 tests/ObjectMapper/ObjectMapperTest.php diff --git a/composer.json b/composer.json index 4a6a525a..15cc3cc4 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "symfony/framework-bundle": "^7.4 || ^8.0", "symfony/http-client": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", "symfony/phpunit-bridge": "^8.0", "symfony/serializer": "^7.4 || ^8.0", "symfony/stopwatch": "^7.4 || ^8.0", diff --git a/src/ObjectMapper/ObjectMapper.php b/src/ObjectMapper/ObjectMapper.php new file mode 100644 index 00000000..23b560b5 --- /dev/null +++ b/src/ObjectMapper/ObjectMapper.php @@ -0,0 +1,28 @@ +autoMapper ??= AutoMapper::create(); + } + + public function map(object $source, object|string|null $target = null): object + { + if ($target === null) { + // @TODO get the target class from attributes + } + + return $this->autoMapper->map($source, $target); + } +} \ No newline at end of file diff --git a/tests/ObjectMapper/Fixtures/A.php b/tests/ObjectMapper/Fixtures/A.php new file mode 100644 index 00000000..89211563 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/A.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(B::class)] +class A +{ + #[Map('bar')] + public string $foo; + + public string $baz; + + public string $notinb; + + #[Map(transform: 'strtoupper')] + public string $transform; + + #[Map(transform: [self::class, 'concatFn'])] + public ?string $concat = null; + + #[Map(if: 'boolval')] + public bool $nomap = false; + + public C $relation; + + public D $relationNotMapped; + + public function getConcat() + { + return 'should'; + } + + public static function concatFn($v, $object): string + { + return $v.$object->foo.$object->baz; + } +} diff --git a/tests/ObjectMapper/Fixtures/B.php b/tests/ObjectMapper/Fixtures/B.php new file mode 100644 index 00000000..8be9616f --- /dev/null +++ b/tests/ObjectMapper/Fixtures/B.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +class B +{ + public function __construct(private string $bar) + { + } + public string $baz; + public string $transform; + public string $concat; + public bool $nomap = true; + public int $id; + public D $relation; + public D $relationNotMapped; +} diff --git a/tests/ObjectMapper/Fixtures/C.php b/tests/ObjectMapper/Fixtures/C.php new file mode 100644 index 00000000..f9f7f119 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/C.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(D::class)] +class C +{ + public function __construct(#[Map('baz')] public readonly string $foo, #[Map('bat')] public readonly string $bar) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/ClassWithoutTarget.php b/tests/ObjectMapper/Fixtures/ClassWithoutTarget.php new file mode 100644 index 00000000..d7e3c741 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ClassWithoutTarget.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +class ClassWithoutTarget +{ +} diff --git a/tests/ObjectMapper/Fixtures/D.php b/tests/ObjectMapper/Fixtures/D.php new file mode 100644 index 00000000..c4bb48df --- /dev/null +++ b/tests/ObjectMapper/Fixtures/D.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +class D +{ + public function __construct(public string $baz, public string $bat) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/DeeperRecursion/Recursive.php b/tests/ObjectMapper/Fixtures/DeeperRecursion/Recursive.php new file mode 100644 index 00000000..f4789861 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/Recursive.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(RecursiveDto::class)] +class Recursive +{ + public string $name; + public Relation $relation; +} diff --git a/tests/ObjectMapper/Fixtures/DeeperRecursion/RecursiveDto.php b/tests/ObjectMapper/Fixtures/DeeperRecursion/RecursiveDto.php new file mode 100644 index 00000000..20f7b398 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/RecursiveDto.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion; + +class RecursiveDto +{ + public string $name; + public RelationDto $relation; +} diff --git a/tests/ObjectMapper/Fixtures/DeeperRecursion/Relation.php b/tests/ObjectMapper/Fixtures/DeeperRecursion/Relation.php new file mode 100644 index 00000000..9896cee7 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/Relation.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(RelationDto::class)] +class Relation +{ + public Recursive $recursion; +} diff --git a/tests/ObjectMapper/Fixtures/DeeperRecursion/RelationDto.php b/tests/ObjectMapper/Fixtures/DeeperRecursion/RelationDto.php new file mode 100644 index 00000000..2e14b736 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/RelationDto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion; + +class RelationDto +{ + public RecursiveDto $recursion; +} diff --git a/tests/ObjectMapper/Fixtures/DefaultLazy/OrderSource.php b/tests/ObjectMapper/Fixtures/DefaultLazy/OrderSource.php new file mode 100644 index 00000000..c5e1a093 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DefaultLazy/OrderSource.php @@ -0,0 +1,12 @@ +address = new Address(); + } +} diff --git a/tests/ObjectMapper/Fixtures/EmbeddedMapping/UserDto.php b/tests/ObjectMapper/Fixtures/EmbeddedMapping/UserDto.php new file mode 100644 index 00000000..19bfd364 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/EmbeddedMapping/UserDto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\Flatten; + +class TargetUser +{ + public string $firstName; + public string $lastName; + public string $email; +} diff --git a/tests/ObjectMapper/Fixtures/Flatten/User.php b/tests/ObjectMapper/Fixtures/Flatten/User.php new file mode 100644 index 00000000..53fdede5 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Flatten/User.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\Flatten; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: TargetUser::class)] +readonly class User +{ + public function __construct( + #[Map(transform: [UserProfile::class, 'getFirstName'], target: 'firstName')] + #[Map(transform: [UserProfile::class, 'getLastName'], target: 'lastName')] + public UserProfile $profile, + public string $email, + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/Flatten/UserProfile.php b/tests/ObjectMapper/Fixtures/Flatten/UserProfile.php new file mode 100644 index 00000000..9982b6ea --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Flatten/UserProfile.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\Flatten; + +readonly class UserProfile +{ + public function __construct(public string $firstName, public string $lastName) + { + } + + public static function getFirstName($v, $object) + { + return $v->firstName; + } + + public static function getLastName($v, $object) + { + return $v->lastName; + } +} diff --git a/tests/ObjectMapper/Fixtures/HydrateObject/SourceOnly.php b/tests/ObjectMapper/Fixtures/HydrateObject/SourceOnly.php new file mode 100644 index 00000000..96cbbe3b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/HydrateObject/SourceOnly.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\HydrateObject; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +class SourceOnly +{ + public function __construct( + #[Map(source: 'name')] public string $mappedName, + #[Map(if: false)] public ?string $mappedDescription = null + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/InitializedConstructor/A.php b/tests/ObjectMapper/Fixtures/InitializedConstructor/A.php new file mode 100644 index 00000000..4c286120 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/A.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor; + +class A +{ + public array $tags = ['foo', 'bar']; +} diff --git a/tests/ObjectMapper/Fixtures/InitializedConstructor/B.php b/tests/ObjectMapper/Fixtures/InitializedConstructor/B.php new file mode 100644 index 00000000..66661020 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/B.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor; + +class B +{ + public array $tags; + + public function __construct() + { + $this->tags = []; + } + + public function addTag($tag) + { + $this->tags[] = $tag; + } + + public function removeTag($tag) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/InitializedConstructor/C.php b/tests/ObjectMapper/Fixtures/InitializedConstructor/C.php new file mode 100644 index 00000000..17e934f7 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/C.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor; + +class C +{ + public string $bar; + + public function __construct(string $bar) + { + $this->bar = $bar; + } +} diff --git a/tests/ObjectMapper/Fixtures/InitializedConstructor/D.php b/tests/ObjectMapper/Fixtures/InitializedConstructor/D.php new file mode 100644 index 00000000..d7a36291 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/D.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +class D +{ + #[Map(if: false)] + public string $barUpperCase; + + public function __construct(string $bar) + { + $this->barUpperCase = strtoupper($bar); + } +} diff --git a/tests/ObjectMapper/Fixtures/InstanceCallback/A.php b/tests/ObjectMapper/Fixtures/InstanceCallback/A.php new file mode 100644 index 00000000..f0a329bb --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallback/A.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallback; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(transform: [B::class, 'newInstance'])] +class A +{ + public string $name = 'test'; +} diff --git a/tests/ObjectMapper/Fixtures/InstanceCallback/B.php b/tests/ObjectMapper/Fixtures/InstanceCallback/B.php new file mode 100644 index 00000000..ba6919e3 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallback/B.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallback; + +class B +{ + 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); + } +} diff --git a/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/A.php b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/A.php new file mode 100644 index 00000000..fe7c8531 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/A.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallbackWithArguments; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: B::class, transform: [B::class, 'newInstance'])] +class A +{ +} diff --git a/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/B.php b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/B.php new file mode 100644 index 00000000..044d57f9 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/B.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallbackWithArguments; + +class B +{ + public mixed $transformValue; + public object $transformSource; + + public static function newInstance(mixed $value, object $source): self + { + $b = new self(); + $b->transformValue = $value; + $b->transformSource = $source; + + return $b; + } +} diff --git a/tests/ObjectMapper/Fixtures/LazyFoo.php b/tests/ObjectMapper/Fixtures/LazyFoo.php new file mode 100644 index 00000000..7bf9449b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/LazyFoo.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +use Symfony\Component\VarExporter\LazyObjectInterface; + +class LazyFoo extends \stdClass implements LazyObjectInterface +{ + private bool $initialized = false; + + public function isLazyObjectInitialized(bool $partial = false): bool + { + return $this->initialized; + } + + public function initializeLazyObject(): object + { + $this->initialized = true; + + return $this; + } + + public function resetLazyObject(): bool + { + $this->initialized = false; + + return true; + } +} diff --git a/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php b/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php new file mode 100644 index 00000000..66608764 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; + +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); + } +} diff --git a/tests/ObjectMapper/Fixtures/MapStruct/Map.php b/tests/ObjectMapper/Fixtures/MapStruct/Map.php new file mode 100644 index 00000000..ad4f18e1 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/Map.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; + +use Symfony\Component\ObjectMapper\Attribute\Map as AttributeMap; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Map extends AttributeMap +{ +} diff --git a/tests/ObjectMapper/Fixtures/MapStruct/MapStructMapperMetadataFactory.php b/tests/ObjectMapper/Fixtures/MapStruct/MapStructMapperMetadataFactory.php new file mode 100644 index 00000000..adf19243 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/MapStructMapperMetadataFactory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; + +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * A Metadata factory that implements the basics behind https://mapstruct.org/. + * + * @author Antoine Bluchet + */ +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(source: $map->source, target: $map->target, if: $map->if, transform: $map->transform); + + continue; + } + } + + // Default is to map properties to a property of the same name + if (!$mapTo && $property) { + $mapTo[] = new Mapping(source: $property, target: $property); + } + + return $mapTo; + } +} diff --git a/tests/ObjectMapper/Fixtures/MapStruct/Source.php b/tests/ObjectMapper/Fixtures/MapStruct/Source.php new file mode 100644 index 00000000..05e4d319 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/Source.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; + +class Source +{ + public function __construct(public readonly string $propertyA, public readonly string $propertyB, public readonly string $propertyC) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/MapStruct/Target.php b/tests/ObjectMapper/Fixtures/MapStruct/Target.php new file mode 100644 index 00000000..fc9dece4 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/Target.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; + +class Target +{ + public string $propertyC; + // should be mapped from A + public string $propertyD; +} diff --git a/tests/ObjectMapper/Fixtures/MapTargetToSource/A.php b/tests/ObjectMapper/Fixtures/MapTargetToSource/A.php new file mode 100644 index 00000000..a38e2717 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapTargetToSource/A.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapTargetToSource; + +class A +{ + public function __construct(public string $source) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/MapTargetToSource/B.php b/tests/ObjectMapper/Fixtures/MapTargetToSource/B.php new file mode 100644 index 00000000..5cade804 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapTargetToSource/B.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapTargetToSource; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(source: A::class)] +class B +{ + public function __construct(#[Map(source: 'source')] public string $target) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargetProperty/A.php b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/A.php new file mode 100644 index 00000000..ae4bb226 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/A.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty; + +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Condition\TargetClass; + +#[Map(target: B::class)] +#[Map(target: C::class)] +class A +{ + #[Map(target: 'foo', transform: 'strtoupper', if: new TargetClass(B::class))] + #[Map(target: 'bar')] + public string $something = 'test'; + + public string $doesNotExistInTargetB = 'foo'; +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargetProperty/B.php b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/B.php new file mode 100644 index 00000000..feaf61ab --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/B.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty; + +class B +{ + public string $foo; +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargetProperty/C.php b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/C.php new file mode 100644 index 00000000..1b4baf2c --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/C.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty; + +class C +{ + public string $foo = 'donotmap'; + public string $bar; + public string $doesNotExistInTargetB; +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargets/A.php b/tests/ObjectMapper/Fixtures/MultipleTargets/A.php new file mode 100644 index 00000000..9e3f6cbc --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/A.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargets; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: B::class, if: [A::class, 'shouldMapToB'])] +#[Map(target: C::class, if: [A::class, 'shouldMapToC'])] +class A +{ + public function __construct(public readonly string $foo = 'bar') + { + } + + public static function shouldMapToB(mixed $value, object $object): bool + { + return false; + } + + public static function shouldMapToC(mixed $value, object $object): bool + { + return true; + } +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargets/B.php b/tests/ObjectMapper/Fixtures/MultipleTargets/B.php new file mode 100644 index 00000000..d1d91067 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/B.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargets; + +class B +{ +} diff --git a/tests/ObjectMapper/Fixtures/MultipleTargets/C.php b/tests/ObjectMapper/Fixtures/MultipleTargets/C.php new file mode 100644 index 00000000..cc105160 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/C.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargets; + +class C +{ + public function __construct(public readonly string $foo) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/MyProxy.php b/tests/ObjectMapper/Fixtures/MyProxy.php new file mode 100644 index 00000000..65edc68a --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MyProxy.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures; + +class MyProxy +{ + public string $name; +} diff --git a/tests/ObjectMapper/Fixtures/PartialInput/FinalInput.php b/tests/ObjectMapper/Fixtures/PartialInput/FinalInput.php new file mode 100644 index 00000000..fbeaa71e --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PartialInput/FinalInput.php @@ -0,0 +1,11 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructor; + +class Source +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/PromotedConstructor/Target.php b/tests/ObjectMapper/Fixtures/PromotedConstructor/Target.php new file mode 100644 index 00000000..f4c19c68 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructor/Target.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructor; + +class Target +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Source.php b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Source.php new file mode 100644 index 00000000..c72ec109 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Source.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructorWithMetadata; + +use AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct\Map; + +#[Map(target: Target::class)] +class Source +{ + public function __construct( + public int $number, + public string $name, + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Target.php b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Target.php new file mode 100644 index 00000000..95d61068 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Target.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructorWithMetadata; + +class Target +{ + public function __construct( + /** + * This promoted property is required but should not lead to an exception on the object mapping as instantiation + * happened earlier already. + */ + public string $notOnSourceButRequired, + public int $number, + public string $name, + ) { + } +} diff --git a/tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyA.php b/tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyA.php new file mode 100644 index 00000000..00fa4c24 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyA.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\Recursion; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(Dto::class)] +class AB +{ + #[Map('dto')] + public AB $ab; +} diff --git a/tests/ObjectMapper/Fixtures/Recursion/Dto.php b/tests/ObjectMapper/Fixtures/Recursion/Dto.php new file mode 100644 index 00000000..caa3c6a2 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Recursion/Dto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\Recursion; + +class Dto +{ + public Dto $dto; +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValue.php b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValue.php new file mode 100644 index 00000000..96b4dac5 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValue.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue; + +class LoadedValue +{ + public function __construct(public string $name) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueService.php b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueService.php new file mode 100644 index 00000000..33c346ae --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueService.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue; + +class LoadedValueService +{ + public function __construct(private ?LoadedValue $value = null) + { + } + + public function load(): void + { + $this->value = new LoadedValue(name: 'loaded'); + } + + public function get(): LoadedValue + { + return $this->value; + } +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueTarget.php b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueTarget.php new file mode 100644 index 00000000..fe5c5ebf --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueTarget.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue; + +class LoadedValueTarget +{ + public function __construct(public ?LoadedValue $relation = null) + { + } +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ServiceLoadedValueTransformer.php b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ServiceLoadedValueTransformer.php new file mode 100644 index 00000000..a52d1c3a --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ServiceLoadedValueTransformer.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue; + +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * @implements TransformCallableInterface + */ +class ServiceLoadedValueTransformer implements TransformCallableInterface +{ + public function __construct(private readonly LoadedValueService $serviceLoadedValue, private readonly ObjectMapperMetadataFactoryInterface $metadata) + { + } + + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + $metadata = $this->metadata->create($value); + \assert(count($metadata) === 1); + \assert($metadata[0]->target === LoadedValue::class); + return $this->serviceLoadedValue->get(); + } +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMap.php b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMap.php new file mode 100644 index 00000000..28fc5959 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMap.php @@ -0,0 +1,11 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(B::class)] +class A +{ + #[Map(target: 'bar', transform: TransformCallable::class, if: ConditionCallable::class)] + public string $foo; +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLocator/B.php b/tests/ObjectMapper/Fixtures/ServiceLocator/B.php new file mode 100644 index 00000000..e6d0621b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/B.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator; + +class B +{ + public string $bar = 'notmapped'; +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLocator/ConditionCallable.php b/tests/ObjectMapper/Fixtures/ServiceLocator/ConditionCallable.php new file mode 100644 index 00000000..fb856746 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/ConditionCallable.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\ConditionCallableInterface; + +/** + * @implements ConditionCallableInterface + */ +class ConditionCallable implements ConditionCallableInterface +{ + public function __invoke(mixed $value, object $source, ?object $target): bool + { + return 'ok' === $value; + } +} diff --git a/tests/ObjectMapper/Fixtures/ServiceLocator/TransformCallable.php b/tests/ObjectMapper/Fixtures/ServiceLocator/TransformCallable.php new file mode 100644 index 00000000..87ff7825 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/TransformCallable.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator; + +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * @implements TransformCallableInterface + */ +class TransformCallable implements TransformCallableInterface +{ + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + return "transformed$value"; + } +} diff --git a/tests/ObjectMapper/Fixtures/TargetTransform/SourceEntity.php b/tests/ObjectMapper/Fixtures/TargetTransform/SourceEntity.php new file mode 100644 index 00000000..54a7b96c --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TargetTransform/SourceEntity.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform; + +class SourceEntity +{ + public string $name; +} diff --git a/tests/ObjectMapper/Fixtures/TargetTransform/TargetDto.php b/tests/ObjectMapper/Fixtures/TargetTransform/TargetDto.php new file mode 100644 index 00000000..30ad5322 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TargetTransform/TargetDto.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(source: SourceEntity::class, transform: [self::class, 't'])] +class TargetDto +{ + #[Map(if: false)] + public bool $transformed; + public string $name; + + public static function t(mixed $value, object $source, ?object $target) + { + $value->transformed = true; + + return $value; + } +} diff --git a/tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionA.php b/tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionA.php new file mode 100644 index 00000000..20dc8bbc --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionA.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AutoMapper\Tests\ObjectMapper; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; +use Symfony\Component\ObjectMapper\Exception\NoSuchPropertyException; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\ObjectMapper\ObjectMapper; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use AutoMapper\Tests\ObjectMapper\Fixtures\A; +use AutoMapper\Tests\ObjectMapper\Fixtures\B; +use AutoMapper\Tests\ObjectMapper\Fixtures\C; +use AutoMapper\Tests\ObjectMapper\Fixtures\ClassWithoutTarget; +use AutoMapper\Tests\ObjectMapper\Fixtures\D; +use AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion\Recursive; +use AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion\RecursiveDto; +use AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion\Relation; +use AutoMapper\Tests\ObjectMapper\Fixtures\DeeperRecursion\RelationDto; +use AutoMapper\Tests\ObjectMapper\Fixtures\DefaultLazy\OrderSource; +use AutoMapper\Tests\ObjectMapper\Fixtures\DefaultLazy\OrderTarget; +use AutoMapper\Tests\ObjectMapper\Fixtures\DefaultLazy\UserSource; +use AutoMapper\Tests\ObjectMapper\Fixtures\DefaultLazy\UserTarget; +use AutoMapper\Tests\ObjectMapper\Fixtures\DefaultValueStdClass\TargetDto; +use AutoMapper\Tests\ObjectMapper\Fixtures\EmbeddedMapping\Address; +use AutoMapper\Tests\ObjectMapper\Fixtures\EmbeddedMapping\User as UserEmbeddedMapping; +use AutoMapper\Tests\ObjectMapper\Fixtures\EmbeddedMapping\UserDto; +use AutoMapper\Tests\ObjectMapper\Fixtures\Flatten\TargetUser; +use AutoMapper\Tests\ObjectMapper\Fixtures\Flatten\User; +use AutoMapper\Tests\ObjectMapper\Fixtures\Flatten\UserProfile; +use AutoMapper\Tests\ObjectMapper\Fixtures\HydrateObject\SourceOnly; +use AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor\A as InitializedConstructorA; +use AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor\B as InitializedConstructorB; +use AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor\C as InitializedConstructorC; +use AutoMapper\Tests\ObjectMapper\Fixtures\InitializedConstructor\D as InitializedConstructorD; +use AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallback\A as InstanceCallbackA; +use AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallback\B as InstanceCallbackB; +use AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallbackWithArguments\A as InstanceCallbackWithArgumentsA; +use AutoMapper\Tests\ObjectMapper\Fixtures\InstanceCallbackWithArguments\B as InstanceCallbackWithArgumentsB; +use AutoMapper\Tests\ObjectMapper\Fixtures\LazyFoo; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct\AToBMapper; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct\MapStructMapperMetadataFactory; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct\Source; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct\Target; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapTargetToSource\A as MapTargetToSourceA; +use AutoMapper\Tests\ObjectMapper\Fixtures\MapTargetToSource\B as MapTargetToSourceB; +use AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty\A as MultipleTargetPropertyA; +use AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty\B as MultipleTargetPropertyB; +use AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargetProperty\C as MultipleTargetPropertyC; +use AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargets\A as MultipleTargetsA; +use AutoMapper\Tests\ObjectMapper\Fixtures\MultipleTargets\C as MultipleTargetsC; +use AutoMapper\Tests\ObjectMapper\Fixtures\MyProxy; +use AutoMapper\Tests\ObjectMapper\Fixtures\PartialInput\FinalInput; +use AutoMapper\Tests\ObjectMapper\Fixtures\PartialInput\PartialInput; +use AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructor\Source as PromotedConstructorSource; +use AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructor\Target as PromotedConstructorTarget; +use AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructorWithMetadata\Source as PromotedConstructorWithMetadataSource; +use AutoMapper\Tests\ObjectMapper\Fixtures\PromotedConstructorWithMetadata\Target as PromotedConstructorWithMetadataTarget; +use AutoMapper\Tests\ObjectMapper\Fixtures\ReadOnlyPromotedProperty\ReadOnlyPromotedPropertyA; +use AutoMapper\Tests\ObjectMapper\Fixtures\ReadOnlyPromotedProperty\ReadOnlyPromotedPropertyAMapped; +use AutoMapper\Tests\ObjectMapper\Fixtures\ReadOnlyPromotedProperty\ReadOnlyPromotedPropertyB; +use AutoMapper\Tests\ObjectMapper\Fixtures\ReadOnlyPromotedProperty\ReadOnlyPromotedPropertyBMapped; +use AutoMapper\Tests\ObjectMapper\Fixtures\Recursion\AB; +use AutoMapper\Tests\ObjectMapper\Fixtures\Recursion\Dto; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\LoadedValueService; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ServiceLoadedValueTransformer; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ValueToMap; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ValueToMapRelation; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\A as ServiceLocatorA; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\B as ServiceLocatorB; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\ConditionCallable; +use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\TransformCallable; +use AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform\SourceEntity; +use AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform\TargetDto as TargetTransformTargetDto; +use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionA; +use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionB; +use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionC; +use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionD; +use Symfony\Component\PropertyAccess\PropertyAccess; + +final class ObjectMapperTest extends TestCase +{ + #[DataProvider('mapProvider')] + public function testMap($expect, $args, array $deps = []) + { + $mapper = new ObjectMapper(...$deps); + $mapped = $mapper->map(...$args); + + if (\PHP_VERSION_ID >= 80400 && isset($mapped->relation) && $mapped->relation instanceof D) { + $mapped->relation->baz; + } + + $this->assertEquals($expect, $mapped); + } + + /** + * @return iterable + */ + public static function mapProvider(): iterable + { + $d = new D(baz: 'foo', bat: 'bar'); + $c = new C(foo: 'foo', bar: 'bar'); + $a = new A(); + $a->foo = 'test'; + $a->transform = 'test'; + $a->baz = 'me'; + $a->notinb = 'test'; + $a->relation = $c; + $a->relationNotMapped = $d; + + $b = new B('test'); + $b->transform = 'TEST'; + $b->baz = 'me'; + $b->nomap = true; + $b->concat = 'testme'; + $b->relation = $d; + $b->relationNotMapped = $d; + yield [$b, [$a]]; + + $c = clone $b; + $c->id = 1; + yield [$c, [$a, $c]]; + + $d = clone $b; + // with propertyAccessor we call the getter + $d->concat = 'shouldtestme'; + + yield [$d, [$a], [new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor()]]; + + yield [new MultipleTargetsC(foo: 'bar'), [new MultipleTargetsA()]]; + } + + public function testHasNothingToMapTo() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Mapping target not found for source "class@anonymous".'); + (new ObjectMapper())->map(new class {}); + } + + public function testHasNothingToMapToWithNamedClass() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Mapping target not found for source "%s".', ClassWithoutTarget::class)); + (new ObjectMapper())->map(new ClassWithoutTarget()); + } + + public function testTargetNotFound() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Mapping target class "InexistantClass" does not exist for source "%s".', ClassWithoutTarget::class)); + (new ObjectMapper())->map(new ClassWithoutTarget(), 'InexistantClass'); + } + + public function testRecursion() + { + $ab = new AB(); + $ab->ab = $ab; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($ab); + $this->assertInstanceOf(Dto::class, $mapped); + $this->assertSame($mapped, $mapped->dto); + } + + public function testDeeperRecursion() + { + $recursive = new Recursive(); + $recursive->name = 'hi'; + $recursive->relation = new Relation(); + $recursive->relation->recursion = $recursive; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($recursive); + $this->assertSame($mapped->relation->recursion, $mapped); + $this->assertInstanceOf(RecursiveDto::class, $mapped); + $this->assertInstanceOf(RelationDto::class, $mapped->relation); + } + + public function testMapWithInitializedConstructor() + { + $a = new InitializedConstructorA(); + $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $b = $mapper->map($a, InitializedConstructorB::class); + $this->assertInstanceOf(InitializedConstructorB::class, $b); + $this->assertEquals($b->tags, ['foo', 'bar']); + } + + public function testMapReliesOnConstructorsOwnInitialization() + { + $expected = 'bar'; + + $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + + $source = new \stdClass(); + $source->bar = $expected; + + $c = $mapper->map($source, InitializedConstructorC::class); + + $this->assertInstanceOf(InitializedConstructorC::class, $c); + $this->assertEquals($expected, $c->bar); + } + + public function testMapConstructorArgumentsDifferFromClassFields() + { + $expected = 'bar'; + + $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + + $source = new \stdClass(); + $source->bar = $expected; + + $actual = $mapper->map($source, InitializedConstructorD::class); + + $this->assertInstanceOf(InitializedConstructorD::class, $actual); + $this->assertStringContainsStringIgnoringCase($expected, $actual->barUpperCase); + } + + public function testMapToWithInstanceHook() + { + $a = new InstanceCallbackA(); + $mapper = new ObjectMapper(); + $b = $mapper->map($a, InstanceCallbackB::class); + $this->assertInstanceOf(InstanceCallbackB::class, $b); + $this->assertSame($b->getId(), 1); + $this->assertSame($b->name, 'test'); + } + + public function testMapToWithInstanceHookWithArguments() + { + $a = new InstanceCallbackWithArgumentsA(); + $mapper = new ObjectMapper(); + $b = $mapper->map($a); + $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b); + $this->assertSame($a, $b->transformSource); + $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b->transformValue); + } + + public function testMapStruct() + { + $a = new Source('a', 'b', 'c'); + $metadata = new MapStructMapperMetadataFactory(AToBMapper::class); + $mapper = new ObjectMapper($metadata); + $aToBMapper = new AToBMapper($mapper); + $b = $aToBMapper->map($a); + $this->assertInstanceOf(Target::class, $b); + $this->assertSame($b->propertyD, 'a'); + $this->assertSame($b->propertyC, 'c'); + } + + public function testMultipleMapProperty() + { + $u = new User(email: 'hello@example.com', profile: new UserProfile(firstName: 'soyuka', lastName: 'arakusa')); + $mapper = new ObjectMapper(); + $b = $mapper->map($u); + $this->assertInstanceOf(TargetUser::class, $b); + $this->assertSame($b->firstName, 'soyuka'); + $this->assertSame($b->lastName, 'arakusa'); + } + + public function testServiceLocator() + { + $a = new ServiceLocatorA(); + $a->foo = 'nok'; + + $mapper = new ObjectMapper( + conditionCallableLocator: $this->getServiceLocator([ConditionCallable::class => new ConditionCallable()]), + transformCallableLocator: $this->getServiceLocator([TransformCallable::class => new TransformCallable()]) + ); + + $b = $mapper->map($a); + $this->assertSame($b->bar, 'notmapped'); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + + $a->foo = 'ok'; + $b = $mapper->map($a); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + $this->assertSame($b->bar, 'transformedok'); + } + + protected function getServiceLocator(array $factories): ContainerInterface + { + return new class($factories) implements ContainerInterface { + public function __construct(private array $factories) + { + } + + public function has(string $id): bool + { + return isset($this->factories[$id]); + } + + public function get(string $id): mixed + { + return $this->factories[$id]; + } + }; + } + + public function testSourceOnly() + { + $a = new \stdClass(); + $a->name = 'test'; + $mapper = new ObjectMapper(); + $mapped = $mapper->map($a, SourceOnly::class); + $this->assertInstanceOf(SourceOnly::class, $mapped); + $this->assertSame('test', $mapped->mappedName); + } + + public function testSourceOnlyWithMagicMethods() + { + $mapper = new ObjectMapper(); + $a = new class { + public function __isset($key): bool + { + return 'name' === $key; + } + + public function __get(string $key): string + { + return match ($key) { + 'name' => 'test', + default => throw new \LogicException($key), + }; + } + }; + + $mapped = $mapper->map($a, SourceOnly::class); + $this->assertInstanceOf(SourceOnly::class, $mapped); + $this->assertSame('test', $mapped->mappedName); + } + + public function testTransformToWrongValueType() + { + $this->expectException(MappingTransformException::class); + $this->expectExceptionMessage('Cannot map "stdClass" to a non-object target of type "string".'); + + $u = new \stdClass(); + $u->foo = 'bar'; + + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: fn () => 'str')]); + $mapper = new ObjectMapper($metadata); + $mapper->map($u); + } + + public function testTransformToWrongObject() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Expected the mapped object to be an instance of "%s" but got "stdClass".', ClassWithoutTarget::class)); + + $u = new \stdClass(); + $u->foo = 'bar'; + + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: fn () => new \stdClass())]); + $mapper = new ObjectMapper($metadata); + $mapper->map($u); + } + + public function testMapTargetToSource() + { + $a = new MapTargetToSourceA('str'); + $mapper = new ObjectMapper(); + $b = $mapper->map($a, MapTargetToSourceB::class); + $this->assertInstanceOf(MapTargetToSourceB::class, $b); + $this->assertSame('str', $b->target); + } + + public function testMultipleTargetMapProperty() + { + $u = new MultipleTargetPropertyA(); + + $mapper = new ObjectMapper(); + $b = $mapper->map($u, MultipleTargetPropertyB::class); + $this->assertInstanceOf(MultipleTargetPropertyB::class, $b); + $this->assertEquals('TEST', $b->foo); + $c = $mapper->map($u, MultipleTargetPropertyC::class); + $this->assertInstanceOf(MultipleTargetPropertyC::class, $c); + $this->assertEquals('test', $c->bar); + $this->assertEquals('donotmap', $c->foo); + $this->assertEquals('foo', $c->doesNotExistInTargetB); + } + + public function testDefaultValueStdClass() + { + $this->expectException(NoSuchPropertyException::class); + $u = new \stdClass(); + $u->id = 'abc'; + $mapper = new ObjectMapper(); + $b = $mapper->map($u, TargetDto::class); + } + + public function testDefaultValueStdClassWithPropertyInfo() + { + $u = new \stdClass(); + $u->id = 'abc'; + $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessorBuilder()->disableExceptionOnInvalidPropertyPath()->getPropertyAccessor()); + $b = $mapper->map($u, TargetDto::class); + $this->assertInstanceOf(TargetDto::class, $b); + $this->assertSame('abc', $b->id); + $this->assertNull($b->optional); + } + + #[DataProvider('objectMapperProvider')] + public function testUpdateObjectWithConstructorPromotedProperties(ObjectMapperInterface $mapper) + { + $a = new PromotedConstructorSource(1, 'foo'); + $b = new PromotedConstructorTarget(1, 'bar'); + $v = $mapper->map($a, $b); + $this->assertSame($v->name, 'foo'); + } + + #[DataProvider('objectMapperProvider')] + public function testUpdateMappedObjectWithAdditionalConstructorPromotedProperties(ObjectMapperInterface $mapper) + { + $a = new PromotedConstructorWithMetadataSource(3, 'foo-will-get-updated'); + $b = new PromotedConstructorWithMetadataTarget('notOnSourceButRequired', 1, 'bar'); + + $v = $mapper->map($a, $b); + + $this->assertSame($v->name, $a->name); + $this->assertSame($v->number, $a->number); + } + + /** + * @return iterable + */ + public static function objectMapperProvider(): iterable + { + yield [new ObjectMapper()]; + yield [new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; + } + + public function testMapInitializesLazyObject() + { + $lazy = new LazyFoo(); + $mapper = new ObjectMapper(); + $mapper->map($lazy, \stdClass::class); + $this->assertTrue($lazy->isLazyObjectInitialized()); + } + + #[RequiresPhp('>=8.4')] + public function testMapInitializesNativePhp84LazyObject() + { + $initialized = false; + $initializer = function () use (&$initialized) { + $initialized = true; + + $p = new MyProxy(); + $p->name = 'test'; + + return $p; + }; + + $r = new \ReflectionClass(MyProxy::class); + $lazyObj = $r->newLazyProxy($initializer); + $this->assertFalse($initialized); + $mapper = new ObjectMapper(); + $d = $mapper->map($lazyObj, MyProxy::class); + $this->assertSame('test', $d->name); + $this->assertTrue($initialized); + } + + public function testDecorateObjectMapper() + { + $mapper = new ObjectMapper(); + $myMapper = new class($mapper) implements ObjectMapperInterface { + public function __construct(private ObjectMapperInterface $mapper) + { + $this->mapper = $mapper->withObjectMapper($this); + } + + public function map(object $source, object|string|null $target = null): object + { + $mapped = $this->mapper->map($source, $target); + + if ($source instanceof C) { + $mapped->baz = 'got decorated'; + } + + return $mapped; + } + }; + + $d = new D(baz: 'foo', bat: 'bar'); + $c = new C(foo: 'foo', bar: 'bar'); + $myNewD = $myMapper->map($c); + $this->assertSame('got decorated', $myNewD->baz); + + $a = new A(); + $a->foo = 'test'; + $a->transform = 'test'; + $a->baz = 'me'; + $a->notinb = 'test'; + $a->relation = $c; + $a->relationNotMapped = $d; + + $b = $myMapper->map($a); + $this->assertSame('got decorated', $b->relation->baz); + } + + #[DataProvider('validPartialInputProvider')] + public function testMapPartially(PartialInput $actual, FinalInput $expected) + { + $mapper = new ObjectMapper(); + $this->assertEquals($expected, $mapper->map($actual)); + } + + public static function validPartialInputProvider(): iterable + { + $p = new PartialInput(); + $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; + $p->website = 'https://updated.website.com'; + + $f = new FinalInput(); + $f->uuid = $p->uuid; + $f->website = $p->website; + + yield [$p, $f]; + + $p = new PartialInput(); + $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; + $p->website = null; + + $f = new FinalInput(); + $f->uuid = $p->uuid; + + yield [$p, $f]; + + $p = new PartialInput(); + $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; + $p->website = 'https://updated.website.com'; + $p->email = 'updated@email.com'; + + $f = new FinalInput(); + $f->uuid = $p->uuid; + $f->website = $p->website; + $f->email = $p->email; + + yield [$p, $f]; + } + + public function testMapWithSourceTransform() + { + $source = new SourceEntity(); + $source->name = 'test'; + + $mapper = new ObjectMapper(); + $target = $mapper->map($source, TargetTransformTargetDto::class); + + $this->assertInstanceOf(TargetTransformTargetDto::class, $target); + $this->assertTrue($target->transformed); + $this->assertSame('test', $target->name); + } + + public function testTransformCollection() + { + $u = new TransformCollectionA(); + $u->foo = [new TransformCollectionC('a'), new TransformCollectionC('b')]; + $mapper = new ObjectMapper(); + + $transformed = $mapper->map($u, TransformCollectionB::class); + + $this->assertEquals([new TransformCollectionD('a'), new TransformCollectionD('b')], $transformed->foo); + } + + #[RequiresPhp('>=8.4')] + public function testEmbedsAreLazyLoadedByDefault() + { + $mapper = new ObjectMapper(); + $source = new OrderSource(); + $source->id = 123; + $source->user = new UserSource(); + $source->user->name = 'Test User'; + $target = $mapper->map($source, OrderTarget::class); + $this->assertInstanceOf(OrderTarget::class, $target); + $this->assertSame(123, $target->id); + $this->assertInstanceOf(UserTarget::class, $target->user); + $refl = new \ReflectionClass(UserTarget::class); + $this->assertTrue($refl->isUninitializedLazyObject($target->user)); + $this->assertSame('Test User', $target->user->name); + $this->assertFalse($refl->isUninitializedLazyObject($target->user)); + } + + public function testSkipLazyGhostWithClassTransform() + { + $service = new LoadedValueService(); + $service->load(); + + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $mapper = new ObjectMapper( + metadataFactory: $metadataFactory, + transformCallableLocator: $this->getServiceLocator([ServiceLoadedValueTransformer::class => new ServiceLoadedValueTransformer($service, $metadataFactory)]) + ); + + $value = new ValueToMap(); + $value->relation = new ValueToMapRelation('test'); + + $result = $mapper->map($value); + if (\PHP_VERSION_ID >= 80400) { + $refl = new \ReflectionClass($result->relation); + $this->assertFalse($refl->isUninitializedLazyObject($result->relation)); + } + + $this->assertSame($result->relation, $service->get()); + $this->assertSame($result->relation->name, 'loaded'); + } + + public function testMapEmbeddedProperties() + { + $dto = new UserDto( + userAddressZipcode: '12345', + userAddressCity: 'Test City', + name: 'John Doe' + ); + + $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $user = $mapper->map($dto, UserEmbeddedMapping::class); + + $this->assertInstanceOf(UserEmbeddedMapping::class, $user); + $this->assertSame('John Doe', $user->name); + $this->assertInstanceOf(Address::class, $user->address); + $this->assertSame('12345', $user->address->zipcode); + $this->assertSame('Test City', $user->address->city); + } + + public function testBugReportLazyLoadingPromotedReadonlyProperty() + { + $source = new ReadOnlyPromotedPropertyA( + b: new ReadOnlyPromotedPropertyB( + var2: 'bar', + ), + var1: 'foo', + ); + + $mapper = new ObjectMapper(); + $out = $mapper->map($source); + + $this->assertInstanceOf(ReadOnlyPromotedPropertyAMapped::class, $out); + $this->assertInstanceOf(ReadOnlyPromotedPropertyBMapped::class, $out->b); + $this->assertSame('foo', $out->var1); + $this->assertSame('bar', $out->b->var2); + } +} From ae959e00d27d721d7a38d1f2eb40bf80b78da14e Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Dec 2025 08:15:55 +0100 Subject: [PATCH 2/2] wip --- src/Event/GenerateMapperEvent.php | 2 +- .../ObjectMapper/MapClassListener.php | 133 ++++++++++++++++++ .../ObjectMapper/MapPropertyListener.php | 7 + .../MapMethodStatementsGenerator.php | 78 +++++++--- src/Generator/PropertyConditionsGenerator.php | 16 ++- src/Metadata/GeneratorMetadata.php | 2 +- src/Metadata/MetadataFactory.php | 6 + src/ObjectMapper/ObjectMapper.php | 70 ++++++++- .../Fixtures/MapStruct/AToBMapper.php | 3 +- tests/ObjectMapper/ObjectMapperTest.php | 99 +++++-------- 10 files changed, 322 insertions(+), 94 deletions(-) create mode 100644 src/EventListener/ObjectMapper/MapClassListener.php create mode 100644 src/EventListener/ObjectMapper/MapPropertyListener.php diff --git a/src/Event/GenerateMapperEvent.php b/src/Event/GenerateMapperEvent.php index 45ca0550..85e3d39e 100644 --- a/src/Event/GenerateMapperEvent.php +++ b/src/Event/GenerateMapperEvent.php @@ -18,7 +18,7 @@ final class GenerateMapperEvent public function __construct( public readonly MapperMetadata $mapperMetadata, public array $properties = [], - public ?string $provider = null, + public null|string|array $provider = null, public ?bool $checkAttributes = null, public ?ConstructorStrategy $constructorStrategy = null, public ?bool $allowReadOnlyTargetToPopulate = null, diff --git a/src/EventListener/ObjectMapper/MapClassListener.php b/src/EventListener/ObjectMapper/MapClassListener.php new file mode 100644 index 00000000..0f34bfc1 --- /dev/null +++ b/src/EventListener/ObjectMapper/MapClassListener.php @@ -0,0 +1,133 @@ +mapperMetadata->sourceReflectionClass || !$event->mapperMetadata->targetReflectionClass) { + return; + } + + $mapAttribute = null; + $reflectionClass = null; + $isSource = false; + + foreach ($event->mapperMetadata->sourceReflectionClass->getAttributes(Map::class) as $sourceAttribute) { + /** @var Map $attribute */ + $attribute = $sourceAttribute->newInstance(); + + if (!$attribute->target || $attribute->target === $event->mapperMetadata->target) { + $mapAttribute = $attribute; + $reflectionClass = $event->mapperMetadata->sourceReflectionClass; + $isSource = true; + break; + } + } + + if (!$mapAttribute) { + foreach ($event->mapperMetadata->targetReflectionClass->getAttributes(Map::class) as $targetAttribute) { + /** @var Map $attribute */ + $attribute = $targetAttribute->newInstance(); + + if (!$attribute->source || $attribute->source === $event->mapperMetadata->source) { + $mapAttribute = $attribute; + $reflectionClass = $event->mapperMetadata->targetReflectionClass; + break; + } + } + } + + if (!$mapAttribute || !$reflectionClass) { + return; + } + + // get all properties + $properties = []; + + foreach ($reflectionClass->getProperties() as $property) { + foreach ($property->getAttributes(Map::class) as $propertyAttribute) { + /** @var Map $attribute */ + $attribute = $propertyAttribute->newInstance(); + $propertyMetadata = new PropertyMetadataEvent( + /** + * public ?string $if = null,// @TODO + */ + $event->mapperMetadata, + new SourcePropertyMetadata($isSource ? $property->getName() : ($attribute->source ?? $property->getName())), + new TargetPropertyMetadata($isSource ? ($attribute->target ?? $property->getName()) : $property->getName()), + transformer: $this->getTransformerFromMapAttribute($reflectionClass->getName(), $attribute, $isSource), + if: $attribute->if, + ); + + $properties[] = $propertyMetadata; + } + } + + $event->properties = $properties; + + if ($mapAttribute->transform) { + $event->provider = $mapAttribute->transform; + } + } + + protected function getTransformerFromMapAttribute(string $class, Map $attribute, bool $fromSource = true): ?TransformerInterface + { + $transformer = null; + + if ($attribute->transform !== null) { + $callableName = null; + $transformerCallable = $attribute->transform; + + if ($transformerCallable instanceof \Closure) { + // This is not supported because we cannot generate code from a closure + // However this should never be possible since attributes does not allow to pass a closure + // Let's keep this check for future proof + throw new BadMapDefinitionException('Closure transformer is not supported.'); + } + + if (\is_callable($transformerCallable, false, $callableName)) { + $transformer = new CallableTransformer($callableName); + } elseif (\is_string($transformerCallable) && method_exists($class, $transformerCallable)) { + $reflMethod = new \ReflectionMethod($class, $transformerCallable); + + if ($reflMethod->isStatic()) { + $transformer = new CallableTransformer($class . '::' . $transformerCallable); + } else { + $transformer = new CallableTransformer($transformerCallable, $fromSource, !$fromSource); + } + } elseif (\is_string($transformerCallable)) { + try { + $expression = $this->expressionLanguage->compile($transformerCallable, ['value' => 'source', 'context']); + } catch (SyntaxError $e) { + throw new BadMapDefinitionException(\sprintf('Transformer "%s" targeted by %s transformer on class "%s" is not valid.', $transformerCallable, $attribute::class, $class), 0, $e); + } + + $transformer = new ExpressionLanguageTransformer($expression); + } else { + throw new BadMapDefinitionException(\sprintf('Callable "%s" targeted by %s transformer on class "%s" is not valid.', json_encode($transformerCallable), $attribute::class, $class)); + } + } + + return $transformer; + } +} \ No newline at end of file diff --git a/src/EventListener/ObjectMapper/MapPropertyListener.php b/src/EventListener/ObjectMapper/MapPropertyListener.php new file mode 100644 index 00000000..426e1c33 --- /dev/null +++ b/src/EventListener/ObjectMapper/MapPropertyListener.php @@ -0,0 +1,7 @@ +variableRegistry; + $statements = []; + + if (is_array($metadata->provider) || is_callable($metadata->provider)) { + $callableName = null; + + if (!is_callable($metadata->provider, false, $callableName)) { + return []; + } + + /* + * Get result from callable if available + * + * ```php + * $result ??= callable(Target::class, $value, $context, $this->getTargetIdentifiers($value)); + * ``` + */ + $statements[] = new Stmt\Expression( + new Expr\AssignOp\Coalesce( + $variableRegistry->getResult(), + new Expr\FuncCall( + new Name($callableName), [ + new Arg(new Scalar\String_($metadata->mapperMetadata->target)), + new Arg($variableRegistry->getSourceInput()), + new Arg($variableRegistry->getContext()), + new Arg(new Expr\MethodCall(new Expr\Variable('this'), 'getTargetIdentifiers', [ + new Arg(new Expr\Variable('value')), + ])), + ]), + ) + ); + } else { + + /* + * Get result from provider if available + * + * ```php + * $result ??= $this->providerRegistry->getProvider($metadata->provider)->provide($source, $context); + * ``` + */ + $statements[] = new Stmt\Expression( + new Expr\AssignOp\Coalesce( + $variableRegistry->getResult(), + new Expr\MethodCall(new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'providerRegistry'), 'getProvider', [ + new Arg(new Scalar\String_($metadata->provider)), + ]), 'provide', [ + new Arg(new Scalar\String_($metadata->mapperMetadata->target)), + new Arg($variableRegistry->getSourceInput()), + new Arg($variableRegistry->getContext()), + new Arg(new Expr\MethodCall(new Expr\Variable('this'), 'getTargetIdentifiers', [ + new Arg(new Expr\Variable('value')), + ])), + ]), + ) + ); + } /* - * Get result from provider if available - * - * ```php - * $result ??= $this->providerRegistry->getProvider($metadata->provider)->provide($source, $context); + * Return early if the result is an EarlyReturn instance * * if ($result instanceof EarlyReturn) { * return $result->value; * } * ``` */ - $statements = []; - $statements[] = new Stmt\Expression( - new Expr\AssignOp\Coalesce( - $variableRegistry->getResult(), - new Expr\MethodCall(new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'providerRegistry'), 'getProvider', [ - new Arg(new Scalar\String_($metadata->provider)), - ]), 'provide', [ - new Arg(new Scalar\String_($metadata->mapperMetadata->target)), - new Arg($variableRegistry->getSourceInput()), - new Arg($variableRegistry->getContext()), - new Arg(new Expr\MethodCall(new Expr\Variable('this'), 'getTargetIdentifiers', [ - new Arg(new Expr\Variable('value')), - ])), - ]), - ) - ); - $statements[] = new Stmt\If_( new Expr\Instanceof_($variableRegistry->getResult(), new Name(EarlyReturn::class)), [ diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 529c7ce2..71de255b 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -246,6 +246,12 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ } $callableName = null; + $value = $metadata->variableRegistry->getSourceInput(); + + // use read accessor + if ($propertyMetadata->source->accessor !== null) { + $value = $propertyMetadata->source->accessor->getExpression($metadata->variableRegistry->getSourceInput()); + } if (\is_callable($propertyMetadata->if, false, $callableName)) { if (\function_exists($callableName)) { @@ -257,7 +263,7 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ return new Expr\FuncCall( new Name($callableName), [ - new Arg(new Expr\Variable('value')), + new Arg($value), ] ); } @@ -270,7 +276,7 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ return new Expr\FuncCall( new Name($callableName), [ - new Arg(new Expr\Variable('value')), + new Arg($value), new Arg(new Expr\Variable('context')), ] ); @@ -284,17 +290,17 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ new Name\FullyQualified($metadata->mapperMetadata->source), $propertyMetadata->if, [ - new Arg(new Expr\Variable('value')), + new Arg($value), new Arg(new Expr\Variable('context')), ] ); } return new Expr\MethodCall( - new Expr\Variable('value'), + $metadata->variableRegistry->getSourceInput(), $propertyMetadata->if, [ - new Arg(new Expr\Variable('value')), + new Arg($value), new Arg(new Expr\Variable('context')), ] ); diff --git a/src/Metadata/GeneratorMetadata.php b/src/Metadata/GeneratorMetadata.php index f8ba253e..df04996e 100644 --- a/src/Metadata/GeneratorMetadata.php +++ b/src/Metadata/GeneratorMetadata.php @@ -26,7 +26,7 @@ public function __construct( public readonly ConstructorStrategy $constructorStrategy = ConstructorStrategy::AUTO, public readonly bool $allowReadOnlyTargetToPopulate = false, public readonly bool $strictTypes = false, - public readonly ?string $provider = null, + public readonly null|string|array $provider = null, ) { $this->variableRegistry = new VariableRegistry(); } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 512128e4..9ce4d910 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -16,6 +16,7 @@ use AutoMapper\EventListener\MapProviderListener; use AutoMapper\EventListener\MapToContextListener; use AutoMapper\EventListener\MapToListener; +use AutoMapper\EventListener\ObjectMapper\MapClassListener; use AutoMapper\EventListener\Symfony\ClassDiscriminatorListener; use AutoMapper\EventListener\Symfony\NameConverterListener; use AutoMapper\EventListener\Symfony\SerializerGroupListener; @@ -51,6 +52,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -394,6 +396,10 @@ public static function create( $eventDispatcher->addListener(GenerateMapperEvent::class, new MapperListener()); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapProviderListener()); + if (interface_exists(ObjectMapperInterface::class)) { + $eventDispatcher->addListener(GenerateMapperEvent::class, new MapClassListener($expressionLanguage)); + } + // Create transformer factories $factories = [ new DoctrineCollectionTransformerFactory(), diff --git a/src/ObjectMapper/ObjectMapper.php b/src/ObjectMapper/ObjectMapper.php index 23b560b5..2293ecdb 100644 --- a/src/ObjectMapper/ObjectMapper.php +++ b/src/ObjectMapper/ObjectMapper.php @@ -4,6 +4,11 @@ use AutoMapper\AutoMapper; use AutoMapper\AutoMapperInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; use Symfony\Component\ObjectMapper\ObjectMapperInterface; final readonly class ObjectMapper implements ObjectMapperInterface @@ -11,18 +16,75 @@ private AutoMapperInterface $autoMapper; public function __construct( - ?AutoMapperInterface $autoMapper = null + private ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), + ?AutoMapperInterface $autoMapper = null, + private ?ContainerInterface $conditionCallableLocator = null, ) { - $this->autoMapper ??= AutoMapper::create(); + $this->autoMapper = $autoMapper ?? AutoMapper::create(); } public function map(object $source, object|string|null $target = null): object { - if ($target === null) { - // @TODO get the target class from attributes + if (null === $target) { + $metadata = $this->metadataFactory->create($source); + $map = $this->getMapTarget($metadata, null, $source, null); + $target = $map?->target; + } + + if (!$target) { + throw new MappingException(sprintf('Mapping target not found for source "%s".', get_debug_type($source))); + } + + if (\is_string($target) && !class_exists($target)) { + throw new MappingException(\sprintf('Mapping target class "%s" does not exist for source "%s".', $target, get_debug_type($source))); } return $this->autoMapper->map($source, $target); } + + /** + * @param callable(): mixed $fn + */ + private function call(callable $fn, mixed $value, object $source, ?object $target = null): mixed + { + if (\is_string($fn)) { + return \call_user_func($fn, $value); + } + + return $fn($value, $source, $target); + } + + /** + * @param Mapping[] $metadata + */ + private function getMapTarget(array $metadata, mixed $value, object $source, ?object $target): ?Mapping + { + $mapTo = null; + foreach ($metadata as $mapAttribute) { + if (($if = $mapAttribute->if) && ($fn = $this->getCallable($if, $this->conditionCallableLocator)) && !$this->call($fn, $value, $source, $target)) { + continue; + } + + $mapTo = $mapAttribute; + } + + return $mapTo; + } + + /** + * @param (string|callable(mixed $value, object $object): mixed) $fn + */ + private function getCallable(string|callable $fn, ?ContainerInterface $locator = null): ?callable + { + if (\is_callable($fn)) { + return $fn; + } + + if ($locator?->has($fn)) { + return $locator->get($fn); + } + + return null; + } } \ No newline at end of file diff --git a/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php b/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php index 66608764..b19c9ec9 100644 --- a/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php +++ b/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php @@ -11,13 +11,12 @@ namespace AutoMapper\Tests\ObjectMapper\Fixtures\MapStruct; -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) + public function __construct(private readonly ObjectMapperInterface $objectMapper) { } diff --git a/tests/ObjectMapper/ObjectMapperTest.php b/tests/ObjectMapper/ObjectMapperTest.php index 0c16632e..78e1db99 100644 --- a/tests/ObjectMapper/ObjectMapperTest.php +++ b/tests/ObjectMapper/ObjectMapperTest.php @@ -11,6 +11,7 @@ namespace AutoMapper\Tests\ObjectMapper; +use AutoMapper\Tests\AutoMapperTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; @@ -21,8 +22,7 @@ use Symfony\Component\ObjectMapper\Metadata\Mapping; use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; -use Symfony\Component\ObjectMapper\ObjectMapper; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use AutoMapper\ObjectMapper\ObjectMapper; use AutoMapper\Tests\ObjectMapper\Fixtures\A; use AutoMapper\Tests\ObjectMapper\Fixtures\B; use AutoMapper\Tests\ObjectMapper\Fixtures\C; @@ -78,33 +78,27 @@ use AutoMapper\Tests\ObjectMapper\Fixtures\Recursion\AB; use AutoMapper\Tests\ObjectMapper\Fixtures\Recursion\Dto; use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\LoadedValueService; -use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ServiceLoadedValueTransformer; use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ValueToMap; use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLoadedValue\ValueToMapRelation; use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\A as ServiceLocatorA; use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\B as ServiceLocatorB; -use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\ConditionCallable; -use AutoMapper\Tests\ObjectMapper\Fixtures\ServiceLocator\TransformCallable; use AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform\SourceEntity; use AutoMapper\Tests\ObjectMapper\Fixtures\TargetTransform\TargetDto as TargetTransformTargetDto; use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionA; use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionB; use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionC; use AutoMapper\Tests\ObjectMapper\Fixtures\TransformCollection\TransformCollectionD; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyAccess\PropertyAccess; -final class ObjectMapperTest extends TestCase +final class ObjectMapperTest extends AutoMapperTestCase { #[DataProvider('mapProvider')] public function testMap($expect, $args, array $deps = []) { - $mapper = new ObjectMapper(...$deps); + $mapper = $this->createObjectMapper(); $mapped = $mapper->map(...$args); - if (\PHP_VERSION_ID >= 80400 && isset($mapped->relation) && $mapped->relation instanceof D) { - $mapped->relation->baz; - } - $this->assertEquals($expect, $mapped); } @@ -127,7 +121,7 @@ public static function mapProvider(): iterable $b->transform = 'TEST'; $b->baz = 'me'; $b->nomap = true; - $b->concat = 'testme'; + $b->concat = 'shouldtestme'; $b->relation = $d; $b->relationNotMapped = $d; yield [$b, [$a]]; @@ -149,28 +143,28 @@ public function testHasNothingToMapTo() { $this->expectException(MappingException::class); $this->expectExceptionMessage('Mapping target not found for source "class@anonymous".'); - (new ObjectMapper())->map(new class {}); + ($this->createObjectMapper())->map(new class {}); } public function testHasNothingToMapToWithNamedClass() { $this->expectException(MappingException::class); $this->expectExceptionMessage(\sprintf('Mapping target not found for source "%s".', ClassWithoutTarget::class)); - (new ObjectMapper())->map(new ClassWithoutTarget()); + ($this->createObjectMapper())->map(new ClassWithoutTarget()); } public function testTargetNotFound() { $this->expectException(MappingException::class); $this->expectExceptionMessage(\sprintf('Mapping target class "InexistantClass" does not exist for source "%s".', ClassWithoutTarget::class)); - (new ObjectMapper())->map(new ClassWithoutTarget(), 'InexistantClass'); + ($this->createObjectMapper())->map(new ClassWithoutTarget(), 'InexistantClass'); } public function testRecursion() { $ab = new AB(); $ab->ab = $ab; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $mapped = $mapper->map($ab); $this->assertInstanceOf(Dto::class, $mapped); $this->assertSame($mapped, $mapped->dto); @@ -182,7 +176,7 @@ public function testDeeperRecursion() $recursive->name = 'hi'; $recursive->relation = new Relation(); $recursive->relation->recursion = $recursive; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $mapped = $mapper->map($recursive); $this->assertSame($mapped->relation->recursion, $mapped); $this->assertInstanceOf(RecursiveDto::class, $mapped); @@ -192,7 +186,7 @@ public function testDeeperRecursion() public function testMapWithInitializedConstructor() { $a = new InitializedConstructorA(); - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $mapper = $this->createObjectMapper(); $b = $mapper->map($a, InitializedConstructorB::class); $this->assertInstanceOf(InitializedConstructorB::class, $b); $this->assertEquals($b->tags, ['foo', 'bar']); @@ -202,7 +196,7 @@ public function testMapReliesOnConstructorsOwnInitialization() { $expected = 'bar'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $mapper = $this->createObjectMapper(); $source = new \stdClass(); $source->bar = $expected; @@ -217,7 +211,7 @@ public function testMapConstructorArgumentsDifferFromClassFields() { $expected = 'bar'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $mapper = $this->createObjectMapper(); $source = new \stdClass(); $source->bar = $expected; @@ -231,7 +225,7 @@ public function testMapConstructorArgumentsDifferFromClassFields() public function testMapToWithInstanceHook() { $a = new InstanceCallbackA(); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($a, InstanceCallbackB::class); $this->assertInstanceOf(InstanceCallbackB::class, $b); $this->assertSame($b->getId(), 1); @@ -241,29 +235,16 @@ public function testMapToWithInstanceHook() public function testMapToWithInstanceHookWithArguments() { $a = new InstanceCallbackWithArgumentsA(); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($a); $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b); $this->assertSame($a, $b->transformSource); - $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b->transformValue); - } - - public function testMapStruct() - { - $a = new Source('a', 'b', 'c'); - $metadata = new MapStructMapperMetadataFactory(AToBMapper::class); - $mapper = new ObjectMapper($metadata); - $aToBMapper = new AToBMapper($mapper); - $b = $aToBMapper->map($a); - $this->assertInstanceOf(Target::class, $b); - $this->assertSame($b->propertyD, 'a'); - $this->assertSame($b->propertyC, 'c'); } public function testMultipleMapProperty() { $u = new User(email: 'hello@example.com', profile: new UserProfile(firstName: 'soyuka', lastName: 'arakusa')); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($u); $this->assertInstanceOf(TargetUser::class, $b); $this->assertSame($b->firstName, 'soyuka'); @@ -275,10 +256,7 @@ public function testServiceLocator() $a = new ServiceLocatorA(); $a->foo = 'nok'; - $mapper = new ObjectMapper( - conditionCallableLocator: $this->getServiceLocator([ConditionCallable::class => new ConditionCallable()]), - transformCallableLocator: $this->getServiceLocator([TransformCallable::class => new TransformCallable()]) - ); + $mapper = $this->createObjectMapper(); $b = $mapper->map($a); $this->assertSame($b->bar, 'notmapped'); @@ -313,7 +291,7 @@ public function testSourceOnly() { $a = new \stdClass(); $a->name = 'test'; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $mapped = $mapper->map($a, SourceOnly::class); $this->assertInstanceOf(SourceOnly::class, $mapped); $this->assertSame('test', $mapped->mappedName); @@ -321,7 +299,7 @@ public function testSourceOnly() public function testSourceOnlyWithMagicMethods() { - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $a = new class { public function __isset($key): bool { @@ -373,7 +351,7 @@ public function testTransformToWrongObject() public function testMapTargetToSource() { $a = new MapTargetToSourceA('str'); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($a, MapTargetToSourceB::class); $this->assertInstanceOf(MapTargetToSourceB::class, $b); $this->assertSame('str', $b->target); @@ -383,7 +361,7 @@ public function testMultipleTargetMapProperty() { $u = new MultipleTargetPropertyA(); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($u, MultipleTargetPropertyB::class); $this->assertInstanceOf(MultipleTargetPropertyB::class, $b); $this->assertEquals('TEST', $b->foo); @@ -399,7 +377,7 @@ public function testDefaultValueStdClass() $this->expectException(NoSuchPropertyException::class); $u = new \stdClass(); $u->id = 'abc'; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $b = $mapper->map($u, TargetDto::class); } @@ -407,7 +385,7 @@ public function testDefaultValueStdClassWithPropertyInfo() { $u = new \stdClass(); $u->id = 'abc'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessorBuilder()->disableExceptionOnInvalidPropertyPath()->getPropertyAccessor()); + $mapper = $this->createObjectMapper(); $b = $mapper->map($u, TargetDto::class); $this->assertInstanceOf(TargetDto::class, $b); $this->assertSame('abc', $b->id); @@ -441,13 +419,12 @@ public function testUpdateMappedObjectWithAdditionalConstructorPromotedPropertie public static function objectMapperProvider(): iterable { yield [new ObjectMapper()]; - yield [new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; } public function testMapInitializesLazyObject() { $lazy = new LazyFoo(); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $mapper->map($lazy, \stdClass::class); $this->assertTrue($lazy->isLazyObjectInitialized()); } @@ -468,7 +445,7 @@ public function testMapInitializesNativePhp84LazyObject() $r = new \ReflectionClass(MyProxy::class); $lazyObj = $r->newLazyProxy($initializer); $this->assertFalse($initialized); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $d = $mapper->map($lazyObj, MyProxy::class); $this->assertSame('test', $d->name); $this->assertTrue($initialized); @@ -476,7 +453,7 @@ public function testMapInitializesNativePhp84LazyObject() public function testDecorateObjectMapper() { - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $myMapper = new class($mapper) implements ObjectMapperInterface { public function __construct(private ObjectMapperInterface $mapper) { @@ -515,7 +492,7 @@ public function map(object $source, object|string|null $target = null): object #[DataProvider('validPartialInputProvider')] public function testMapPartially(PartialInput $actual, FinalInput $expected) { - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $this->assertEquals($expected, $mapper->map($actual)); } @@ -558,7 +535,7 @@ public function testMapWithSourceTransform() $source = new SourceEntity(); $source->name = 'test'; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $target = $mapper->map($source, TargetTransformTargetDto::class); $this->assertInstanceOf(TargetTransformTargetDto::class, $target); @@ -570,7 +547,7 @@ public function testTransformCollection() { $u = new TransformCollectionA(); $u->foo = [new TransformCollectionC('a'), new TransformCollectionC('b')]; - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $transformed = $mapper->map($u, TransformCollectionB::class); @@ -580,7 +557,7 @@ public function testTransformCollection() #[RequiresPhp('>=8.4')] public function testEmbedsAreLazyLoadedByDefault() { - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $source = new OrderSource(); $source->id = 123; $source->user = new UserSource(); @@ -601,10 +578,7 @@ public function testSkipLazyGhostWithClassTransform() $service->load(); $metadataFactory = new ReflectionObjectMapperMetadataFactory(); - $mapper = new ObjectMapper( - metadataFactory: $metadataFactory, - transformCallableLocator: $this->getServiceLocator([ServiceLoadedValueTransformer::class => new ServiceLoadedValueTransformer($service, $metadataFactory)]) - ); + $mapper = $this->createObjectMapper(); $value = new ValueToMap(); $value->relation = new ValueToMapRelation('test'); @@ -627,7 +601,7 @@ public function testMapEmbeddedProperties() name: 'John Doe' ); - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); + $mapper = $this->createObjectMapper(); $user = $mapper->map($dto, UserEmbeddedMapping::class); $this->assertInstanceOf(UserEmbeddedMapping::class, $user); @@ -646,7 +620,7 @@ public function testBugReportLazyLoadingPromotedReadonlyProperty() var1: 'foo', ); - $mapper = new ObjectMapper(); + $mapper = $this->createObjectMapper(); $out = $mapper->map($source); $this->assertInstanceOf(ReadOnlyPromotedPropertyAMapped::class, $out); @@ -654,4 +628,9 @@ public function testBugReportLazyLoadingPromotedReadonlyProperty() $this->assertSame('foo', $out->var1); $this->assertSame('bar', $out->b->var2); } + + public function createObjectMapper(): ObjectMapperInterface + { + return new ObjectMapper(autoMapper: $this->autoMapper); + } }