diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c3c877..fd69eb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial support for nested properties - Add support for object invokable transformer in attribute transformer - Add a new interface `PropertyTransformerComputeInterface` to allow property transformers with supports, to compute a value that will be fixed during code generation. +- Support ObjectMapper attributes +- Add an implementation for Symfony `ObjectMapperInterface` using AutoMapper ### Changed - [BC Break] `PropertyTransformerSupportInterface` does not use a `TypesMatching` anymore, you can get the type directly from `SourcePropertyMetadata` or `TargetPropertyMetadata`. diff --git a/composer.json b/composer.json index 060018af..680faba1 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,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/phpstan.neon b/phpstan.neon index 02680322..4c4a5cbd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,9 @@ parameters: - src/ tmpDir: cache + + ignoreErrors: + - + message: "#^Method AutoMapper\\\\ObjectMapper\\\\ObjectMapper\\:\\:map\\(\\) should return T of object but returns object\\|null\\.$#" + count: 1 + path: src/ObjectMapper/ObjectMapper.php diff --git a/src/AttributeReference/Reference.php b/src/AttributeReference/Reference.php index 441fb8c2..e4b85807 100644 --- a/src/AttributeReference/Reference.php +++ b/src/AttributeReference/Reference.php @@ -4,6 +4,11 @@ namespace AutoMapper\AttributeReference; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar; + class Reference { public function __construct( @@ -14,4 +19,40 @@ public function __construct( public ?string $methodName = null, ) { } + + public function getReferenceExpression(): Expr + { + if ($this->methodName) { + /** ReflectionReference::fromMethod($className, $methodName) */ + return new Expr\StaticCall( + new Name\FullyQualified(ReflectionReference::class), + 'fromMethod', + [ + new Arg(new Scalar\String_($this->className)), + new Arg(new Scalar\String_($this->methodName)), + ] + ); + } + + if ($this->propertyName) { + /** ReflectionReference::fromProperty($className, $propertyName) */ + return new Expr\StaticCall( + new Name\FullyQualified(ReflectionReference::class), + 'fromProperty', + [ + new Arg(new Scalar\String_($this->className)), + new Arg(new Scalar\String_($this->propertyName)), + ] + ); + } + + /** ReflectionReference::fromClass($className) */ + return new Expr\StaticCall( + new Name\FullyQualified(ReflectionReference::class), + 'fromClass', + [ + new Arg(new Scalar\String_($this->className)), + ] + ); + } } diff --git a/src/AutoMapper.php b/src/AutoMapper.php index 0a79c90b..fc5de16e 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -130,6 +130,7 @@ public function mapCollection(iterable $collection, string $target, array $conte /** * @param ProviderInterface[] $providers * @param iterable $propertyTransformers + * @param iterable $extraMapperServices * * @return self */ @@ -142,6 +143,7 @@ public static function create( EventDispatcherInterface $eventDispatcher = new EventDispatcher(), iterable $providers = [], ?ObjectManager $objectManager = null, + iterable $extraMapperServices = [], ): AutoMapperInterface { if (class_exists(AttributeLoader::class)) { $loaderClass = new AttributeLoader(); @@ -190,6 +192,14 @@ public static function create( } } + foreach ($extraMapperServices as $key => $mapperService) { + if (\is_int($key)) { + $key = $mapperService::class; + } + + $serviceLocator->set($key, $mapperService); + } + $metadataRegistry = new MetadataRegistry($configuration); $classDiscriminatorResolver = new ClassDiscriminatorResolver(); diff --git a/src/Event/GenerateMapperEvent.php b/src/Event/GenerateMapperEvent.php index 7218cb4b..43fd99d2 100644 --- a/src/Event/GenerateMapperEvent.php +++ b/src/Event/GenerateMapperEvent.php @@ -7,11 +7,13 @@ use AutoMapper\ConstructorStrategy; use AutoMapper\Metadata\Discriminator; use AutoMapper\Metadata\MapperMetadata; +use AutoMapper\Metadata\Provider; +use Symfony\Contracts\EventDispatcher\Event; /** * @internal */ -final class GenerateMapperEvent +final class GenerateMapperEvent extends Event { /** * @param PropertyMetadataEvent[] $properties A list of properties to add to this mapping @@ -19,7 +21,7 @@ final class GenerateMapperEvent public function __construct( public readonly MapperMetadata $mapperMetadata, public array $properties = [], - public ?string $provider = null, + public ?Provider $provider = null, public ?bool $checkAttributes = null, public ?ConstructorStrategy $constructorStrategy = null, public ?bool $allowReadOnlyTargetToPopulate = null, diff --git a/src/Event/PropertyMetadataEvent.php b/src/Event/PropertyMetadataEvent.php index bd51e205..aebeedb1 100644 --- a/src/Event/PropertyMetadataEvent.php +++ b/src/Event/PropertyMetadataEvent.php @@ -4,6 +4,7 @@ namespace AutoMapper\Event; +use AutoMapper\AttributeReference\Reference; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Transformer\TransformerInterface; @@ -24,7 +25,7 @@ public function __construct( public ?string $dateTimeFormat = null, public ?bool $ignored = null, public ?string $ignoreReason = null, - public ?string $if = null, + public string|Reference|null $if = null, public ?array $groups = null, public ?bool $disableGroupsCheck = null, public int $priority = 0, diff --git a/src/EventListener/ApiPlatform/JsonLdListener.php b/src/EventListener/ApiPlatform/JsonLdListener.php index 634a836b..0e7c725e 100644 --- a/src/EventListener/ApiPlatform/JsonLdListener.php +++ b/src/EventListener/ApiPlatform/JsonLdListener.php @@ -11,6 +11,7 @@ use AutoMapper\Event\PropertyMetadataEvent; use AutoMapper\Event\SourcePropertyMetadata; use AutoMapper\Event\TargetPropertyMetadata; +use AutoMapper\Metadata\Provider; use AutoMapper\Provider\ApiPlatform\IriProvider; use AutoMapper\Transformer\ApiPlatform\JsonLdContextTransformer; use AutoMapper\Transformer\ApiPlatform\JsonLdIdTransformer; @@ -68,7 +69,7 @@ public function __invoke(GenerateMapperEvent $event): void } if ($event->mapperMetadata->source === 'array' && $this->resourceClassResolver->isResourceClass($event->mapperMetadata->target)) { - $event->provider ??= IriProvider::class; + $event->provider ??= new Provider(Provider::TYPE_SERVICE, IriProvider::class); } } } diff --git a/src/EventListener/Doctrine/DoctrineProviderListener.php b/src/EventListener/Doctrine/DoctrineProviderListener.php index 15d684cd..0d127758 100644 --- a/src/EventListener/Doctrine/DoctrineProviderListener.php +++ b/src/EventListener/Doctrine/DoctrineProviderListener.php @@ -5,6 +5,7 @@ namespace AutoMapper\EventListener\Doctrine; use AutoMapper\Event\GenerateMapperEvent; +use AutoMapper\Metadata\Provider; use AutoMapper\Provider\Doctrine\DoctrineProvider; use Doctrine\Persistence\ObjectManager; @@ -21,6 +22,6 @@ public function __invoke(GenerateMapperEvent $event): void return; } - $event->provider ??= DoctrineProvider::class; + $event->provider ??= new Provider(Provider::TYPE_SERVICE, DoctrineProvider::class); } } diff --git a/src/EventListener/MapProviderListener.php b/src/EventListener/MapProviderListener.php index 2a07396f..816f42f7 100644 --- a/src/EventListener/MapProviderListener.php +++ b/src/EventListener/MapProviderListener.php @@ -7,6 +7,7 @@ use AutoMapper\Attribute\MapProvider; use AutoMapper\Event\GenerateMapperEvent; use AutoMapper\Exception\BadMapDefinitionException; +use AutoMapper\Metadata\Provider; /** * @internal @@ -60,7 +61,7 @@ public function __invoke(GenerateMapperEvent $event): void if (false === $eventProvider) { $event->provider = null; } else { - $event->provider = $eventProvider; + $event->provider = new Provider(Provider::TYPE_SERVICE, $eventProvider); } } } diff --git a/src/EventListener/ObjectMapper/MapListener.php b/src/EventListener/ObjectMapper/MapListener.php new file mode 100644 index 00000000..5534a379 --- /dev/null +++ b/src/EventListener/ObjectMapper/MapListener.php @@ -0,0 +1,67 @@ +transform; + + if ($transformerCallable !== null) { + $callableName = null; + + if ($transformerCallable instanceof \Closure) { + $transformer = new ReferenceTransformer($reference, true); + } elseif (!\is_object($transformerCallable) && \is_callable($transformerCallable, false, $callableName)) { + $transformer = new CallableTransformer($callableName); + } elseif (\is_callable($transformerCallable, false, $callableName)) { + $transformer = new ReferenceTransformer($reference, true); + } 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) && class_exists($transformerCallable) && is_subclass_of($transformerCallable, TransformCallableInterface::class)) { + $transformer = new ServiceLocatorTransformer($transformerCallable); + } 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; + } +} diff --git a/src/EventListener/ObjectMapper/MapSourceListener.php b/src/EventListener/ObjectMapper/MapSourceListener.php new file mode 100644 index 00000000..d220e473 --- /dev/null +++ b/src/EventListener/ObjectMapper/MapSourceListener.php @@ -0,0 +1,105 @@ +mapperMetadata->sourceReflectionClass) { + return; + } + + $mapAttribute = null; + $hasAnyMapAttribute = false; + + foreach ($event->mapperMetadata->sourceReflectionClass->getAttributes(Map::class) as $sourceAttribute) { + /** @var Map $attribute */ + $attribute = $sourceAttribute->newInstance(); + $hasAnyMapAttribute = true; + + if (!$attribute->target || $attribute->target === $event->mapperMetadata->target) { + $mapAttribute = $attribute; + break; + } + } + + // it means that there is at least one Map attribute but none match the current mapping + if (!$mapAttribute && $hasAnyMapAttribute) { + return; + } + + // get all properties + $properties = []; + + foreach ($event->mapperMetadata->sourceReflectionClass->getProperties() as $property) { + foreach ($property->getAttributes(Map::class) as $index => $propertyAttribute) { + /** @var Map $attribute */ + $attribute = $propertyAttribute->newInstance(); + $reference = new Reference(Map::class, $index, $event->mapperMetadata->sourceReflectionClass->getName(), propertyName: $property->getName()); + $propertyMetadata = new PropertyMetadataEvent( + /* + * public ?string $if = null,// @TODO + */ + $event->mapperMetadata, + new SourcePropertyMetadata($property->getName()), + new TargetPropertyMetadata($attribute->target ?? $property->getName()), + transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->sourceReflectionClass->getName(), $attribute, $reference, true), + ); + + $ifCallableName = null; + + if ($attribute->if instanceof TargetClass) { + $reflectionObject = new \ReflectionClass($attribute->if); + /** @var string $targetClassName */ + $targetClassName = $reflectionObject->getProperty('className')->getRawValue($attribute->if); + + if ($targetClassName !== null && $event->mapperMetadata->target !== $targetClassName && !is_subclass_of($event->mapperMetadata->target, $targetClassName)) { + continue; + } + } elseif ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) { + if (\is_object($attribute->if)) { + $propertyMetadata->if = $reference; + } else { + $propertyMetadata->if = $ifCallableName; + } + } elseif (\is_string($attribute->if)) { + $propertyMetadata->if = $attribute->if; + } + + $properties[] = $propertyMetadata; + } + } + + $event->properties = [...$event->properties, ...$properties]; + + if ($mapAttribute?->transform) { + $callableName = null; + + if (\is_callable($mapAttribute->transform, false, $callableName)) { + $event->provider = new Provider(Provider::TYPE_CALLABLE, $callableName, true); + } + + if (\is_string($mapAttribute->transform) && $this->serviceLocator->has($mapAttribute->transform)) { + $event->provider = new Provider(Provider::TYPE_SERVICE_CALLABLE, $mapAttribute->transform, true); + } + } + + // Stop propagation if any Map attribute is found + if ($hasAnyMapAttribute || \count($properties) > 0 || $mapAttribute) { + $event->stopPropagation(); + } + } +} diff --git a/src/EventListener/ObjectMapper/MapTargetListener.php b/src/EventListener/ObjectMapper/MapTargetListener.php new file mode 100644 index 00000000..b706463d --- /dev/null +++ b/src/EventListener/ObjectMapper/MapTargetListener.php @@ -0,0 +1,86 @@ +mapperMetadata->targetReflectionClass) { + return; + } + + $mapAttribute = null; + $hasAnyMapAttribute = false; + + foreach ($event->mapperMetadata->targetReflectionClass->getAttributes(Map::class) as $targetAttribute) { + /** @var Map $attribute */ + $attribute = $targetAttribute->newInstance(); + $hasAnyMapAttribute = true; + + if (!$attribute->source || $attribute->source === $event->mapperMetadata->source) { + $mapAttribute = $attribute; + } + } + + // it means that there is at least one Map attribute but none match the current mapping + if (!$mapAttribute && $hasAnyMapAttribute) { + return; + } + + // get all properties + $properties = []; + + foreach ($event->mapperMetadata->targetReflectionClass->getProperties() as $property) { + foreach ($property->getAttributes(Map::class) as $index => $propertyAttribute) { + /** @var Map $attribute */ + $attribute = $propertyAttribute->newInstance(); + $reference = new Reference(Map::class, $index, $event->mapperMetadata->targetReflectionClass->getName(), propertyName: $property->getName()); + $propertyMetadata = new PropertyMetadataEvent( + /* + * public ?string $if = null,// @TODO + */ + $event->mapperMetadata, + new SourcePropertyMetadata($attribute->source ?? $property->getName()), + new TargetPropertyMetadata($property->getName()), + transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->targetReflectionClass->getName(), $attribute, $reference, false), + ); + + $ifCallableName = null; + + if ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) { + $propertyMetadata->if = $ifCallableName; + } elseif (\is_string($attribute->if)) { + $propertyMetadata->if = $attribute->if; + } + + $properties[] = $propertyMetadata; + } + } + + $event->properties = [...$event->properties, ...$properties]; + + if ($mapAttribute?->transform) { + $callableName = null; + + if (\is_callable($mapAttribute->transform, false, $callableName)) { + $event->provider = new Provider(Provider::TYPE_CALLABLE, $callableName, true); + } + + if (\is_string($mapAttribute->transform) && $this->serviceLocator->has($mapAttribute->transform)) { + $event->provider = new Provider(Provider::TYPE_SERVICE_CALLABLE, $mapAttribute->transform, true); + } + } + } +} diff --git a/src/Generator/MapMethodStatementsGenerator.php b/src/Generator/MapMethodStatementsGenerator.php index 76e73e02..97fb1b36 100644 --- a/src/Generator/MapMethodStatementsGenerator.php +++ b/src/Generator/MapMethodStatementsGenerator.php @@ -9,14 +9,17 @@ use AutoMapper\Generator\Shared\DiscriminatorStatementsGenerator; use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; +use AutoMapper\Metadata\Provider; use AutoMapper\Provider\EarlyReturn; use PhpParser\Comment; use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Name; use PhpParser\Node\Scalar; +use PhpParser\Node\StaticVar; use PhpParser\Node\Stmt; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\VarExporter\LazyObjectInterface; /** * @internal @@ -102,6 +105,7 @@ public function getStatements(GeneratorMetadata $metadata, array $duplicatedStat $variableRegistry = $metadata->variableRegistry; $statements = [$this->ifSourceIsNullReturnNull($metadata)]; + $statements = [...$statements, ...$this->isSourceIsLazyProxy($metadata)]; $statements = [...$statements, ...$this->handleCircularReference($metadata)]; if ($this->createObjectStatementsGenerator->canUseTargetToPopulate($metadata)) { @@ -198,6 +202,98 @@ private function ifSourceIsNullReturnNull(GeneratorMetadata $metadata): Stmt ); } + /** + * Return a list of statement to handle lazy ghost objects. + * + * @return Stmt[] + */ + private function isSourceIsLazyProxy(GeneratorMetadata $metadata): array + { + // If there is no source reflection class, we can't handle lazy proxy + if ($metadata->mapperMetadata->sourceReflectionClass === null) { + return []; + } + + /** + * Statements for handling lazy proxy initialization. + * + * ```php + * static $reflectionClass = new \ReflectionClass($source); + * + * if ($reflectionClass->isUninitializedLazyObject($source)) { + * $refl->initializeLazyObject($source); + * } + */ + $reflectionClassVar = new Expr\Variable('reflectionClass'); + + $statements = [ + new Stmt\Static_([new StaticVar( + $reflectionClassVar, + new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ + new Arg(new Scalar\String_( + $metadata->mapperMetadata->sourceReflectionClass->getName() + )), + ] + ))]), + new Stmt\If_(new Expr\MethodCall($reflectionClassVar, 'isUninitializedLazyObject', [ + new Arg($metadata->variableRegistry->getSourceInput()), + ]), [ + 'stmts' => [ + new Stmt\Expression( + new Expr\MethodCall($reflectionClassVar, 'initializeLazyObject', [ + new Arg($metadata->variableRegistry->getSourceInput()), + ]) + ), + ], + ]), + ]; + + if (interface_exists(LazyObjectInterface::class)) { + /** + * ```php + * if ($source instanceof LazyObjectInterface) { + * $source->initializeLazyObject(); + * } else { + * ... + * } + * ```. + */ + $statements = [ + new Stmt\If_(new Expr\Instanceof_($metadata->variableRegistry->getSourceInput(), new Name\FullyQualified(LazyObjectInterface::class)), [ + 'stmts' => [ + new Stmt\Expression( + new Expr\MethodCall($metadata->variableRegistry->getSourceInput(), 'initializeLazyObject') + ), + ], + 'else' => new Stmt\Else_($statements), + ]), + ]; + } + + /** + * ```php + * if ($context[MapperContext::INITIALIZE_LAZY_OBJECT] ?? false) { + * ... + * } + * ```. + */ + + return [ + new Stmt\If_( + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch( + $metadata->variableRegistry->getContext(), + new Scalar\String_(MapperContext::INITIALIZE_LAZY_OBJECT) + ), + new Expr\ConstFetch(new Name('false')) + ), + [ + 'stmts' => $statements, + ] + ), + ]; + } + /** * When there can be circular dependency in the mapping, * the following statements try to use the reference for the source if it's available. @@ -326,46 +422,91 @@ private function initializeTargetFromProvider(GeneratorMetadata $metadata): arra $variableRegistry = $metadata->variableRegistry; + if ($metadata->provider->isFromObjectMapper) { + // When the provider is from the ObjectMapper, we call it with 3 arguments + /* + * $result ?? (new ReflectionClass($metadata->mapperMetadata->target))->newInstanceWithoutConstructor(); + */ + $args = [ + new Arg(new Expr\BinaryOp\Coalesce($variableRegistry->getResult(), new Expr\MethodCall( + new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ + new Arg(new Scalar\String_($metadata->mapperMetadata->target)), + ]), + 'newInstanceWithoutConstructor' + ))), + new Arg($variableRegistry->getSourceInput()), + new Arg(new Expr\ConstFetch(new Name('null'))), + ]; + } else { + $args = [ + 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')), + ])), + ]; + } + + if ($metadata->provider->type === Provider::TYPE_CALLABLE) { + /* + * Get result from callable if available + * + * ```php + * callable(Target::class, $value, $context, $this->getTargetIdentifiers($value)); + * ``` + */ + $providerExpression = new Expr\FuncCall(new Name($metadata->provider->value), $args); + } elseif ($metadata->provider->type === Provider::TYPE_SERVICE_CALLABLE) { + /* + * Get result from provider if available + * + * ```php + * $this->serviceLocator->get($metadata->provider)($source, $context); + * ``` + */ + $providerExpression = new Expr\FuncCall(new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'serviceLocator'), 'get', [ + new Arg(new Scalar\String_($metadata->provider->value)), + ]), $args); + } else { + /* + * Get result from provider if available + * + * ```php + * $this->serviceLocator->get($metadata->provider)->provide($source, $context); + * ``` + */ + $providerExpression = new Expr\MethodCall(new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'serviceLocator'), 'get', [ + new Arg(new Scalar\String_($metadata->provider->value)), + ]), 'provide', $args); + } + /* - * Get result from provider if available - * - * ```php - * $result ??= $this->providerRegistry->getProvider($metadata->provider)->provide($source, $context); + * $result ??= provider(...); * * 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'), 'serviceLocator'), 'get', [ - 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)), - [ - 'stmts' => [ - new Stmt\Return_( - new Expr\PropertyFetch($variableRegistry->getResult(), 'value') - ), - ], - ] - ); - - return $statements; + return [ + new Stmt\Expression( + new Expr\AssignOp\Coalesce( + $variableRegistry->getResult(), + $providerExpression, + ) + ), + new Stmt\If_( + new Expr\Instanceof_($variableRegistry->getResult(), new Name(EarlyReturn::class)), + [ + 'stmts' => [ + new Stmt\Return_( + new Expr\PropertyFetch($variableRegistry->getResult(), 'value') + ), + ], + ] + ), + ]; } /** diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 529c7ce2..d512a69d 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -4,6 +4,8 @@ namespace AutoMapper\Generator; +use AutoMapper\AttributeReference\AttributeInstance; +use AutoMapper\AttributeReference\Reference; use AutoMapper\Exception\CompileException; use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; @@ -16,6 +18,7 @@ use PhpParser\Parser; use PhpParser\ParserFactory; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ObjectMapper\ConditionCallableInterface; use function AutoMapper\PhpParser\create_expr_array_item; use function AutoMapper\PhpParser\create_scalar_int; @@ -246,6 +249,32 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ } $callableName = null; + $value = $metadata->variableRegistry->getSourceInput(); + $input = null; + + // use read accessor + if ($propertyMetadata->source->accessor !== null) { + $input = $propertyMetadata->source->accessor->getExpression($metadata->variableRegistry->getSourceInput()); + } + + if ($propertyMetadata->if instanceof Reference) { + $refExpr = $propertyMetadata->if->getReferenceExpression(); + + /** (AttributeInstance::get($attributeClassName, $index, $reference)->transformer)(...) */ + return new Expr\FuncCall(new Expr\PropertyFetch(new Expr\StaticCall( + new Name\FullyQualified(AttributeInstance::class), + 'get', + [ + new Arg(new Scalar\String_($propertyMetadata->if->attributeClassName)), + new Arg($refExpr), + new Arg(new Scalar\Int_($propertyMetadata->if->attributeIndex)), + ] + ), 'if'), [ + new Arg($value), + new Arg($input ?? new Expr\ConstFetch(new Name('null'))), + new Arg(new Expr\Variable('context')), + ]); + } if (\is_callable($propertyMetadata->if, false, $callableName)) { if (\function_exists($callableName)) { @@ -257,12 +286,22 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ return new Expr\FuncCall( new Name($callableName), [ - new Arg(new Expr\Variable('value')), + new Arg($value), ] ); } - if ($argumentsCount > 2) { + if ($argumentsCount === 2) { + return new Expr\FuncCall( + new Name($callableName), + [ + new Arg($value), + new Arg(new Expr\Variable('context')), + ] + ); + } + + if ($argumentsCount > 3) { throw new CompileException('Callable condition must have 1 or 2 arguments required, but it has ' . $argumentsCount); } } @@ -270,7 +309,8 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ return new Expr\FuncCall( new Name($callableName), [ - new Arg(new Expr\Variable('value')), + new Arg($value), + new Arg($input ?? new Expr\ConstFetch(new Name('null'))), new Arg(new Expr\Variable('context')), ] ); @@ -284,22 +324,45 @@ 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($input ?? new Expr\ConstFetch(new Name('null'))), 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')), + // pass null as value if there is no read accessor + new Arg($input ?? new Expr\ConstFetch(new Name('null'))), new Arg(new Expr\Variable('context')), ] ); } + if (class_exists($propertyMetadata->if) && is_subclass_of($propertyMetadata->if, ConditionCallableInterface::class)) { + return new Expr\MethodCall( + new Expr\NullsafeMethodCall( + new Expr\PropertyFetch( + new Expr\Variable('this'), + 'serviceLocator' + ), + 'get', + [ + new Arg(new Scalar\String_($propertyMetadata->if)), + ] + ), + '__invoke', + [ + new Arg($input ?? $value), + new Arg($value), + new Arg(new Expr\Variable('result')), + ] + ); + } + $expression = $this->expressionLanguage->compile($propertyMetadata->if, ['value' => 'source', 'context']); $expr = $this->parser->parse(', - * "normalizer_format"?: string + * "normalizer_format"?: string, + * "initialize_lazy_object"?: bool, + * "lazy_mapping"?: bool, * } */ class MapperContext { - public const GROUPS = 'groups'; - public const ALLOWED_ATTRIBUTES = 'allowed_attributes'; - public const IGNORED_ATTRIBUTES = 'ignored_attributes'; - public const CIRCULAR_REFERENCE_LIMIT = 'circular_reference_limit'; - public const CIRCULAR_REFERENCE_HANDLER = 'circular_reference_handler'; - public const CIRCULAR_REFERENCE_REGISTRY = 'circular_reference_registry'; - public const CIRCULAR_COUNT_REFERENCE_REGISTRY = 'circular_count_reference_registry'; - public const DEPTH = 'depth'; - public const TARGET_TO_POPULATE = 'target_to_populate'; - public const DEEP_TARGET_TO_POPULATE = 'deep_target_to_populate'; - public const CONSTRUCTOR_ARGUMENTS = 'constructor_arguments'; - public const SKIP_NULL_VALUES = 'skip_null_values'; - public const SKIP_UNINITIALIZED_VALUES = 'skip_uninitialized_values'; - public const ALLOW_READONLY_TARGET_TO_POPULATE = 'allow_readonly_target_to_populate'; - public const DATETIME_FORMAT = 'datetime_format'; - public const DATETIME_FORCE_TIMEZONE = 'datetime_force_timezone'; - public const MAP_TO_ACCESSOR_PARAMETER = 'map_to_accessor_parameter'; - public const NORMALIZER_FORMAT = 'normalizer_format'; - public const LAZY_MAPPING = 'lazy_mapping'; + public const string GROUPS = 'groups'; + public const string ALLOWED_ATTRIBUTES = 'allowed_attributes'; + public const string IGNORED_ATTRIBUTES = 'ignored_attributes'; + public const string CIRCULAR_REFERENCE_LIMIT = 'circular_reference_limit'; + public const string CIRCULAR_REFERENCE_HANDLER = 'circular_reference_handler'; + public const string CIRCULAR_REFERENCE_REGISTRY = 'circular_reference_registry'; + public const string CIRCULAR_COUNT_REFERENCE_REGISTRY = 'circular_count_reference_registry'; + public const string DEPTH = 'depth'; + public const string TARGET_TO_POPULATE = 'target_to_populate'; + public const string DEEP_TARGET_TO_POPULATE = 'deep_target_to_populate'; + public const string CONSTRUCTOR_ARGUMENTS = 'constructor_arguments'; + public const string SKIP_NULL_VALUES = 'skip_null_values'; + public const string SKIP_UNINITIALIZED_VALUES = 'skip_uninitialized_values'; + public const string ALLOW_READONLY_TARGET_TO_POPULATE = 'allow_readonly_target_to_populate'; + public const string DATETIME_FORMAT = 'datetime_format'; + public const string DATETIME_FORCE_TIMEZONE = 'datetime_force_timezone'; + public const string MAP_TO_ACCESSOR_PARAMETER = 'map_to_accessor_parameter'; + public const string NORMALIZER_FORMAT = 'normalizer_format'; + public const string INITIALIZE_LAZY_OBJECT = 'initialize_lazy_object'; + public const string LAZY_MAPPING = 'lazy_mapping'; /** @var MapperContextArray */ private array $context = [ diff --git a/src/Metadata/GeneratorMetadata.php b/src/Metadata/GeneratorMetadata.php index 127251d2..79c21bc0 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 ?Provider $provider = null, public readonly ?Discriminator $sourceDiscriminator = null, public readonly ?Discriminator $targetDiscriminator = null, ) { diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 52932b7c..97021154 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -43,7 +43,7 @@ public function __construct( } /** @var class-string $className */ - $className = \sprintf('%s%s_%s', $this->classPrefix, str_replace('\\', '_', $this->source), str_replace('\\', '_', $this->target)); + $className = \sprintf('%s%s_%s', $this->classPrefix, $this->formatSourceTarget($this->source, $this->sourceReflectionClass?->isAnonymous() ?? false), $this->formatSourceTarget($this->target, $this->targetReflectionClass?->isAnonymous() ?? false)); $this->className = $className; } @@ -71,4 +71,13 @@ public function getHash(): string return $hash; } + + private function formatSourceTarget(string $name, bool $isAnonymous): string + { + if ($isAnonymous) { + return 'Anonymous'; + } + + return str_replace('\\', '_', $name); + } } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 34e84c77..87ed385e 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -16,6 +16,8 @@ use AutoMapper\EventListener\MapProviderListener; use AutoMapper\EventListener\MapToContextListener; use AutoMapper\EventListener\MapToListener; +use AutoMapper\EventListener\ObjectMapper\MapSourceListener; +use AutoMapper\EventListener\ObjectMapper\MapTargetListener; use AutoMapper\EventListener\Symfony\ClassDiscriminatorListener; use AutoMapper\EventListener\Symfony\NameConverterListener; use AutoMapper\EventListener\Symfony\SerializerGroupListener; @@ -52,6 +54,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; @@ -389,6 +392,11 @@ 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 MapSourceListener($serviceLocator, $expressionLanguage)); + $eventDispatcher->addListener(GenerateMapperEvent::class, new MapTargetListener($serviceLocator, $expressionLanguage)); + } + // Create transformer factories $factories = [ new DoctrineCollectionTransformerFactory(), diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index a96e8bbd..92f43dc7 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -4,6 +4,7 @@ namespace AutoMapper\Metadata; +use AutoMapper\AttributeReference\Reference; use AutoMapper\Transformer\TransformerInterface; /** @@ -25,7 +26,7 @@ public function __construct( public bool $ignored = false, public string $ignoreReason = '', public ?int $maxDepth = null, - public ?string $if = null, + public string|Reference|null $if = null, public ?array $groups = null, public ?bool $disableGroupsCheck = null, public bool $identifier = false, diff --git a/src/Metadata/Provider.php b/src/Metadata/Provider.php new file mode 100644 index 00000000..955a50f6 --- /dev/null +++ b/src/Metadata/Provider.php @@ -0,0 +1,20 @@ +autoMapper = $autoMapper ?? AutoMapper::create(); + } + + public function map(object $source, object|string|null $target = null): object + { + $metadata = $this->metadataFactory->create($source); + $map = $this->getMapTarget($metadata, null, $source, null); + + if (null === $target) { + /** @var class-string|object $target */ + $target = $map?->target; + } + + if ($target && $map && $map->transform) { + // Support only one transform for object mapper at the moment + $transform = \is_array($map->transform) ? $map->transform[0] : $map->transform; + + if ($fn = $this->getCallable($transform)) { + $targetRefl = new \ReflectionClass($target); + + $mappedTarget = $this->call($fn, $targetRefl->newInstanceWithoutConstructor(), $source); + + if (!\is_object($mappedTarget)) { + throw new MappingTransformException(\sprintf('Cannot map "%s" to a non-object target of type "%s".', get_debug_type($source), get_debug_type($mappedTarget))); + } + + if (!is_a($mappedTarget, $targetRefl->getName(), false)) { + throw new MappingException(\sprintf('Expected the mapped object to be an instance of "%s" but got "%s".', $targetRefl->getName(), get_debug_type($mappedTarget))); + } + + $target = $mappedTarget; + } + } + + 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, [ + MapperContext::SKIP_UNINITIALIZED_VALUES => true, + MapperContext::INITIALIZE_LAZY_OBJECT => true, + ]); + } + + /** + * @param callable(mixed $value, object $source, ?object $target): 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) { + /** @var string|callable(mixed $value, object $source, ?object $target):mixed|null $if */ + $if = $mapAttribute->if; + + if ($if && ($fn = $this->getCallable($if)) && !$this->call($fn, $value, $source, $target)) { + continue; + } + + $mapTo = $mapAttribute; + } + + return $mapTo; + } + + /** + * @param (string|callable(mixed $value, object $source, ?object $target): mixed) $fn + */ + private function getCallable(string|callable $fn): ?callable + { + if (\is_callable($fn)) { + return $fn; + } + + if ($this->serviceLocator?->has($fn)) { + /** @var callable(mixed $value, object $source, ?object $target): mixed) */ + return $this->serviceLocator->get($fn); + } + + return null; + } +} diff --git a/src/Transformer/ObjectMapper/TransformCallableTransformer.php b/src/Transformer/ObjectMapper/TransformCallableTransformer.php new file mode 100644 index 00000000..dab35689 --- /dev/null +++ b/src/Transformer/ObjectMapper/TransformCallableTransformer.php @@ -0,0 +1,41 @@ + $transformCallable + */ + public function __construct( + private TransformCallableInterface $transformCallable, + ) { + } + + /** + * @param T|array $source + * + * @return ?T2 + */ + public function transform(mixed $value, object|array $source, array $context): mixed + { + if (!\is_object($source)) { + return null; + } + + /** @var ?T2 $target */ + $target = \is_object($context['target'] ?? null) ? $context['target'] : null; + + /** @var ?T2 */ + return ($this->transformCallable)($value, $source, $target); + } +} diff --git a/src/Transformer/ReferenceTransformer.php b/src/Transformer/ReferenceTransformer.php index 54946d8b..3e3a0242 100644 --- a/src/Transformer/ReferenceTransformer.php +++ b/src/Transformer/ReferenceTransformer.php @@ -6,7 +6,6 @@ use AutoMapper\AttributeReference\AttributeInstance; use AutoMapper\AttributeReference\Reference; -use AutoMapper\AttributeReference\ReflectionReference; use AutoMapper\Generator\UniqueVariableScope; use AutoMapper\Metadata\PropertyMetadata; use PhpParser\Node\Arg; @@ -18,40 +17,26 @@ { public function __construct( private Reference $reference, + private bool $objectMapperTransformer = false, ) { } public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { - if ($this->reference->methodName) { - /** ReflectionReference::fromMethod($className, $methodName) */ - $reflectionReferenceExpr = new Expr\StaticCall( - new Name\FullyQualified(ReflectionReference::class), - 'fromMethod', - [ - new Arg(new Scalar\String_($this->reference->className)), - new Arg(new Scalar\String_($this->reference->methodName)), - ] - ); - } elseif ($this->reference->propertyName) { - /** ReflectionReference::fromProperty($className, $propertyName) */ - $reflectionReferenceExpr = new Expr\StaticCall( - new Name\FullyQualified(ReflectionReference::class), - 'fromProperty', - [ - new Arg(new Scalar\String_($this->reference->className)), - new Arg(new Scalar\String_($this->reference->propertyName)), - ] - ); + $reflectionReferenceExpr = $this->reference->getReferenceExpression(); + + if ($this->objectMapperTransformer) { + $args = [ + new Arg($input), + new Arg($source), + new Arg(new Expr\ConstFetch(new Name('null'))), + ]; } else { - /** ReflectionReference::fromClass($className) */ - $reflectionReferenceExpr = new Expr\StaticCall( - new Name\FullyQualified(ReflectionReference::class), - 'fromClass', - [ - new Arg(new Scalar\String_($this->reference->className)), - ] - ); + $args = [ + new Arg($input), + new Arg($source), + new Arg(new Expr\Variable('context')), + ]; } /** (AttributeInstance::get($attributeClassName, $index, $reference)->transformer)(...) */ @@ -64,11 +49,7 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM new Arg($reflectionReferenceExpr), new Arg(new Scalar\Int_($this->reference->attributeIndex)), ] - ), 'transformer'), [ - new Arg($input), - new Arg($source), - new Arg(new Expr\Variable('context')), - ]), [], + ), $this->objectMapperTransformer ? 'transform' : 'transformer'), $args), [], ]; } } diff --git a/src/Transformer/ServiceLocatorTransformer.php b/src/Transformer/ServiceLocatorTransformer.php new file mode 100644 index 00000000..f89e5cf9 --- /dev/null +++ b/src/Transformer/ServiceLocatorTransformer.php @@ -0,0 +1,50 @@ +serviceLocator?->get($serviceId)->__invoke($value, $source, $context) */ + return [ + new Expr\MethodCall( + new Expr\MethodCall( + new Expr\NullsafePropertyFetch( + new Expr\Variable('this'), + 'serviceLocator' + ), + 'get', + [ + new Arg(new Scalar\String_($this->serviceId)), + ] + ), + '__invoke', + [ + new Arg($input), + new Arg($source), + new Arg(new Expr\Variable('result')), + ] + ), + [], + ]; + } +} diff --git a/tests/AutoMapperBuilder.php b/tests/AutoMapperBuilder.php index fa51d1ea..03158e4d 100644 --- a/tests/AutoMapperBuilder.php +++ b/tests/AutoMapperBuilder.php @@ -29,6 +29,7 @@ public static function buildAutoMapper( ?ExpressionLanguageProvider $expressionLanguageProvider = null, EventDispatcherInterface $eventDispatcher = new EventDispatcher(), ?ObjectManager $objectManager = null, + array $extraServices = [], ): AutoMapper { $skipCacheRemove = $_SERVER['SKIP_CACHE_REMOVE'] ?? false; @@ -53,6 +54,7 @@ classPrefix: $classPrefix, eventDispatcher: $eventDispatcher, providers: $providers, objectManager: $objectManager, + extraMapperServices: $extraServices, ); } } diff --git a/tests/ObjectMapper/Fixtures/A.php b/tests/ObjectMapper/Fixtures/A.php new file mode 100644 index 00000000..52bdb697 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/A.php @@ -0,0 +1,50 @@ + + * + * 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..3b310705 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/B.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; + +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..73a6c665 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/C.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; + +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..aac925c2 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ClassWithoutTarget.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; + +class ClassWithoutTarget +{ +} diff --git a/tests/ObjectMapper/Fixtures/D.php b/tests/ObjectMapper/Fixtures/D.php new file mode 100644 index 00000000..027c6f84 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/D.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; + +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..7fbc649f --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/Recursive.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\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..fcdf69b1 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/RecursiveDto.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; + +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..3d001f3b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/Relation.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\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..d5a133d0 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DeeperRecursion/RelationDto.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\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..9f4cfea1 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/DefaultLazy/OrderSource.php @@ -0,0 +1,14 @@ +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..143530f1 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/EmbeddedMapping/UserDto.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\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..808feb50 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Flatten/User.php @@ -0,0 +1,28 @@ + + * + * 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..7b47530d --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Flatten/UserProfile.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\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..1f29ff5a --- /dev/null +++ b/tests/ObjectMapper/Fixtures/HydrateObject/SourceOnly.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\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..b849456d --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/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\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..10ad7569 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/B.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\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..0d4733e4 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/C.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\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..d69c714b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InitializedConstructor/D.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\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..8dbb3aca --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallback/A.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\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..a616e1b3 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallback/B.php @@ -0,0 +1,34 @@ + + * + * 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..e0a5f65b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/A.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\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..d89f8a2c --- /dev/null +++ b/tests/ObjectMapper/Fixtures/InstanceCallbackWithArguments/B.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\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..8a3c3c59 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/LazyFoo.php @@ -0,0 +1,40 @@ + + * + * 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..16386a7c --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/AToBMapper.php @@ -0,0 +1,32 @@ + + * + * 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\ObjectMapperInterface; + +#[Map(source: Source::class, target: Target::class)] +class AToBMapper implements ObjectMapperInterface +{ + public function __construct( + private readonly ObjectMapperInterface $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..067ef38b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/Map.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\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..bd6b91b6 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/MapStructMapperMetadataFactory.php @@ -0,0 +1,56 @@ + + * + * 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..699fcb6f --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/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\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..ee2325c4 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapStruct/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\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..fe6392a9 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapTargetToSource/A.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; + +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..9fd2e2c9 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MapTargetToSource/B.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\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..a1b813e3 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/A.php @@ -0,0 +1,28 @@ + + * + * 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..a3413b2d --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/B.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 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..a3f27f1b --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargetProperty/C.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\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..d71b74c0 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/A.php @@ -0,0 +1,36 @@ + + * + * 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..180e4616 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/B.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\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..5daa2407 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MultipleTargets/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\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..bf43d500 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/MyProxy.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 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..24997418 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PartialInput/FinalInput.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\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..33e4e0ac --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructor/Target.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\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..18205dc2 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Source.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; + +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..cc7f8402 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/PromotedConstructorWithMetadata/Target.php @@ -0,0 +1,28 @@ + + * + * 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..0b0cf25a --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ReadOnlyPromotedProperty/ReadOnlyPromotedPropertyA.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; + +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..680f2b91 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/Recursion/Dto.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\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..6dd818e8 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValue.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\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..d87eec8e --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueService.php @@ -0,0 +1,32 @@ + + * + * 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..82690d9d --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/LoadedValueTarget.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\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..ba15811f --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ServiceLoadedValueTransformer.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\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..fd716991 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLoadedValue/ValueToMap.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\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..aa72e5fc --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/B.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\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..7bd1f381 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/ConditionCallable.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\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..0af0caf1 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/ServiceLocator/TransformCallable.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\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..9ea179aa --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TargetTransform/SourceEntity.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\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..8935d180 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TargetTransform/TargetDto.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\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..6909b3d5 --- /dev/null +++ b/tests/ObjectMapper/Fixtures/TransformCollection/TransformCollectionA.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; + +use AutoMapper\ObjectMapper\ObjectMapper; +use AutoMapper\Tests\AutoMapperBuilder; +use AutoMapper\Tests\AutoMapperTestCase; +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\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 PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +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\ObjectMapperInterface; +use Symfony\Component\PropertyAccess\PropertyAccess; + +final class ObjectMapperTest extends AutoMapperTestCase +{ + protected LoadedValueService $service; + + protected function setUp(): void + { + parent::setUp(); + + $this->service = new LoadedValueService(); + } + + #[DataProvider('mapProvider')] + public function testMap($expect, $args, array $deps = []) + { + $mapper = $this->createObjectMapper(); + $mapped = $mapper->map(...$args); + + $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 = false; + $b->concat = 'shouldtestme'; + $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".'); + $this->createObjectMapper()->map(new class {}); + } + + public function testHasNothingToMapToWithNamedClass() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Mapping target not found for source "%s".', ClassWithoutTarget::class)); + $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)); + $this->createObjectMapper()->map(new ClassWithoutTarget(), 'InexistantClass'); + } + + public function testRecursion() + { + $ab = new AB(); + $ab->ab = $ab; + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $b = $mapper->map($a, InitializedConstructorB::class); + $this->assertInstanceOf(InitializedConstructorB::class, $b); + $this->assertEquals($b->tags, ['foo', 'bar']); + } + + public function testMapReliesOnConstructorsOwnInitialization() + { + $expected = 'bar'; + + $mapper = $this->createObjectMapper(); + + $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 = $this->createObjectMapper(); + + $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 = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $b = $mapper->map($a); + $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b); + $this->assertSame($a, $b->transformSource); + } + + public function testMultipleMapProperty() + { + $u = new User(email: 'hello@example.com', profile: new UserProfile(firstName: 'soyuka', lastName: 'arakusa')); + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + + $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'); + } + + public function testSourceOnly() + { + $a = new \stdClass(); + $a->name = 'test'; + $mapper = $this->createObjectMapper(); + $mapped = $mapper->map($a, SourceOnly::class); + $this->assertInstanceOf(SourceOnly::class, $mapped); + $this->assertSame('test', $mapped->mappedName); + } + + public function testSourceOnlyWithMagicMethods() + { + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $b = $mapper->map($a, MapTargetToSourceB::class); + $this->assertInstanceOf(MapTargetToSourceB::class, $b); + $this->assertSame('str', $b->target); + } + + public function testMultipleTargetMapProperty() + { + $u = new MultipleTargetPropertyA(); + + $mapper = $this->createObjectMapper(); + $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->markTestSkipped('This use case is supported by AutoMapper, as we skip non existing properties by default.'); + + $this->expectException(NoSuchPropertyException::class); + $u = new \stdClass(); + $u->id = 'abc'; + $mapper = $this->createObjectMapper(); + $b = $mapper->map($u, TargetDto::class); + } + + public function testDefaultValueStdClassWithPropertyInfo() + { + $u = new \stdClass(); + $u->id = 'abc'; + $mapper = $this->createObjectMapper(); + $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()]; + } + + public function testMapInitializesLazyObject() + { + $lazy = new LazyFoo(); + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $d = $mapper->map($lazyObj, MyProxy::class); + $this->assertSame('test', $d->name); + $this->assertTrue($initialized); + } + + public function testDecorateObjectMapper() + { + $this->markTestSkipped('This use case is not supported by AutoMapper.'); + + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + + $transformed = $mapper->map($u, TransformCollectionB::class); + + $this->assertEquals([new TransformCollectionD('a'), new TransformCollectionD('b')], $transformed->foo); + } + + #[RequiresPhp('>=8.4')] + public function testEmbedsAreLazyLoadedByDefault() + { + $this->markTestSkipped('Lazy Loading is not enable by default and works differently.'); + + $mapper = $this->createObjectMapper(); + $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() + { + $mapper = $this->createObjectMapper(); + + $value = new ValueToMap(); + $value->relation = new ValueToMapRelation('test'); + + $result = $mapper->map($value); + $refl = new \ReflectionClass($result->relation); + $this->assertFalse($refl->isUninitializedLazyObject($result->relation)); + + $this->assertSame($result->relation, $this->service->get()); + $this->assertSame('test', $result->relation->name); + } + + public function testMapEmbeddedProperties() + { + $dto = new UserDto( + userAddressZipcode: '12345', + userAddressCity: 'Test City', + name: 'John Doe' + ); + + $mapper = $this->createObjectMapper(); + $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 = $this->createObjectMapper(); + $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); + } + + public function createObjectMapper(): ObjectMapperInterface + { + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $this->service->load(); + + return new ObjectMapper(autoMapper: AutoMapperBuilder::buildAutoMapper(extraServices: [ + new TransformCallable(), + new ConditionCallable(), + new ServiceLoadedValueTransformer($this->service, $metadataFactory), + ])); + } +}