diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f398ae4..b41098ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased - -## [10.2.0] - 2026-27-04 +### Fixed +- Use the `MapFrom` attribute reference instead of `MapTo` when resolving transformers in `MapFromListener` +- Do not run a transformation on a null source value when the target is not nullable, a `TypeError` is thrown for typed properties instead of the transformation crashing on the null value +- Create backed enum from scalar source value instead of assigning the raw scalar +- Convert between different backed enums through their backing value instead of copying the source instance +- Normalize leading backslash in class names resolved from docblocks, fixing a parse error in generated mappers for global namespace classes and the `__PM__` proxy name off-by-one +- Implement `CheckTypeInterface` on enum, date-time and uid transformers so union source types generate a runtime check for every branch +- Unwrap nullable doctrine collection target type, items are mapped to the collection value type instead of plain arrays +- Capture the context in the `isAllowedAttribute` closure, fixing `MapToContext` combined with `skip_null_values` +- Guard nested property accessors (`parent.child`) against null or uninitialized parent values on read, write and constructor paths, support private nested leaf properties and nullable parent types +- Support variadic constructor parameters, values are spread as individual arguments and an absent variadic no longer throws `MissingConstructorArgumentsException` +- The highest priority `Mapper` attribute wins as documented, instead of the lowest +- Correct identifier hashing: scalar identifiers are cast to string, object identifiers are hashed through their own mapper and `hash_final` no longer receives a string as its boolean argument, fixing mappers generated with `strictTypes: true` +- Atomic mapper file writes and locked registry updates in `FileLoader`, fixing partially written files and lost registry entries under concurrency, the cache warmer no longer discards previously registered mapper hashes +- Initialize `LazyMap` only once instead of re-running the mapping on every access +- Align `Map` attribute `if` condition semantics with symfony/object-mapper: `if: false` never maps the property, callables receive the value first and class level `Map` targets also match child classes +- Do not register the cache warmer when the `eval` loader is enabled, fixing container compilation with `automapper.loader.eval: true` +- Detect doctrine entities even when their metadata is not loaded yet, fixing missing provider and identifier on cold metadata factories +- Do not map static properties +- Keep the concrete Symfony Uid subclass (e.g. `UuidV4`) when copying a uid instead of building a base `Uuid`/`Ulid` +- Keep the concrete date time subclass in date time to date time mapping instead of always building `DateTime`/`DateTimeImmutable` +- Guard a single matching union branch at runtime so a value of an unhandled union member is not blindly transformed +- The `AutoMapperNormalizer` no longer claims value objects handled by dedicated symfony normalizers (`DateTimeInterface`, `BackedEnum`, `AbstractUid`, etc.), which could produce a structure dump instead of the expected representation +- Use the computed resource class in the API Platform JSON-LD context transformer instead of resolving it from a non-resource source (operator precedence fix) +- Only shorten an API Platform resource to its id for `array` or `mixed` targets, avoiding a `TypeError` on scalar targets +- Allow `final` and `static` public getters to be used with `#[MapToContext]` +- Run the object mapper listeners after the Doctrine and discriminator listeners in the bundle, so a `Map`-attributed source class keeps its Doctrine provider and discriminator handling + +## [10.2.0] - 2026-04-27 ### Added - [GH#336](https://github.com/jolicode/automapper/pull/336) Support for array shape @@ -15,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [GH#340](https://github.com/jolicode/automapper/pull/340) Allow type resolver in 2.0 for dependencies - [GH#341](https://github.com/jolicode/automapper/pull/341) Fix phpstan extractor not using private properties in bundle -## [10.1.0] - 2026-18-03 +## [10.1.0] - 2026-03-18 ### Added - [GH#328](https://github.com/jolicode/automapper/pull/328) Register mappers as lazy to reduce initialization calls when sub mappers are not used @@ -28,18 +55,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [GH#331](https://github.com/jolicode/automapper/pull/331) Fix syntax error in supports method return statement example - [GH#329](https://github.com/jolicode/automapper/pull/329) Fix some typos -## [10.0.3] - 2025-25-02 +## [10.0.3] - 2025-02-25 - [GH#327](https://github.com/jolicode/automapper/pull/327) Fix cache extractor in symfony bundle being mixed between source and target. -## [10.0.2] - 2025-24-02 +## [10.0.2] - 2025-02-24 ### Fixed - [GH#326](https://github.com/jolicode/automapper/pull/326) Fix array, be consistent with old behavior, undefined array should be mapped with their keys. -## [10.0.1] - 2025-24-02 +## [10.0.1] - 2025-02-24 ### Fixed - [GH#325](https://github.com/jolicode/automapper/pull/325) Fix creating union or intersection type when not enough types. -## [10.0.0] - 2025-10-02 +## [10.0.0] - 2025-02-10 ### Added - [GH#297](https://github.com/jolicode/automapper/pull/297) Support PHP 8.5 and Symfony 8, this library now use the `TypeInfo` Component for types instead of PropertyInfo directly. - [GH#297](https://github.com/jolicode/automapper/pull/297) Debug command now show the type of each property mapped, transformers will also display more information. diff --git a/phpstan.neon b/phpstan.neon index 1b7f6dde..1a1ca415 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -35,15 +35,3 @@ parameters: identifier: function.alreadyNarrowedType count: 1 path: src/Generator/PropertyConditionsGenerator.php - - - - message: '#^Method AutoMapper\\Loader\\FileLoader\:\:getRegistry\(\) should return array\ but returns array\.$#' - identifier: return.type - count: 1 - path: src/Loader/FileLoader.php - - - - message: '#^Property AutoMapper\\Loader\\FileLoader\:\:\$registry \(array\\) does not accept mixed\.$#' - identifier: assign.propertyType - count: 1 - path: src/Loader/FileLoader.php diff --git a/src/EventListener/Doctrine/DoctrineIdentifierListener.php b/src/EventListener/Doctrine/DoctrineIdentifierListener.php index 3e2d391c..bdb68d03 100644 --- a/src/EventListener/Doctrine/DoctrineIdentifierListener.php +++ b/src/EventListener/Doctrine/DoctrineIdentifierListener.php @@ -16,7 +16,8 @@ public function __construct( public function __invoke(PropertyMetadataEvent $event): void { - if ($event->mapperMetadata->target === 'array' || !$this->objectManager->getMetadataFactory()->hasMetadataFor($event->mapperMetadata->target)) { + // isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones + if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) { return; } diff --git a/src/EventListener/Doctrine/DoctrineProviderListener.php b/src/EventListener/Doctrine/DoctrineProviderListener.php index 0d127758..3803d9f2 100644 --- a/src/EventListener/Doctrine/DoctrineProviderListener.php +++ b/src/EventListener/Doctrine/DoctrineProviderListener.php @@ -18,7 +18,8 @@ public function __construct( public function __invoke(GenerateMapperEvent $event): void { - if ($event->mapperMetadata->target === 'array' || !$this->objectManager->getMetadataFactory()->hasMetadataFor($event->mapperMetadata->target)) { + // isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones + if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) { return; } diff --git a/src/EventListener/MapFromListener.php b/src/EventListener/MapFromListener.php index 5dbf80ad..9688569d 100644 --- a/src/EventListener/MapFromListener.php +++ b/src/EventListener/MapFromListener.php @@ -5,7 +5,6 @@ namespace AutoMapper\EventListener; use AutoMapper\Attribute\MapFrom; -use AutoMapper\Attribute\MapTo; use AutoMapper\AttributeReference\Reference; use AutoMapper\Event\GenerateMapperEvent; use AutoMapper\Event\PropertyMetadataEvent; @@ -48,13 +47,13 @@ public function __invoke(GenerateMapperEvent $event): void } $property = $mapFromAttributeInstance->property; - $reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getName()); + $reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getName()); } elseif ($reflectionClassOrPropertyOrMethod instanceof \ReflectionMethod) { $property = $this->getPropertyName($reflectionClassOrPropertyOrMethod->getName(), $properties); - $reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), methodName: $reflectionClassOrPropertyOrMethod->getName()); + $reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), methodName: $reflectionClassOrPropertyOrMethod->getName()); } else { $property = $reflectionClassOrPropertyOrMethod->getName(); - $reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), propertyName: $reflectionClassOrPropertyOrMethod->getName()); + $reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), propertyName: $reflectionClassOrPropertyOrMethod->getName()); } if (null === $property) { diff --git a/src/EventListener/MapToContextListener.php b/src/EventListener/MapToContextListener.php index 488eec65..c6ca68a0 100644 --- a/src/EventListener/MapToContextListener.php +++ b/src/EventListener/MapToContextListener.php @@ -47,7 +47,8 @@ public function __invoke(PropertyMetadataEvent $event): void $reflectionMethod = $reflectionClass->getMethod($methodName); - if ($reflectionMethod->getModifiers() !== \ReflectionMethod::IS_PUBLIC) { + // getModifiers() is a bitmask, comparing it to IS_PUBLIC would reject final or static public methods + if (!$reflectionMethod->isPublic()) { continue; } diff --git a/src/EventListener/MapperListener.php b/src/EventListener/MapperListener.php index bcd6081f..6bc29071 100644 --- a/src/EventListener/MapperListener.php +++ b/src/EventListener/MapperListener.php @@ -104,8 +104,8 @@ private function getMapperAttribute(GenerateMapperEvent $event, bool $allowParen return [null, false, false]; } - // sort by priority - usort($mappers, static fn (array $a, array $b) => $a[0]->priority <=> $b[0]->priority); + // sort by priority, the highest priority wins + usort($mappers, static fn (array $a, array $b) => $b[0]->priority <=> $a[0]->priority); return $mappers[0]; } diff --git a/src/EventListener/ObjectMapper/MapSourceListener.php b/src/EventListener/ObjectMapper/MapSourceListener.php index fcc657b2..2350cd20 100644 --- a/src/EventListener/ObjectMapper/MapSourceListener.php +++ b/src/EventListener/ObjectMapper/MapSourceListener.php @@ -30,7 +30,7 @@ public function __invoke(GenerateMapperEvent $event): void $attribute = $sourceAttribute->newInstance(); $hasAnyMapAttribute = true; - if (!$attribute->target || $attribute->target === $event->mapperMetadata->target) { + if (!$attribute->target || $attribute->target === $event->mapperMetadata->target || is_subclass_of($event->mapperMetadata->target, $attribute->target)) { $mapAttribute = $attribute; break; } @@ -59,9 +59,11 @@ public function __invoke(GenerateMapperEvent $event): void transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->sourceReflectionClass->getName(), $attribute, $reference, true), ); - $ifCallableName = null; - - if ($attribute->if instanceof TargetClass) { + if (false === $attribute->if) { + // symfony/object-mapper never maps a property with `if: false` + $propertyMetadata->ignored = true; + $propertyMetadata->ignoreReason = 'Property is ignored by Map Attribute if condition'; + } elseif ($attribute->if instanceof TargetClass) { $reflectionObject = new \ReflectionClass($attribute->if); if ($reflectionObject->hasProperty('className')) { @@ -95,12 +97,10 @@ public function __invoke(GenerateMapperEvent $event): void continue; } } - } elseif ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) { - if (\is_object($attribute->if)) { - $propertyMetadata->if = $reference; - } else { - $propertyMetadata->if = $ifCallableName; - } + } elseif ($attribute->if && \is_callable($attribute->if, false)) { + // symfony/object-mapper callables have their own calling convention, keep the + // attribute reference so the generated code can replicate it + $propertyMetadata->if = $reference; } elseif (\is_string($attribute->if)) { $propertyMetadata->if = $attribute->if; } diff --git a/src/EventListener/ObjectMapper/MapTargetListener.php b/src/EventListener/ObjectMapper/MapTargetListener.php index b706463d..12e11c0f 100644 --- a/src/EventListener/ObjectMapper/MapTargetListener.php +++ b/src/EventListener/ObjectMapper/MapTargetListener.php @@ -29,7 +29,7 @@ public function __invoke(GenerateMapperEvent $event): void $attribute = $targetAttribute->newInstance(); $hasAnyMapAttribute = true; - if (!$attribute->source || $attribute->source === $event->mapperMetadata->source) { + if (!$attribute->source || $attribute->source === $event->mapperMetadata->source || is_subclass_of($event->mapperMetadata->source, $attribute->source)) { $mapAttribute = $attribute; } } @@ -57,10 +57,14 @@ public function __invoke(GenerateMapperEvent $event): void transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->targetReflectionClass->getName(), $attribute, $reference, false), ); - $ifCallableName = null; - - if ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) { - $propertyMetadata->if = $ifCallableName; + if (false === $attribute->if) { + // symfony/object-mapper never maps a property with `if: false` + $propertyMetadata->ignored = true; + $propertyMetadata->ignoreReason = 'Property is ignored by Map Attribute if condition'; + } elseif ($attribute->if && \is_callable($attribute->if, false)) { + // symfony/object-mapper callables have their own calling convention, keep the + // attribute reference so the generated code can replicate it + $propertyMetadata->if = $reference; } elseif (\is_string($attribute->if)) { $propertyMetadata->if = $attribute->if; } diff --git a/src/Extractor/ArrayReadAccessor.php b/src/Extractor/ArrayReadAccessor.php index 49d9b50c..c2fe4083 100644 --- a/src/Extractor/ArrayReadAccessor.php +++ b/src/Extractor/ArrayReadAccessor.php @@ -22,7 +22,7 @@ public function getExpression(Expr $input, bool $target = false): Expr return new Expr\ArrayDimFetch($input, new Scalar\String_($this->property)); } - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): Expr + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): Expr { if ($this->isArrayAccess) { return new Expr\MethodCall($input, 'offsetExists', [new Arg(new Scalar\String_($this->property))]); @@ -35,7 +35,7 @@ public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = fa return new Expr\FuncCall(new Name('array_key_exists'), [new Arg(new Scalar\String_($this->property)), new Arg($input)]); } - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsNullExpression(Expr $input, bool $target = false): Expr { /* * Use the array dim fetch to read the value @@ -45,7 +45,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false): return new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\ArrayDimFetch($input, new Scalar\String_($this->property))])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\ArrayDimFetch($input, new Scalar\String_($this->property)))); } - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr { if ($this->isArrayAccess) { return new Expr\BooleanNot(new Expr\MethodCall($input, 'offsetExists', [new Arg(new Scalar\String_($this->property))])); diff --git a/src/Extractor/MappingExtractor.php b/src/Extractor/MappingExtractor.php index 4eea5248..e5707f9c 100644 --- a/src/Extractor/MappingExtractor.php +++ b/src/Extractor/MappingExtractor.php @@ -44,6 +44,13 @@ public function getProperties(string $class, bool $withConstructorParameters = f $properties = $this->propertyInfoExtractor->getProperties($class) ?? []; + // static properties are not part of an object state, they must not be mapped + $reflectionClass = new \ReflectionClass($class); + $properties = array_values(array_filter( + $properties, + static fn (string $property) => !($reflectionClass->hasProperty($property) && $reflectionClass->getProperty($property)->isStatic()), + )); + if ($withConstructorParameters) { $properties = array_values( array_unique( @@ -95,8 +102,15 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr $type = $this->sourceTypeExtractor->getType($class, $parent); } + // a nullable parent wraps the real type, the nested accessor guards against the null value at runtime + if ($type instanceof Type\NullableType) { + $type = $type->getWrappedType(); + } + $parentAccessor = $this->doGetReadAccessor($class, $parent, $allowExtraProperties); + $childClass = null; + if ($type instanceof Type\ObjectType) { /** @var class-string $childClass */ $childClass = $type->getClassName(); @@ -111,7 +125,7 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr return null; } - return new NestedReadAccessor($parentAccessor, $childAccessor); + return new NestedReadAccessor($parentAccessor, $childAccessor, $childClass, $property); } public function getWriteMutator(string $source, string $target, string $property, array $context = [], bool $allowExtraProperties = false): ?WriteMutatorInterface @@ -139,6 +153,11 @@ public function getWriteMutator(string $source, string $target, string $property return null; } + // a nullable parent wraps the real type, the nested mutator guards against the null value at runtime + if ($lastAccessorType instanceof Type\NullableType) { + $lastAccessorType = $lastAccessorType->getWrappedType(); + } + if ($lastAccessorType instanceof Type\ObjectType) { /** @var class-string|'array' $targetClass */ $targetClass = $lastAccessorType->getClassName(); diff --git a/src/Extractor/MethodReadAccessor.php b/src/Extractor/MethodReadAccessor.php index e97b4ffc..fab62875 100644 --- a/src/Extractor/MethodReadAccessor.php +++ b/src/Extractor/MethodReadAccessor.php @@ -89,12 +89,12 @@ public function getExpression(Expr $input, bool $target = false): Expr return new Expr\MethodCall($input, $this->method, $methodCallArguments); } - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): ?Expr + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): ?Expr { return null; } - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsNullExpression(Expr $input, bool $target = false): Expr { $methodCallExpr = $this->getExpression($input); @@ -107,7 +107,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false): ); } - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr { return new Expr\ConstFetch(new Name('false')); } diff --git a/src/Extractor/NestedReadAccessor.php b/src/Extractor/NestedReadAccessor.php index 9b248910..f28b4990 100644 --- a/src/Extractor/NestedReadAccessor.php +++ b/src/Extractor/NestedReadAccessor.php @@ -4,13 +4,18 @@ namespace AutoMapper\Extractor; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar; final readonly class NestedReadAccessor implements ReadAccessorInterface { public function __construct( public ReadAccessorInterface $parent, public ReadAccessorInterface $child, + public ?string $childClass = null, + public ?string $path = null, ) { } @@ -18,27 +23,90 @@ public function getExpression(Expr $input, bool $target = false): Expr { $parentExpr = $this->parent->getExpression($input, $target); + if ($this->childUsesExtractCallback() && null !== $this->path) { + /* + * The child property is private, use the extract callback registered under the full path, + * which reads the value from the parent object + * + * $this->extractCallbacks['parent.child']($value->parent) + */ + return new Expr\FuncCall( + new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), $target ? MethodReadAccessor::EXTRACT_TARGET_CALLBACK : MethodReadAccessor::EXTRACT_CALLBACK), new Scalar\String_($this->path)), + [ + new Arg($parentExpr), + ] + ); + } + return $this->child->getExpression($parentExpr, $target); } - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): ?Expr + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): Expr { - return null; + $parentDefined = $this->getParentDefinedExpression($input, $target); + $childDefined = $this->childUsesExtractCallback() + ? null + : $this->child->getIsDefinedExpression($this->parent->getExpression($input, $target), $nullable, $target); + + if (null === $childDefined) { + return $parentDefined; + } + + return new Expr\BinaryOp\BooleanAnd($parentDefined, $childDefined); } - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsNullExpression(Expr $input, bool $target = false): Expr { - return $this->parent->getIsNullExpression($input, $target); + $parentExpr = $this->parent->getExpression($input, $target); + + if ($this->childUsesExtractCallback() && null !== $this->path) { + /* ! || null === $this->extractCallbacks['parent.child']($value->parent) */ + $childIsNull = new Expr\BinaryOp\Identical( + new Expr\ConstFetch(new Name('null')), + $this->getExpression($input, $target), + ); + } else { + $childIsNull = $this->child->getIsNullExpression($parentExpr, $target); + } + + /* + * The nested value is considered null when the parent cannot be accessed or when the child value is null + * + * !isset($value->parent) || parent> + */ + return new Expr\BinaryOp\BooleanOr( + new Expr\BooleanNot($this->getParentDefinedExpression($input, $target)), + $childIsNull, + ); } - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr { - return $this->parent->getIsNullExpression($input, $target); + $parentExpr = $this->parent->getExpression($input, $target); + + $childUndefined = $this->childUsesExtractCallback() + ? new Expr\ConstFetch(new Name('false')) + : $this->child->getIsUndefinedExpression($parentExpr, $target); + + /* + * The nested value is undefined when the parent cannot be accessed or when the child value is undefined + * + * !isset($value->parent) || parent> + */ + return new Expr\BinaryOp\BooleanOr( + new Expr\BooleanNot($this->getParentDefinedExpression($input, $target)), + $childUndefined, + ); } public function getExtractCallback(string $className): ?Expr { - return null; + if (null === $this->childClass) { + return null; + } + + // the callback reads the child property from the parent object + return $this->child->getExtractCallback($this->childClass); } public function getExtractIsNullCallback(string $className): ?Expr @@ -50,4 +118,22 @@ public function getExtractIsUndefinedCallback(string $className): ?Expr { return null; } + + /** + * Expression checking that the parent value can be safely accessed: it must be defined and not null. + */ + private function getParentDefinedExpression(Expr $input, bool $target = false): Expr + { + return $this->parent->getIsDefinedExpression($input, false, $target) + ?? new Expr\BinaryOp\NotIdentical(new Expr\ConstFetch(new Name('null')), $this->parent->getExpression($input, $target)); + } + + private function childUsesExtractCallback(): bool + { + if ($this->child instanceof PropertyReadAccessor || $this->child instanceof MethodReadAccessor) { + return $this->child->private; + } + + return false; + } } diff --git a/src/Extractor/NestedWriteMutator.php b/src/Extractor/NestedWriteMutator.php index 8b4147fa..2ec8d9b7 100644 --- a/src/Extractor/NestedWriteMutator.php +++ b/src/Extractor/NestedWriteMutator.php @@ -5,6 +5,7 @@ namespace AutoMapper\Extractor; use PhpParser\Node\Expr; +use PhpParser\Node\Name; final readonly class NestedWriteMutator implements WriteMutatorInterface { @@ -17,15 +18,44 @@ public function __construct( public function getExpression(Expr $output, Expr $value, bool $byRef = false): Expr { $accessExpr = $this->accessor->getExpression($output); + $writeExpr = $this->mutator->getExpression($accessExpr, $value, $byRef); - return $this->mutator->getExpression($accessExpr, $value, $byRef); + return $this->guardedExpression($output, $writeExpr); } public function getRemoveExpression(Expr $object, Expr $value): ?Expr { $accessExpr = $this->accessor->getExpression($object); + $removeExpr = $this->mutator->getRemoveExpression($accessExpr, $value); - return $this->mutator->getRemoveExpression($accessExpr, $value); + if (null === $removeExpr) { + return null; + } + + return $this->guardedExpression($object, $removeExpr); + } + + /** + * Only write to the nested value when the parent value can be safely accessed. + * + * ```php + * isset($result->parent) ? : null + * ``` + */ + private function guardedExpression(Expr $object, Expr $expression): Expr + { + // array parents are auto-vivified by the write expression, no guard needed + if ($this->accessor instanceof ArrayReadAccessor) { + return $expression; + } + + $guard = $this->accessor->getIsDefinedExpression($object); + + if (null === $guard) { + return $expression; + } + + return new Expr\Ternary($guard, $expression, new Expr\ConstFetch(new Name('null'))); } public function getHydrateCallback(string $className): ?Expr diff --git a/src/Extractor/PropertyReadAccessor.php b/src/Extractor/PropertyReadAccessor.php index 4da3b094..7b409efb 100644 --- a/src/Extractor/PropertyReadAccessor.php +++ b/src/Extractor/PropertyReadAccessor.php @@ -53,7 +53,7 @@ public function getExpression(Expr $input, bool $target = false): Expr return new Expr\PropertyFetch($input, $this->property); } - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): Expr + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): Expr { if ($this->private) { /* @@ -84,7 +84,7 @@ public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = fa return new Expr\FuncCall(new Name('property_exists'), [new Arg($input), new Arg(new Scalar\String_($this->property))]); } - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsNullExpression(Expr $input, bool $target = false): Expr { if ($this->private) { /* @@ -110,7 +110,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false): return new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->property)])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\PropertyFetch($input, $this->property))); } - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr { if ($this->private) { /* diff --git a/src/Extractor/ReadAccessorInterface.php b/src/Extractor/ReadAccessorInterface.php index da8370f4..460ce051 100644 --- a/src/Extractor/ReadAccessorInterface.php +++ b/src/Extractor/ReadAccessorInterface.php @@ -10,11 +10,11 @@ interface ReadAccessorInterface { public function getExpression(Expr $input, bool $target = false): Expr; - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): ?Expr; + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): ?Expr; - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr; + public function getIsNullExpression(Expr $input, bool $target = false): Expr; - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr; + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr; public function getExtractCallback(string $className): ?Expr; diff --git a/src/Extractor/SourceReadAccessor.php b/src/Extractor/SourceReadAccessor.php index c8c61878..5f3026c6 100644 --- a/src/Extractor/SourceReadAccessor.php +++ b/src/Extractor/SourceReadAccessor.php @@ -14,12 +14,12 @@ public function getExpression(Expr $input, bool $target = false): Expr return $input; } - public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): ?Expr + public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): ?Expr { return null; } - public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsNullExpression(Expr $input, bool $target = false): Expr { return new Expr\BinaryOp\Identical( new Expr\ConstFetch(new Name('null')), @@ -27,7 +27,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false): ); } - public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr + public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr { return new Expr\ConstFetch(new Name('false')); } diff --git a/src/Generator/CreateTargetStatementsGenerator.php b/src/Generator/CreateTargetStatementsGenerator.php index d34a44d7..87e224a1 100644 --- a/src/Generator/CreateTargetStatementsGenerator.php +++ b/src/Generator/CreateTargetStatementsGenerator.php @@ -234,16 +234,24 @@ public function getConstructStatements(GeneratorMetadata $metadata): array $createObjectStatements = []; $constructVar = $metadata->variableRegistry->getVariableWithUniqueName('constructArgs'); + $variadicVar = null; foreach ($targetConstructor->getParameters() as $constructorParameter) { // Find property for parameter $propertyMetadata = $metadata->getTargetPropertyWithConstructor($constructorParameter->getName()); $propertyStatements = null; - $assignVar = new Expr\ArrayDimFetch( - $constructVar, - new Scalar\String_($constructorParameter->getName()) - ); + + if ($constructorParameter->isVariadic()) { + /* A variadic parameter cannot be passed by name, collect its values in a dedicated variable spread at the end of the constructor call */ + $variadicVar = $metadata->variableRegistry->getVariableWithUniqueName('variadicConstructArgs'); + $assignVar = $variadicVar; + } else { + $assignVar = new Expr\ArrayDimFetch( + $constructVar, + new Scalar\String_($constructorParameter->getName()) + ); + } if (null !== $propertyMetadata) { $propertyStatements = $this->constructorArgument($assignVar, $metadata, $propertyMetadata, $constructorParameter); @@ -261,16 +269,32 @@ public function getConstructStatements(GeneratorMetadata $metadata): array ...$createObjectStatements, ]; + if (null !== $variadicVar) { + $createObjectStatements = [ + new Stmt\Expression(new Expr\Assign($variadicVar, new Expr\Array_())), + ...$createObjectStatements, + ]; + } + /* - * Create object with named constructor arguments + * Create object with named constructor arguments, variadic values are spread positionally at the end * - * $result->__construct(foo: $constructArg1, bar: $constructArg2, ...); // If lazy ghost class is available + * $result->__construct(...$constructArgs, ...array_values($variadicConstructArgs)); */ + $constructArguments = [new Arg($constructVar, unpack: true)]; + + if (null !== $variadicVar) { + $constructArguments[] = new Arg( + new Expr\FuncCall(new Name('array_values'), [new Arg($variadicVar)]), + unpack: true + ); + } + $createObjectStatements[] = new Stmt\Expression( new Expr\MethodCall( $metadata->variableRegistry->getResult(), '__construct', - [new Arg($constructVar, unpack: true)], + $constructArguments, ) ); @@ -292,7 +316,7 @@ public function getConstructStatements(GeneratorMetadata $metadata): array * * @return Stmt[]|null */ - private function constructorArgument(Expr\ArrayDimFetch $assignVar, GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata, \ReflectionParameter $parameter): ?array + private function constructorArgument(Expr\ArrayDimFetch|Expr\Variable $assignVar, GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata, \ReflectionParameter $parameter): ?array { $variableRegistry = $metadata->variableRegistry; $fieldValueExpr = $propertyMetadata->source->accessor?->getExpression($variableRegistry->getSourceInput()); @@ -308,7 +332,7 @@ private function constructorArgument(Expr\ArrayDimFetch $assignVar, GeneratorMet $defaultValueExpr = null; - if (!$parameter->isDefaultValueAvailable()) { + if (!$parameter->isDefaultValueAvailable() && !$parameter->isVariadic()) { if ($parameter->allowsNull()) { $defaultValueExpr = new Expr\ConstFetch(new Name('null')); } else { @@ -395,12 +419,12 @@ private function constructorArgument(Expr\ArrayDimFetch $assignVar, GeneratorMet * * @return Stmt[] */ - private function constructorArgumentWithoutSource(Expr\ArrayDimFetch $assignVar, GeneratorMetadata $metadata, \ReflectionParameter $constructorParameter): array + private function constructorArgumentWithoutSource(Expr\ArrayDimFetch|Expr\Variable $assignVar, GeneratorMetadata $metadata, \ReflectionParameter $constructorParameter): array { $variableRegistry = $metadata->variableRegistry; $defaultValueExpr = null; - if (!$constructorParameter->isDefaultValueAvailable()) { + if (!$constructorParameter->isDefaultValueAvailable() && !$constructorParameter->isVariadic()) { if ($constructorParameter->allowsNull()) { $defaultValueExpr = new Expr\ConstFetch(new Name('null')); } else { diff --git a/src/Generator/IdentifierHashGenerator.php b/src/Generator/IdentifierHashGenerator.php index 72492956..7f4eac07 100644 --- a/src/Generator/IdentifierHashGenerator.php +++ b/src/Generator/IdentifierHashGenerator.php @@ -70,11 +70,14 @@ private function getStatements(GeneratorMetadata $metadata, bool $fromSource): a ]); } - // add identifier to hash - $statements[] = new Stmt\Expression(new Expr\FuncCall(new Name('hash_update'), [ - new Arg($hashCtxVariable), - new Arg($property->source->accessor->getExpression($valueVariable)), - ])); + $identifierExpr = $property->source->accessor->getExpression($valueVariable); + + if ($property->transformer instanceof IdentifierHashInterface) { + // an object identifier is hashed through its own mapper + $identifierExpr = $property->transformer->getSourceHashExpression($identifierExpr); + } else { + $identifierExpr = new Expr\Cast\String_($identifierExpr); + } } else { $statements[] = new Stmt\If_($property->target->readAccessor->getIsUndefinedExpression($valueVariable, true), [ 'stmts' => [ @@ -82,11 +85,21 @@ private function getStatements(GeneratorMetadata $metadata, bool $fromSource): a ], ]); - $statements[] = new Stmt\Expression(new Expr\FuncCall(new Name('hash_update'), [ - new Arg($hashCtxVariable), - new Arg($property->target->readAccessor->getExpression($valueVariable, true)), - ])); + $identifierExpr = $property->target->readAccessor->getExpression($valueVariable, true); + + if ($property->transformer instanceof IdentifierHashInterface) { + // an object identifier is hashed through its own mapper + $identifierExpr = $property->transformer->getTargetHashExpression($identifierExpr); + } else { + $identifierExpr = new Expr\Cast\String_($identifierExpr); + } } + + // add identifier to hash + $statements[] = new Stmt\Expression(new Expr\FuncCall(new Name('hash_update'), [ + new Arg($hashCtxVariable), + new Arg($identifierExpr), + ])); } if (\count($statements) < 2) { @@ -96,7 +109,7 @@ private function getStatements(GeneratorMetadata $metadata, bool $fromSource): a // return hash as string $statements[] = new Stmt\Return_(new Expr\FuncCall(new Name('hash_final'), [ new Arg($hashCtxVariable), - new Arg(new Scalar\String_('true')), + new Arg(new Expr\ConstFetch(new Name('false'))), ])); return $statements; diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 8ad73a9c..60554cca 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -7,6 +7,7 @@ use AutoMapper\AttributeReference\AttributeInstance; use AutoMapper\AttributeReference\Reference; use AutoMapper\Exception\CompileException; +use AutoMapper\Extractor\NestedReadAccessor; use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; use AutoMapper\Metadata\PropertyMetadata; @@ -46,6 +47,7 @@ public function generate(GeneratorMetadata $metadata, PropertyMetadata $property $conditions[] = $this->customCondition($metadata, $propertyMetadata); $conditions[] = $this->propertyExistsForStdClass($metadata, $propertyMetadata); $conditions[] = $this->propertyExistsForArray($metadata, $propertyMetadata); + $conditions[] = $this->nestedSourceIsAccessible($metadata, $propertyMetadata); if (!$onlyExists) { $conditions[] = $this->isAllowedAttribute($metadata, $propertyMetadata); @@ -88,6 +90,22 @@ public function generate(GeneratorMetadata $metadata, PropertyMetadata $property return $condition; } + /** + * When reading from a nested property, the whole mapping is skipped if a value on the path cannot be accessed. + * + * ```php + * isset($source->parent) && isset($source->parent->child) + * ``` + */ + private function nestedSourceIsAccessible(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Expr + { + if (!$propertyMetadata->source->accessor instanceof NestedReadAccessor) { + return null; + } + + return $propertyMetadata->source->accessor->getIsDefinedExpression($metadata->variableRegistry->getSourceInput()); + } + /** * In case of source is an \stdClass we ensure that the property exists. * @@ -145,7 +163,7 @@ private function isAllowedAttribute(GeneratorMetadata $metadata, PropertyMetadat new Arg($variableRegistry->getContext()), new Arg(new Scalar\String_($propertyMetadata->source->property)), new Arg(new Expr\Closure([ - 'uses' => [new Expr\ClosureUse($variableRegistry->getSourceInput())], + 'uses' => [new Expr\ClosureUse($variableRegistry->getSourceInput()), new Expr\ClosureUse($variableRegistry->getContext())], 'stmts' => [new Stmt\Return_($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput()))], ])), new Arg($propertyMetadata->source->accessor->getIsUndefinedExpression($variableRegistry->getSourceInput())), @@ -259,9 +277,16 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ 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( + $inputExpr = $input ?? new Expr\ConstFetch(new Name('null')); + $ifVariable = new Expr\Variable($metadata->variableRegistry->getUniqueVariableScope()->getUniqueName('ifCallable')); + + /* + * Replicate the symfony/object-mapper calling convention: a string callable only receives the + * value while other callables receive the value, the source and the target + * + * \is_string($ifCallable = AttributeInstance::get(...)->if) ? $ifCallable($input) : $ifCallable($input, $value, $result) + */ + $ifExpr = new Expr\Assign($ifVariable, new Expr\PropertyFetch(new Expr\StaticCall( new Name\FullyQualified(AttributeInstance::class), 'get', [ @@ -269,11 +294,19 @@ private function customCondition(GeneratorMetadata $metadata, PropertyMetadata $ 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')); + + return new Expr\Ternary( + new Expr\FuncCall(new Name('is_string'), [new Arg($ifExpr)]), + new Expr\FuncCall($ifVariable, [ + new Arg($inputExpr), + ]), + new Expr\FuncCall($ifVariable, [ + new Arg($inputExpr), + new Arg($value), + new Arg(new Expr\Variable('result')), + ]), + ); } if (\is_callable($propertyMetadata->if, false, $callableName)) { diff --git a/src/Generator/PropertyStatementsGenerator.php b/src/Generator/PropertyStatementsGenerator.php index 24dce11d..b8eb9d05 100644 --- a/src/Generator/PropertyStatementsGenerator.php +++ b/src/Generator/PropertyStatementsGenerator.php @@ -8,6 +8,7 @@ use AutoMapper\Metadata\PropertyMetadata; use AutoMapper\Transformer\AllowNullValueTransformerInterface; use AutoMapper\Transformer\AssignedByReferenceTransformerInterface; +use AutoMapper\Transformer\NullableTransformer; use PhpParser\Node\Expr; use PhpParser\Node\Name; use PhpParser\Node\Stmt; @@ -64,6 +65,29 @@ public function generate(GeneratorMetadata $metadata, PropertyMetadata $property $propStatements[] = new Stmt\Expression($writeExpression); } + if ($propertyMetadata->transformer instanceof NullableTransformer && !$propertyMetadata->transformer->isTargetNullable) { + $guard = $propertyMetadata->source->checkExists + ? new Expr\Isset_([$fieldValueExpr]) + : new Expr\BinaryOp\NotIdentical(new Expr\ConstFetch(new Name('null')), $fieldValueExpr); + + $elseStatements = []; + + if ($propertyMetadata->target->writeMutator && !$propertyMetadata->target->writeMutator->isAdderRemover()) { + $elseStatements[] = new Stmt\Expression($propertyMetadata->target->writeMutator->getExpression( + $variableRegistry->getResult(), + new Expr\ConstFetch(new Name('null')), + false + )); + } + + $propStatements = [ + new Stmt\If_($guard, [ + 'stmts' => $propStatements, + 'else' => $elseStatements ? new Stmt\Else_($elseStatements) : null, + ]), + ]; + } + $condition = $this->propertyConditionsGenerator->generate($metadata, $propertyMetadata); if ($condition) { diff --git a/src/Lazy/LazyMap.php b/src/Lazy/LazyMap.php index 0f5070c2..7c8c49e1 100644 --- a/src/Lazy/LazyMap.php +++ b/src/Lazy/LazyMap.php @@ -65,6 +65,7 @@ private function initialize(): void } ($this->mapper)($this->mappedValue); + $this->initialized = true; } public function jsonSerialize(): mixed diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php index 82e22bad..02a10f61 100644 --- a/src/Loader/FileLoader.php +++ b/src/Loader/FileLoader.php @@ -107,47 +107,72 @@ public function createGeneratedMapper(MapperMetadata $mapperMetadata): string */ private function addHashToRegistry(string $className, string $hash): void { - if (!isset($this->registry)) { - $this->registry = []; - } - $registryPath = $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; - $this->registry[$className] = $hash; - $this->write($registryPath, "registry, true) . ";\n"); + + // The registry is shared by all mappers: it is locked globally and reloaded from disk so entries + // written concurrently by other processes are not lost + $lock = $this->lockFactory->createLock('automapper_registry'); + $lock->acquire(true); + + try { + $this->registry = $this->readRegistry(); + $this->registry[$className] = $hash; + $this->write($registryPath, "registry, true) . ";\n"); + } finally { + $lock->release(); + } } /** @return array */ private function getRegistry(): array { - if (!isset($this->registry)) { - $registryPath = $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; + return $this->registry ??= $this->readRegistry(); + } - if (!file_exists($registryPath)) { - $this->registry = []; - } else { - $this->registry = require $registryPath; - } + /** @return array */ + private function readRegistry(): array + { + $registryPath = $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; + + if (!file_exists($registryPath)) { + return []; + } + + $registry = require $registryPath; + + if (!\is_array($registry)) { + // corrupted registry, mappers will be regenerated + return []; } - return $this->registry; + /** @var array $registry */ + return $registry; } private function write(string $file, string $contents): void { - if (!file_exists($this->directory)) { - mkdir($this->directory); + if (!is_dir($this->directory) && !@mkdir($this->directory, 0755, true) && !is_dir($this->directory)) { + throw new CompileException(\sprintf('Could not create directory "%s"', $this->directory)); } - $fp = fopen($file, 'w'); + // Write to a temporary file then rename: the rename is atomic so a concurrent process can never + // require a partially written file + $tmpFile = tempnam($this->directory, 'am_'); - if (false === $fp) { - throw new CompileException(\sprintf('Could not open file "%s"', $file)); + if (false === $tmpFile || false === file_put_contents($tmpFile, $contents)) { + throw new CompileException(\sprintf('Could not write file "%s"', $file)); } - if (flock($fp, LOCK_EX)) { - fwrite($fp, $contents); + @chmod($tmpFile, 0666 & ~umask()); + + if (!rename($tmpFile, $file)) { + @unlink($tmpFile); + + throw new CompileException(\sprintf('Could not write file "%s"', $file)); } - fclose($fp); + if (\function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } } } diff --git a/src/Metadata/MetadataRegistry.php b/src/Metadata/MetadataRegistry.php index 2dc0b25b..1cb66ac8 100644 --- a/src/Metadata/MetadataRegistry.php +++ b/src/Metadata/MetadataRegistry.php @@ -82,6 +82,11 @@ public function getIterator(): \Traversable */ private function getRealClassName(string $className): string { + // Class names resolved from docblocks may have a leading backslash, normalize it away so + // registry keys and generated code always use the same form + /** @var class-string|'array' $className */ + $className = ltrim($className, '\\'); + // __CG__: Doctrine Common Marker for Proxy (ODM < 2.0 and ORM < 3.0) // __PM__: Ocramius Proxy Manager (ODM >= 2.0) $positionCg = strrpos($className, '\\__CG__\\'); @@ -96,8 +101,6 @@ private function getRealClassName(string $className): string return substr($className, $positionCg + 8); } - $className = ltrim($className, '\\'); - /** @var class-string */ return substr( $className, diff --git a/src/Normalizer/AutoMapperNormalizer.php b/src/Normalizer/AutoMapperNormalizer.php index 6856b2eb..ec0b98ad 100644 --- a/src/Normalizer/AutoMapperNormalizer.php +++ b/src/Normalizer/AutoMapperNormalizer.php @@ -26,6 +26,20 @@ */ readonly class AutoMapperNormalizer implements NormalizerInterface, DenormalizerInterface { + /** + * Value object types handled by their own dedicated symfony normalizer, AutoMapper must not claim them + * otherwise it would produce a structure dump instead of e.g. an RFC3339 date string. + * + * @var list + */ + private const array UNSUPPORTED_TYPES = [ + \DateTimeInterface::class, + \DateTimeZone::class, + \DateInterval::class, + \UnitEnum::class, + \Symfony\Component\Uid\AbstractUid::class, + ]; + private const array SERIALIZER_CONTEXT_MAPPING = [ AbstractNormalizer::GROUPS => MapperContext::GROUPS, AbstractNormalizer::ATTRIBUTES => MapperContext::ALLOWED_ATTRIBUTES, @@ -92,6 +106,12 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return false; } + foreach (self::UNSUPPORTED_TYPES as $unsupportedType) { + if ($data instanceof $unsupportedType) { + return false; + } + } + if ($this->onlyMetadataRegistry === null) { return true; } @@ -108,6 +128,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form return false; } + foreach (self::UNSUPPORTED_TYPES as $unsupportedType) { + if (is_a($type, $unsupportedType, true)) { + return false; + } + } + if ($this->onlyMetadataRegistry === null) { return true; } diff --git a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php index da1cea09..7dec8a0c 100644 --- a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php @@ -116,6 +116,9 @@ public function load(array $configs, ContainerBuilder $container): void $container ->setAlias(ClassLoaderInterface::class, EvalLoader::class) ; + + // there is no cache directory to warm with the eval loader + $container->removeDefinition(CacheWarmer::class); } else { $container ->getDefinition(FileLoader::class) diff --git a/src/Symfony/Bundle/Resources/config/object_mapper.php b/src/Symfony/Bundle/Resources/config/object_mapper.php index 9722fb3c..4a32e1e1 100644 --- a/src/Symfony/Bundle/Resources/config/object_mapper.php +++ b/src/Symfony/Bundle/Resources/config/object_mapper.php @@ -19,14 +19,18 @@ service('automapper.mapper_service_locator'), service('automapper.expression_language'), ]) - ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => 0]) + // run last (after Doctrine and discriminator listeners) so stopPropagation for Map-attributed + // classes does not suppress them, matching the standalone listener order + ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => -256]) ->set(MapTargetListener::class) ->args([ service('automapper.mapper_service_locator'), service('automapper.expression_language'), ]) - ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => 0]) + // run last (after Doctrine and discriminator listeners) so stopPropagation for Map-attributed + // classes does not suppress them, matching the standalone listener order + ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => -256]) ->set('automapper.object_mapper') ->class(ObjectMapper::class) diff --git a/src/Transformer/ApiPlatform/JsonLdContextTransformer.php b/src/Transformer/ApiPlatform/JsonLdContextTransformer.php index b099c7dc..9d122338 100644 --- a/src/Transformer/ApiPlatform/JsonLdContextTransformer.php +++ b/src/Transformer/ApiPlatform/JsonLdContextTransformer.php @@ -23,9 +23,10 @@ public function transform(mixed $value, object|array $source, array $context, mi return null; } - $resourceClass = $computed ?? $this->resourceClassResolver->isResourceClass( - $source::class - ) ? $this->resourceClassResolver->getResourceClass($source) : null; + // a computed resource class takes precedence, otherwise resolve it only when the source is a resource + $resourceClass = \is_string($computed) ? $computed : ($this->resourceClassResolver->isResourceClass($source::class) + ? $this->resourceClassResolver->getResourceClass($source) + : null); if (null === $resourceClass) { if ($this->contextBuilder instanceof AnonymousContextBuilderInterface) { diff --git a/src/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactory.php b/src/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactory.php index 81354f20..bdd33d3d 100644 --- a/src/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactory.php +++ b/src/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactory.php @@ -38,7 +38,9 @@ public function getTransformer( return null; } - if (!$target->type->isIdentifiedBy(TypeIdentifier::ARRAY) && $target->type->isIdentifiedBy(TypeIdentifier::MIXED)) { + // the transformer produces either the resource IRI or, with groups, a nested array/object, so it only + // applies to targets able to hold both (array or mixed), not to concrete scalar/object targets + if (!$target->type->isIdentifiedBy(TypeIdentifier::ARRAY) && !$target->type->isIdentifiedBy(TypeIdentifier::MIXED)) { return null; } diff --git a/src/Transformer/BuiltinTransformerFactory.php b/src/Transformer/BuiltinTransformerFactory.php index 273ee60f..b4590e9c 100644 --- a/src/Transformer/BuiltinTransformerFactory.php +++ b/src/Transformer/BuiltinTransformerFactory.php @@ -23,6 +23,11 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet return null; } + // An enum target is created from the scalar value by the EnumTransformerFactory, which has a lower priority + if ($target->type instanceof Type\EnumType) { + return null; + } + // We don't want to handle mixed here as we can better guess the type with other transformers if ($source->type instanceof Type\BuiltinType && $source->type->getTypeIdentifier() !== TypeIdentifier::MIXED && $source->type->getTypeIdentifier() !== TypeIdentifier::NULL) { return new BuiltinTransformer($source->type, $target->type ?? Type::mixed()); diff --git a/src/Transformer/CopyEnumTransformer.php b/src/Transformer/CopyEnumTransformer.php index c41ccb25..c3e20fb3 100644 --- a/src/Transformer/CopyEnumTransformer.php +++ b/src/Transformer/CopyEnumTransformer.php @@ -7,6 +7,7 @@ use AutoMapper\Generator\UniqueVariableScope; use AutoMapper\Metadata\PropertyMetadata; use PhpParser\Node\Expr; +use PhpParser\Node\Name; /** * Transform an Enum into a copied Enum. @@ -15,11 +16,22 @@ * * @internal */ -final class CopyEnumTransformer implements TransformerInterface +final readonly class CopyEnumTransformer implements TransformerInterface, CheckTypeInterface { + public function __construct( + private ?string $enumClassName = null, + ) { + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /* No transform here it's the same value and it's a copy so we do not need to clone */ return [$input, []]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \Some\Enum */ + return new Expr\Instanceof_($input, new Name\FullyQualified($this->enumClassName ?? \UnitEnum::class)); + } } diff --git a/src/Transformer/DateTimeInterfaceToImmutableTransformer.php b/src/Transformer/DateTimeInterfaceToImmutableTransformer.php index f869060a..8296c610 100644 --- a/src/Transformer/DateTimeInterfaceToImmutableTransformer.php +++ b/src/Transformer/DateTimeInterfaceToImmutableTransformer.php @@ -17,20 +17,34 @@ * * @internal */ -final class DateTimeInterfaceToImmutableTransformer implements TransformerInterface +final readonly class DateTimeInterfaceToImmutableTransformer implements TransformerInterface, CheckTypeInterface { + /** + * @param class-string $className a \DateTimeImmutable class or one of its subclasses + */ + public function __construct( + private string $className = \DateTimeImmutable::class, + ) { + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /* - * Handles all DateTime instance types using createFromInterface. + * Handles all DateTime instance types using createFromInterface, keeping the concrete target class. * * \DateTimeImmutable::createFromInterface($input); */ return [ - new Expr\StaticCall(new Name\FullyQualified(\DateTimeImmutable::class), 'createFromInterface', [ + new Expr\StaticCall(new Name\FullyQualified($this->className), 'createFromInterface', [ new Arg($input), ]), [], ]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \DateTimeInterface */ + return new Expr\Instanceof_($input, new Name\FullyQualified(\DateTimeInterface::class)); + } } diff --git a/src/Transformer/DateTimeInterfaceToMutableTransformer.php b/src/Transformer/DateTimeInterfaceToMutableTransformer.php index 0485a329..3b41bec7 100644 --- a/src/Transformer/DateTimeInterfaceToMutableTransformer.php +++ b/src/Transformer/DateTimeInterfaceToMutableTransformer.php @@ -17,20 +17,34 @@ * * @internal */ -final class DateTimeInterfaceToMutableTransformer implements TransformerInterface +final readonly class DateTimeInterfaceToMutableTransformer implements TransformerInterface, CheckTypeInterface { + /** + * @param class-string $className a \DateTime class or one of its subclasses + */ + public function __construct( + private string $className = \DateTime::class, + ) { + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /* - * Handles all DateTime instance types using createFromInterface. + * Handles all DateTime instance types using createFromInterface, keeping the concrete target class. * - * \DateTimeImmutable::createFromInterface($input); + * \DateTime::createFromInterface($input); */ return [ - new Expr\StaticCall(new Name\FullyQualified(\DateTime::class), 'createFromInterface', [ + new Expr\StaticCall(new Name\FullyQualified($this->className), 'createFromInterface', [ new Arg($input), ]), [], ]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \DateTimeInterface */ + return new Expr\Instanceof_($input, new Name\FullyQualified(\DateTimeInterface::class)); + } } diff --git a/src/Transformer/DateTimeTransformerFactory.php b/src/Transformer/DateTimeTransformerFactory.php index 2cb23fa9..f4f10957 100644 --- a/src/Transformer/DateTimeTransformerFactory.php +++ b/src/Transformer/DateTimeTransformerFactory.php @@ -41,11 +41,28 @@ private function createTransformerForSourceAndTarget(?Type $targetType): Transfo { // if target is mutable if ($this->isDateTimeMutable($targetType)) { - return new DateTimeInterfaceToMutableTransformer(); + return new DateTimeInterfaceToMutableTransformer($this->getConcreteClassName($targetType, \DateTime::class)); } // if target is immutable or a generic DateTimeInterface - return new DateTimeInterfaceToImmutableTransformer(); + return new DateTimeInterfaceToImmutableTransformer($this->getConcreteClassName($targetType, \DateTimeImmutable::class)); + } + + /** + * @param class-string $default + * + * @return class-string + */ + private function getConcreteClassName(?Type $targetType, string $default): string + { + // \DateTimeInterface cannot be instantiated, fall back to the default concrete class, + // otherwise keep the concrete target class (custom subclasses included) + if (!$targetType instanceof Type\ObjectType || \DateTimeInterface::class === $targetType->getClassName()) { + return $default; + } + + /** @var class-string */ + return $targetType->getClassName(); } private function createTransformerForSource(?Type $targetType, SourcePropertyMetadata $metadata): ?TransformerInterface diff --git a/src/Transformer/DoctrineCollectionTransformerFactory.php b/src/Transformer/DoctrineCollectionTransformerFactory.php index 671eb86d..90284b57 100644 --- a/src/Transformer/DoctrineCollectionTransformerFactory.php +++ b/src/Transformer/DoctrineCollectionTransformerFactory.php @@ -39,8 +39,11 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet }); if ($isDoctrineCollection) { + // a nullable collection target wraps the collection type, unwrap it to get the real item type + $targetType = $target->type instanceof Type\NullableType ? $target->type->getWrappedType() : $target->type; + $sourceItemType = $source->type->getCollectionValueType(); - $targetItemType = $target->type instanceof Type\CollectionType ? $target->type->getCollectionValueType() : Type::mixed(); + $targetItemType = $targetType instanceof Type\CollectionType ? $targetType->getCollectionValueType() : Type::mixed(); $newSource = $source->withType($sourceItemType); $newTarget = $target->withType($targetItemType); diff --git a/src/Transformer/EnumToEnumTransformer.php b/src/Transformer/EnumToEnumTransformer.php new file mode 100644 index 00000000..84664693 --- /dev/null +++ b/src/Transformer/EnumToEnumTransformer.php @@ -0,0 +1,45 @@ + + * + * @internal + */ +final readonly class EnumToEnumTransformer implements TransformerInterface, CheckTypeInterface +{ + public function __construct( + private string $targetClassName, + private ?string $sourceClassName = null, + ) { + } + + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array + { + /* + * Transform a BackedEnum into another BackedEnum. + * + * \Backed\Enum\TargetEnum::from($input->value); + */ + return [new Expr\StaticCall(new Name\FullyQualified($this->targetClassName), 'from', [ + new Arg(new Expr\PropertyFetch($input, 'value')), + ]), []]; + } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \Some\Enum */ + return new Expr\Instanceof_($input, new Name\FullyQualified($this->sourceClassName ?? \BackedEnum::class)); + } +} diff --git a/src/Transformer/EnumTransformerFactory.php b/src/Transformer/EnumTransformerFactory.php index 3b465ebe..a115795b 100644 --- a/src/Transformer/EnumTransformerFactory.php +++ b/src/Transformer/EnumTransformerFactory.php @@ -20,7 +20,8 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet { // source is enum, target isn't if ($this->isEnumType($source->type, true) && !$this->isEnumType($target->type)) { - return new SourceEnumTransformer(); + // @phpstan-ignore-next-line + return new SourceEnumTransformer($source->type->getClassName()); } // target is enum, source isn't @@ -31,7 +32,19 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet // both source & target are enums if ($this->isEnumType($source->type) && $this->isEnumType($target->type)) { - return new CopyEnumTransformer(); + // @phpstan-ignore-next-line + if ($source->type->getClassName() === $target->type->getClassName()) { + // @phpstan-ignore-next-line + return new CopyEnumTransformer($source->type->getClassName()); + } + + // different enum classes can only be converted through their backing value + if ($this->isEnumType($source->type, true) && $this->isEnumType($target->type, true)) { + // @phpstan-ignore-next-line + return new EnumToEnumTransformer($target->type->getClassName(), $source->type->getClassName()); + } + + return null; } return null; diff --git a/src/Transformer/MultipleTransformerFactory.php b/src/Transformer/MultipleTransformerFactory.php index b67bc822..2dae51a2 100644 --- a/src/Transformer/MultipleTransformerFactory.php +++ b/src/Transformer/MultipleTransformerFactory.php @@ -25,8 +25,10 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet } $transformers = []; + $sourceTypesCount = 0; foreach ($source->type->getTypes() as $sourceType) { + ++$sourceTypesCount; $newSource = $source->withType($sourceType); $transformer = $this->chainTransformerFactory->getTransformer($newSource, $target, $mapperMetadata); @@ -38,7 +40,10 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet } } - if (\count($transformers) > 1) { + // As soon as some union members have no transformer, a runtime type guard is required so the found + // transformer only runs on the type it was built for, otherwise a value of an unhandled member would + // be blindly transformed. MultipleTransformer generates that guard. + if (\count($transformers) > 1 || (\count($transformers) === 1 && \count($transformers) < $sourceTypesCount)) { return new MultipleTransformer($transformers); } diff --git a/src/Transformer/NullableTransformer.php b/src/Transformer/NullableTransformer.php index 8e5d714c..c5d78cd2 100644 --- a/src/Transformer/NullableTransformer.php +++ b/src/Transformer/NullableTransformer.php @@ -21,7 +21,7 @@ { public function __construct( private TransformerInterface $itemTransformer, - private bool $isTargetNullable, + public bool $isTargetNullable, ) { } diff --git a/src/Transformer/ObjectTransformer.php b/src/Transformer/ObjectTransformer.php index d8cac545..b8eaa8f2 100644 --- a/src/Transformer/ObjectTransformer.php +++ b/src/Transformer/ObjectTransformer.php @@ -83,7 +83,7 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): ?Expr { if ($this->sourceType instanceof Type\ObjectType) { - $condition = new Expr\Instanceof_($input, new Name\FullyQualified($this->sourceType->getClassName())); + $condition = new Expr\Instanceof_($input, new Name\FullyQualified(ltrim($this->sourceType->getClassName(), '\\'))); } elseif ($this->sourceType instanceof Type\BuiltinType) { if ($this->sourceType->getTypeIdentifier() === TypeIdentifier::OBJECT) { $condition = new Expr\FuncCall( @@ -139,7 +139,7 @@ private function getSource(): string * * @var class-string $sourceTypeName */ - $sourceTypeName = $this->sourceType->getClassName(); + $sourceTypeName = ltrim($this->sourceType->getClassName(), '\\'); } return $sourceTypeName; @@ -158,7 +158,7 @@ private function getTarget(): string * * @var class-string $targetTypeName */ - $targetTypeName = $this->targetType->getClassName(); + $targetTypeName = ltrim($this->targetType->getClassName(), '\\'); } return $targetTypeName; diff --git a/src/Transformer/SourceEnumTransformer.php b/src/Transformer/SourceEnumTransformer.php index fa49b635..32190ba5 100644 --- a/src/Transformer/SourceEnumTransformer.php +++ b/src/Transformer/SourceEnumTransformer.php @@ -7,6 +7,7 @@ use AutoMapper\Generator\UniqueVariableScope; use AutoMapper\Metadata\PropertyMetadata; use PhpParser\Node\Expr; +use PhpParser\Node\Name; /** * Transform a BackendEnum into a scalar. @@ -15,11 +16,22 @@ * * @internal */ -final class SourceEnumTransformer implements TransformerInterface +final readonly class SourceEnumTransformer implements TransformerInterface, CheckTypeInterface { + public function __construct( + private ?string $enumClassName = null, + ) { + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /* $input->value */ return [new Expr\PropertyFetch($input, 'value'), []]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \Some\Enum */ + return new Expr\Instanceof_($input, new Name\FullyQualified($this->enumClassName ?? \BackedEnum::class)); + } } diff --git a/src/Transformer/StringToDateTimeTransformer.php b/src/Transformer/StringToDateTimeTransformer.php index 9e4435fd..49aaa5b1 100644 --- a/src/Transformer/StringToDateTimeTransformer.php +++ b/src/Transformer/StringToDateTimeTransformer.php @@ -19,7 +19,7 @@ * * @internal */ -final readonly class StringToDateTimeTransformer implements TransformerInterface +final readonly class StringToDateTimeTransformer implements TransformerInterface, CheckTypeInterface { public function __construct( private string $className, @@ -55,4 +55,10 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM ), ]), []]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* is_string($input) */ + return new Expr\FuncCall(new Name('is_string'), [new Arg($input)]); + } } diff --git a/src/Transformer/StringToSymfonyUidTransformer.php b/src/Transformer/StringToSymfonyUidTransformer.php index c47c24af..2f780b9a 100644 --- a/src/Transformer/StringToSymfonyUidTransformer.php +++ b/src/Transformer/StringToSymfonyUidTransformer.php @@ -17,7 +17,7 @@ * * @internal */ -final readonly class StringToSymfonyUidTransformer implements TransformerInterface +final readonly class StringToSymfonyUidTransformer implements TransformerInterface, CheckTypeInterface { public function __construct( private string $className, @@ -36,4 +36,10 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM [], ]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* is_string($input) */ + return new Expr\FuncCall(new Name('is_string'), [new Arg($input)]); + } } diff --git a/src/Transformer/SymfonyUidCopyTransformer.php b/src/Transformer/SymfonyUidCopyTransformer.php index 9e87a5e1..a4d088f8 100644 --- a/src/Transformer/SymfonyUidCopyTransformer.php +++ b/src/Transformer/SymfonyUidCopyTransformer.php @@ -9,32 +9,45 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Name; -use Symfony\Component\Uid\Ulid; -use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\AbstractUid; /** - * Transform Symfony Uid to the same object. + * Transform a Symfony Uid to another Symfony Uid, keeping the concrete target class. * * @author Baptiste Leduc * * @internal */ -final class SymfonyUidCopyTransformer implements TransformerInterface +final readonly class SymfonyUidCopyTransformer implements TransformerInterface, CheckTypeInterface { + /** + * @param class-string $targetClassName a Symfony Uid class (Uuid, Ulid or one of their subclasses) + */ + public function __construct( + private string $targetClassName, + ) { + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /* - * Create a Symfony Uid object from another Symfony Uid object. + * Create the target Symfony Uid from another Symfony Uid. `fromString` is used so the concrete + * target class is respected (e.g. UuidV4), and the base Uuid/Ulid class still returns the proper + * versioned instance. * - * $input instanceof \Symfony\Component\Uid\Ulid ? new \Symfony\Component\Uid\Ulid($input->toBase32()) : new \Symfony\Component\Uid\Uuid($input->toRfc4122()); + * \Symfony\Component\Uid\TargetUid::fromString((string) $input); */ return [ - new Expr\Ternary( - new Expr\Instanceof_($input, new Name(Ulid::class)), - new Expr\New_(new Name(Ulid::class), [new Arg(new Expr\MethodCall($input, 'toBase32'))]), - new Expr\New_(new Name(Uuid::class), [new Arg(new Expr\MethodCall($input, 'toRfc4122'))]) - ), + new Expr\StaticCall(new Name\FullyQualified($this->targetClassName), 'fromString', [ + new Arg(new Expr\Cast\String_($input)), + ]), [], ]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \Symfony\Component\Uid\AbstractUid */ + return new Expr\Instanceof_($input, new Name\FullyQualified(AbstractUid::class)); + } } diff --git a/src/Transformer/SymfonyUidToStringTransformer.php b/src/Transformer/SymfonyUidToStringTransformer.php index af84da7c..819fe484 100644 --- a/src/Transformer/SymfonyUidToStringTransformer.php +++ b/src/Transformer/SymfonyUidToStringTransformer.php @@ -7,6 +7,8 @@ use AutoMapper\Generator\UniqueVariableScope; use AutoMapper\Metadata\PropertyMetadata; use PhpParser\Node\Expr; +use PhpParser\Node\Name; +use Symfony\Component\Uid\AbstractUid; /** * Transform a \DateTimeInterface object to a string. @@ -15,7 +17,7 @@ * * @internal */ -final readonly class SymfonyUidToStringTransformer implements TransformerInterface +final readonly class SymfonyUidToStringTransformer implements TransformerInterface, CheckTypeInterface { public function __construct( private bool $isUlid, @@ -43,4 +45,10 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM [], ]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* $input instanceof \Symfony\Component\Uid\AbstractUid */ + return new Expr\Instanceof_($input, new Name\FullyQualified(AbstractUid::class)); + } } diff --git a/src/Transformer/SymfonyUidTransformerFactory.php b/src/Transformer/SymfonyUidTransformerFactory.php index 29276d27..3fa5568f 100644 --- a/src/Transformer/SymfonyUidTransformerFactory.php +++ b/src/Transformer/SymfonyUidTransformerFactory.php @@ -27,7 +27,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet $targetUid = $this->getUid($target->type); if ($sourceUid[0] && $targetUid[0]) { - return new SymfonyUidCopyTransformer(); + return new SymfonyUidCopyTransformer($targetUid[2]); } if ($sourceUid[0]) { diff --git a/src/Transformer/TargetEnumTransformer.php b/src/Transformer/TargetEnumTransformer.php index cdf93967..cefa898c 100644 --- a/src/Transformer/TargetEnumTransformer.php +++ b/src/Transformer/TargetEnumTransformer.php @@ -17,7 +17,7 @@ * * @internal */ -final readonly class TargetEnumTransformer implements TransformerInterface +final readonly class TargetEnumTransformer implements TransformerInterface, CheckTypeInterface { public function __construct( private string $targetClassName, @@ -35,4 +35,13 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM new Arg($input), ]), []]; } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): Expr + { + /* is_string($input) || is_int($input) */ + return new Expr\BinaryOp\BooleanOr( + new Expr\FuncCall(new Name('is_string'), [new Arg($input)]), + new Expr\FuncCall(new Name('is_int'), [new Arg($input)]), + ); + } } diff --git a/tests/AutoMapperTest/DateTimeSubclass/expected.data b/tests/AutoMapperTest/DateTimeSubclass/expected.data new file mode 100644 index 00000000..7caf64da --- /dev/null +++ b/tests/AutoMapperTest/DateTimeSubclass/expected.data @@ -0,0 +1,6 @@ +[ + "immutable_class" => "AutoMapper\Tests\AutoMapperTest\DateTimeSubclass\CustomImmutable" + "immutable_value" => "2021-01-01T00:00:00+00:00" + "mutable_class" => "AutoMapper\Tests\AutoMapperTest\DateTimeSubclass\CustomMutable" + "mutable_value" => "2021-01-01T00:00:00+00:00" +] diff --git a/tests/AutoMapperTest/DateTimeSubclass/map.php b/tests/AutoMapperTest/DateTimeSubclass/map.php new file mode 100644 index 00000000..8501205e --- /dev/null +++ b/tests/AutoMapperTest/DateTimeSubclass/map.php @@ -0,0 +1,50 @@ +date = new \DateTimeImmutable('2021-01-01T00:00:00+00:00'); + + // datetime -> custom immutable subclass + $immutable = $autoMapper->map($source, Target::class); + + // datetime -> custom mutable subclass + $mutable = $autoMapper->map($source, MutableTarget::class); + + return [ + 'immutable_class' => $immutable->date::class, + 'immutable_value' => $immutable->date->format(\DateTimeInterface::RFC3339), + 'mutable_class' => $mutable->date::class, + 'mutable_value' => $mutable->date->format(\DateTimeInterface::RFC3339), + ]; +})(); diff --git a/tests/AutoMapperTest/EnumToDifferentEnum/expected.data b/tests/AutoMapperTest/EnumToDifferentEnum/expected.data new file mode 100644 index 00000000..ad7cbf90 --- /dev/null +++ b/tests/AutoMapperTest/EnumToDifferentEnum/expected.data @@ -0,0 +1,10 @@ +AutoMapper\Tests\AutoMapperTest\EnumToDifferentEnum\Target { + +status: AutoMapper\Tests\AutoMapperTest\EnumToDifferentEnum\TargetStatus { + +name: "Active" + +value: "active" + } + +shared: AutoMapper\Tests\AutoMapperTest\EnumToDifferentEnum\SharedStatus { + +name: "Active" + +value: "active" + } +} diff --git a/tests/AutoMapperTest/EnumToDifferentEnum/map.php b/tests/AutoMapperTest/EnumToDifferentEnum/map.php new file mode 100644 index 00000000..791a6f3e --- /dev/null +++ b/tests/AutoMapperTest/EnumToDifferentEnum/map.php @@ -0,0 +1,39 @@ +map(new Source(), Target::class); diff --git a/tests/AutoMapperTest/GlobalNamespaceClasses/classes.php b/tests/AutoMapperTest/GlobalNamespaceClasses/classes.php new file mode 100644 index 00000000..7c431040 --- /dev/null +++ b/tests/AutoMapperTest/GlobalNamespaceClasses/classes.php @@ -0,0 +1,28 @@ +name = 'foo'; + +$source = new \AutoMapperGlobalSource(); +$source->items = [$item]; + +return AutoMapperBuilder::buildAutoMapper()->map($source, \AutoMapperGlobalDto::class); diff --git a/tests/AutoMapperTest/IdentifierHash/expected.int-identifier.data b/tests/AutoMapperTest/IdentifierHash/expected.int-identifier.data new file mode 100644 index 00000000..1d450795 --- /dev/null +++ b/tests/AutoMapperTest/IdentifierHash/expected.int-identifier.data @@ -0,0 +1,12 @@ +AutoMapper\Tests\AutoMapperTest\IdentifierHash\Basket { + +items: [ + AutoMapper\Tests\AutoMapperTest\IdentifierHash\Item { + +id: 1 + +label: "one updated" + } + AutoMapper\Tests\AutoMapperTest\IdentifierHash\Item { + +id: 3 + +label: "three" + } + ] +} diff --git a/tests/AutoMapperTest/IdentifierHash/expected.object-identifier.data b/tests/AutoMapperTest/IdentifierHash/expected.object-identifier.data new file mode 100644 index 00000000..397bcabd --- /dev/null +++ b/tests/AutoMapperTest/IdentifierHash/expected.object-identifier.data @@ -0,0 +1,16 @@ +AutoMapper\Tests\AutoMapperTest\IdentifierHash\Catalog { + +products: [ + AutoMapper\Tests\AutoMapperTest\IdentifierHash\Product { + +ref: AutoMapper\Tests\AutoMapperTest\IdentifierHash\Ref { + +value: "a" + } + +name: "product a updated" + } + AutoMapper\Tests\AutoMapperTest\IdentifierHash\Product { + +ref: AutoMapper\Tests\AutoMapperTest\IdentifierHash\Ref { + +value: "b" + } + +name: "product b" + } + ] +} diff --git a/tests/AutoMapperTest/IdentifierHash/map.php b/tests/AutoMapperTest/IdentifierHash/map.php new file mode 100644 index 00000000..4a59b224 --- /dev/null +++ b/tests/AutoMapperTest/IdentifierHash/map.php @@ -0,0 +1,91 @@ +id = 1; + $itemOne->label = 'one'; + + $itemTwo = new Item(); + $itemTwo->id = 2; + $itemTwo->label = 'two'; + + $basket = new Basket(); + $basket->items = [$itemOne, $itemTwo]; + + $data = [ + 'items' => [ + ['id' => 1, 'label' => 'one updated'], + ['id' => 3, 'label' => 'three'], + ], + ]; + + yield 'int-identifier' => $autoMapper->map($data, $basket, ['deep_target_to_populate' => true]); + + // object identifier hashed through its own mapper + $ref = new Ref(); + $ref->value = 'a'; + + $product = new Product(); + $product->ref = $ref; + $product->name = 'product a'; + + $catalog = new Catalog(); + $catalog->products = [$product]; + + $data = [ + 'products' => [ + ['ref' => ['value' => 'a'], 'name' => 'product a updated'], + ['ref' => ['value' => 'b'], 'name' => 'product b'], + ], + ]; + + yield 'object-identifier' => $autoMapper->map($data, $catalog, ['deep_target_to_populate' => true]); +})(); diff --git a/tests/AutoMapperTest/LazyMapSingleInitialization/expected.data b/tests/AutoMapperTest/LazyMapSingleInitialization/expected.data new file mode 100644 index 00000000..15cfeaf3 --- /dev/null +++ b/tests/AutoMapperTest/LazyMapSingleInitialization/expected.data @@ -0,0 +1,5 @@ +[ + "name" => "foo" + "extra" => "bar" + "reads" => 1 +] diff --git a/tests/AutoMapperTest/LazyMapSingleInitialization/map.php b/tests/AutoMapperTest/LazyMapSingleInitialization/map.php new file mode 100644 index 00000000..ad0834ad --- /dev/null +++ b/tests/AutoMapperTest/LazyMapSingleInitialization/map.php @@ -0,0 +1,39 @@ +name; + } +} + +$map = AutoMapperBuilder::buildAutoMapper()->map(new Source(), 'array', [MapperContext::LAZY_MAPPING => true]); + +// the mapping must run exactly once, whatever the number of accesses +$first = $map['name']; +$second = $map['name']; +$map['extra'] = 'bar'; +$exists = isset($map['name']); + +return [ + 'name' => $map['name'], + 'extra' => $map['extra'], + 'reads' => ReadCounter::$count, +]; diff --git a/tests/AutoMapperTest/MapFromObjectTransformer/expected.data b/tests/AutoMapperTest/MapFromObjectTransformer/expected.data new file mode 100644 index 00000000..b283cd9d --- /dev/null +++ b/tests/AutoMapperTest/MapFromObjectTransformer/expected.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\MapFromObjectTransformer\OrderDto { + +price: "123.45 €" +} diff --git a/tests/AutoMapperTest/MapFromObjectTransformer/map.php b/tests/AutoMapperTest/MapFromObjectTransformer/map.php new file mode 100644 index 00000000..f2d01b56 --- /dev/null +++ b/tests/AutoMapperTest/MapFromObjectTransformer/map.php @@ -0,0 +1,29 @@ +map(new Order(), OrderDto::class); diff --git a/tests/AutoMapperTest/MapToContextFinalGetter/expected.data b/tests/AutoMapperTest/MapToContextFinalGetter/expected.data new file mode 100644 index 00000000..62dcf65f --- /dev/null +++ b/tests/AutoMapperTest/MapToContextFinalGetter/expected.data @@ -0,0 +1,3 @@ +[ + "value" => "foo_bar" +] diff --git a/tests/AutoMapperTest/MapToContextFinalGetter/map.php b/tests/AutoMapperTest/MapToContextFinalGetter/map.php new file mode 100644 index 00000000..2371fa0d --- /dev/null +++ b/tests/AutoMapperTest/MapToContextFinalGetter/map.php @@ -0,0 +1,30 @@ +value}"; + } +} + +return AutoMapperBuilder::buildAutoMapper()->map( + new Source('bar'), + 'array', + [MapperContext::MAP_TO_ACCESSOR_PARAMETER => ['prefix' => 'foo']] +); diff --git a/tests/AutoMapperTest/MapToContextSkipNullValues/expected.data b/tests/AutoMapperTest/MapToContextSkipNullValues/expected.data new file mode 100644 index 00000000..62dcf65f --- /dev/null +++ b/tests/AutoMapperTest/MapToContextSkipNullValues/expected.data @@ -0,0 +1,3 @@ +[ + "value" => "foo_bar" +] diff --git a/tests/AutoMapperTest/MapToContextSkipNullValues/map.php b/tests/AutoMapperTest/MapToContextSkipNullValues/map.php new file mode 100644 index 00000000..0f11d7c7 --- /dev/null +++ b/tests/AutoMapperTest/MapToContextSkipNullValues/map.php @@ -0,0 +1,32 @@ +value}"; + } +} + +return AutoMapperBuilder::buildAutoMapper()->map( + new Source('bar'), + 'array', + [ + MapperContext::MAP_TO_ACCESSOR_PARAMETER => ['prefix' => 'foo'], + MapperContext::SKIP_NULL_VALUES => true, + ] +); diff --git a/tests/AutoMapperTest/MapperAttributePriority/expected.data b/tests/AutoMapperTest/MapperAttributePriority/expected.data new file mode 100644 index 00000000..0489a1e5 --- /dev/null +++ b/tests/AutoMapperTest/MapperAttributePriority/expected.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\MapperAttributePriority\Entity { + +foo: "bar" +} diff --git a/tests/AutoMapperTest/MapperAttributePriority/map.php b/tests/AutoMapperTest/MapperAttributePriority/map.php new file mode 100644 index 00000000..0d3922b2 --- /dev/null +++ b/tests/AutoMapperTest/MapperAttributePriority/map.php @@ -0,0 +1,29 @@ +foo = $foo . ' (constructor used)'; + } +} + +#[Mapper(constructorStrategy: ConstructorStrategy::ALWAYS, priority: 1)] +class EntityDto +{ + public string $foo = 'bar'; +} + +return AutoMapperBuilder::buildAutoMapper()->map(new EntityDto(), Entity::class); diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-uninitialized-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-uninitialized-parent.data new file mode 100644 index 00000000..81d8d054 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-uninitialized-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserConstructorDto { + +zipcode: null +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-with-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-with-parent.data new file mode 100644 index 00000000..9f8274f1 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.construct-with-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserConstructorDto { + +zipcode: "13005" +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-null-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-null-parent.data new file mode 100644 index 00000000..091793a7 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-null-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserDto { + +zipcode: null +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-private-leaf.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-private-leaf.data new file mode 100644 index 00000000..3ba033d0 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-private-leaf.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserDto { + +zipcode: "69001" +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-uninitialized-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-uninitialized-parent.data new file mode 100644 index 00000000..091793a7 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-uninitialized-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserDto { + +zipcode: null +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-with-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-with-parent.data new file mode 100644 index 00000000..3f0b772d --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.read-with-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\UserDto { + +zipcode: "13005" +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-null-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-null-parent.data new file mode 100644 index 00000000..f3c26626 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-null-parent.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\NestedTarget { + +address: null +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-with-parent.data b/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-with-parent.data new file mode 100644 index 00000000..209d0fe6 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/expected.write-with-parent.data @@ -0,0 +1,5 @@ +AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\NestedTarget { + +address: AutoMapper\Tests\AutoMapperTest\NestedPropertyAccessors\Address { + +zipcode: "75000" + } +} diff --git a/tests/AutoMapperTest/NestedPropertyAccessors/map.php b/tests/AutoMapperTest/NestedPropertyAccessors/map.php new file mode 100644 index 00000000..1550dc65 --- /dev/null +++ b/tests/AutoMapperTest/NestedPropertyAccessors/map.php @@ -0,0 +1,104 @@ +zipcode = $zipcode; + } +} + +class User +{ + public Address $address; +} + +class NullableUser +{ + public ?Address $address = null; +} + +class PrivateUser +{ + public PrivateAddress $address; +} + +class UserDto +{ + #[MapFrom(property: 'address.zipcode')] + public ?string $zipcode = null; +} + +class UserConstructorDto +{ + public function __construct( + #[MapFrom(property: 'address.zipcode')] + public ?string $zipcode = null, + ) { + } +} + +class NestedTarget +{ + public ?Address $address = null; +} + +class ZipSource +{ + #[MapTo(target: NestedTarget::class, property: 'address.zipcode')] + public string $zipcode = '75000'; +} + +return (function () { + $autoMapper = AutoMapperBuilder::buildAutoMapper(); + + // uninitialized typed parent property: must be skipped, not crash + yield 'read-uninitialized-parent' => $autoMapper->map(new User(), UserDto::class); + + // null parent property: must be skipped + yield 'read-null-parent' => $autoMapper->map(new NullableUser(), UserDto::class); + + // defined parent: nested value is mapped + $user = new User(); + $user->address = new Address(); + $user->address->zipcode = '13005'; + + yield 'read-with-parent' => $autoMapper->map($user, UserDto::class); + + // uninitialized parent through the constructor path: must fall back to the default value + yield 'construct-uninitialized-parent' => $autoMapper->map(new User(), UserConstructorDto::class); + + yield 'construct-with-parent' => $autoMapper->map($user, UserConstructorDto::class); + + // nested write on a null parent: must be skipped, not crash + yield 'write-null-parent' => $autoMapper->map(new ZipSource(), NestedTarget::class); + + // nested write on an existing parent + $target = new NestedTarget(); + $target->address = new Address(); + + yield 'write-with-parent' => $autoMapper->map(new ZipSource(), $target); + + // private nested leaf property + $privateMapper = AutoMapperBuilder::buildAutoMapper(mapPrivatePropertiesAndMethod: true); + + $privateUser = new PrivateUser(); + $privateUser->address = new PrivateAddress('69001'); + + yield 'read-private-leaf' => $privateMapper->map($privateUser, UserDto::class); +})(); diff --git a/tests/AutoMapperTest/NullableDoctrineCollection/expected.data b/tests/AutoMapperTest/NullableDoctrineCollection/expected.data new file mode 100644 index 00000000..fe7cc1e4 --- /dev/null +++ b/tests/AutoMapperTest/NullableDoctrineCollection/expected.data @@ -0,0 +1,9 @@ +AutoMapper\Tests\AutoMapperTest\NullableDoctrineCollection\Target { + +items: Doctrine\Common\Collections\ArrayCollection { + -elements: [ + AutoMapper\Tests\AutoMapperTest\NullableDoctrineCollection\ItemDto { + +name: "foo" + } + ] + } +} diff --git a/tests/AutoMapperTest/NullableDoctrineCollection/map.php b/tests/AutoMapperTest/NullableDoctrineCollection/map.php new file mode 100644 index 00000000..b52b6c1d --- /dev/null +++ b/tests/AutoMapperTest/NullableDoctrineCollection/map.php @@ -0,0 +1,38 @@ + */ + public ?Collection $items = null; +} + +$item = new Item(); +$item->name = 'foo'; + +$source = new Source(); +$source->items = [$item]; + +return AutoMapperBuilder::buildAutoMapper()->map($source, Target::class); diff --git a/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values-untyped.data b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values-untyped.data new file mode 100644 index 00000000..ff7d5850 --- /dev/null +++ b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values-untyped.data @@ -0,0 +1,4 @@ +AutoMapper\Tests\AutoMapperTest\NullableSourceNonNullableTarget\UntypedTarget { + +date: null + +name: null +} diff --git a/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values.data b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values.data new file mode 100644 index 00000000..d30fc92a --- /dev/null +++ b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.null-values.data @@ -0,0 +1,4 @@ +TypeError { + +class: "TypeError" + +message: "Cannot assign null to property AutoMapper\Tests\AutoMapperTest\NullableSourceNonNullableTarget\Target::$date of type string" +} diff --git a/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.with-values.data b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.with-values.data new file mode 100644 index 00000000..32e913e0 --- /dev/null +++ b/tests/AutoMapperTest/NullableSourceNonNullableTarget/expected.with-values.data @@ -0,0 +1,4 @@ +AutoMapper\Tests\AutoMapperTest\NullableSourceNonNullableTarget\Target { + +date: "2021-01-01T00:00:00+00:00" + +name: "foo" +} diff --git a/tests/AutoMapperTest/NullableSourceNonNullableTarget/map.php b/tests/AutoMapperTest/NullableSourceNonNullableTarget/map.php new file mode 100644 index 00000000..5faba568 --- /dev/null +++ b/tests/AutoMapperTest/NullableSourceNonNullableTarget/map.php @@ -0,0 +1,48 @@ + $autoMapper->map(new Source(), Target::class); + } catch (\Throwable $th) { + yield 'null-values' => $th; + } + + // null source values on an untyped (docblock only) target: null is assigned as-is + yield 'null-values-untyped' => $autoMapper->map(new Source(), UntypedTarget::class); + + $source = new Source(); + $source->date = new \DateTimeImmutable('2021-01-01T00:00:00+00:00'); + $source->name = 'foo'; + + yield 'with-values' => $autoMapper->map($source, Target::class); +})(); diff --git a/tests/AutoMapperTest/ObjectMapperIfConditions/expected.source-side.data b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.source-side.data new file mode 100644 index 00000000..af7c78a2 --- /dev/null +++ b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.source-side.data @@ -0,0 +1,6 @@ +AutoMapper\Tests\AutoMapperTest\ObjectMapperIfConditions\Target { + +secret: "unchanged" + +name: "john" + +active: true + +score: 42 +} diff --git a/tests/AutoMapperTest/ObjectMapperIfConditions/expected.subclass-target.data b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.subclass-target.data new file mode 100644 index 00000000..8b76af42 --- /dev/null +++ b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.subclass-target.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\ObjectMapperIfConditions\ChildDto { + +fullName: "zoe" +} diff --git a/tests/AutoMapperTest/ObjectMapperIfConditions/expected.target-side.data b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.target-side.data new file mode 100644 index 00000000..9dbc4ccf --- /dev/null +++ b/tests/AutoMapperTest/ObjectMapperIfConditions/expected.target-side.data @@ -0,0 +1,5 @@ +AutoMapper\Tests\AutoMapperTest\ObjectMapperIfConditions\TargetB { + +username: "jdoe" + +hidden: "unchanged" + +code: "ABC" +} diff --git a/tests/AutoMapperTest/ObjectMapperIfConditions/map.php b/tests/AutoMapperTest/ObjectMapperIfConditions/map.php new file mode 100644 index 00000000..93c3cafb --- /dev/null +++ b/tests/AutoMapperTest/ObjectMapperIfConditions/map.php @@ -0,0 +1,97 @@ + skipped + #[Map(if: 'boolval')] + public bool $active = false; + + // other callables receive ($value, $source, $target) like symfony/object-mapper + #[Map(if: [self::class, 'shouldMapScore'])] + public int $score = 42; + + public static function shouldMapScore(mixed $value, ?object $source = null, ?object $target = null): bool + { + return 42 === $value && $source instanceof self; + } +} + +class Target +{ + public string $secret = 'unchanged'; + public string $name = 'unchanged'; + public bool $active = true; + public int $score = 0; +} + +final class UppercaseCheck +{ + public function __invoke(mixed $value, ?object $source = null, ?object $target = null): bool + { + return \is_string($value) && $value === strtoupper($value); + } +} + +class SourceB +{ + public string $login = 'jdoe'; + public string $hidden = 'x'; + public string $code = 'ABC'; + + public static function hasLogin(mixed $value, ?object $source = null, ?object $target = null): bool + { + return 'jdoe' === $value; + } +} + +class TargetB +{ + #[Map(source: 'login', if: [SourceB::class, 'hasLogin'])] + public string $username = 'anonymous'; + + #[Map(source: 'hidden', if: false)] + public string $hidden = 'unchanged'; + + #[Map(source: 'code', if: new UppercaseCheck())] + public string $code = 'none'; +} + +// the class level Map target also matches child classes of the configured target +#[Map(target: BaseDto::class)] +class SourceC +{ + #[Map(target: 'fullName')] + public string $name = 'zoe'; +} + +class BaseDto +{ + public string $fullName = ''; +} + +class ChildDto extends BaseDto +{ +} + +return (static function () { + $autoMapper = AutoMapperBuilder::buildAutoMapper(); + + yield 'source-side' => $autoMapper->map(new Source(), Target::class); + + yield 'target-side' => $autoMapper->map(new SourceB(), TargetB::class); + + yield 'subclass-target' => $autoMapper->map(new SourceC(), ChildDto::class); +})(); diff --git a/tests/AutoMapperTest/ScalarToBackedEnum/expected.data b/tests/AutoMapperTest/ScalarToBackedEnum/expected.data new file mode 100644 index 00000000..0c406859 --- /dev/null +++ b/tests/AutoMapperTest/ScalarToBackedEnum/expected.data @@ -0,0 +1,10 @@ +AutoMapper\Tests\AutoMapperTest\ScalarToBackedEnum\Target { + +status: AutoMapper\Tests\AutoMapperTest\ScalarToBackedEnum\Status { + +name: "Active" + +value: "active" + } + +level: AutoMapper\Tests\AutoMapperTest\ScalarToBackedEnum\Level { + +name: "High" + +value: 10 + } +} diff --git a/tests/AutoMapperTest/ScalarToBackedEnum/map.php b/tests/AutoMapperTest/ScalarToBackedEnum/map.php new file mode 100644 index 00000000..6dd5a79f --- /dev/null +++ b/tests/AutoMapperTest/ScalarToBackedEnum/map.php @@ -0,0 +1,33 @@ +map(new Source(), Target::class); diff --git a/tests/AutoMapperTest/SingleMemberUnion/expected.data b/tests/AutoMapperTest/SingleMemberUnion/expected.data new file mode 100644 index 00000000..4b480292 --- /dev/null +++ b/tests/AutoMapperTest/SingleMemberUnion/expected.data @@ -0,0 +1,6 @@ +[ + "int" => "42" + "object" => [ + "error" => "TypeError" + ] +] diff --git a/tests/AutoMapperTest/SingleMemberUnion/map.php b/tests/AutoMapperTest/SingleMemberUnion/map.php new file mode 100644 index 00000000..a44479d0 --- /dev/null +++ b/tests/AutoMapperTest/SingleMemberUnion/map.php @@ -0,0 +1,46 @@ +value = 42; + + // object value of the other union member: must not be blindly cast to string + $objectSource = new Source(); + $objectSource->value = new Money(); + + try { + $objectResult = ['value' => $autoMapper->map($objectSource, Target::class)->value]; + } catch (\Throwable $th) { + $objectResult = ['error' => $th::class]; + } + + return [ + 'int' => $autoMapper->map($intSource, Target::class)->value, + 'object' => $objectResult, + ]; +})(); diff --git a/tests/AutoMapperTest/StaticProperty/expected.to-array.data b/tests/AutoMapperTest/StaticProperty/expected.to-array.data new file mode 100644 index 00000000..591b06f8 --- /dev/null +++ b/tests/AutoMapperTest/StaticProperty/expected.to-array.data @@ -0,0 +1,3 @@ +[ + "name" => "john" +] diff --git a/tests/AutoMapperTest/StaticProperty/expected.to-object.data b/tests/AutoMapperTest/StaticProperty/expected.to-object.data new file mode 100644 index 00000000..a63adf51 --- /dev/null +++ b/tests/AutoMapperTest/StaticProperty/expected.to-object.data @@ -0,0 +1,3 @@ +AutoMapper\Tests\AutoMapperTest\StaticProperty\Target { + +name: "john" +} diff --git a/tests/AutoMapperTest/StaticProperty/map.php b/tests/AutoMapperTest/StaticProperty/map.php new file mode 100644 index 00000000..a3829ea8 --- /dev/null +++ b/tests/AutoMapperTest/StaticProperty/map.php @@ -0,0 +1,30 @@ + $autoMapper->map(new Source(), 'array'); + + yield 'to-object' => $autoMapper->map(new Source(), Target::class); +})(); diff --git a/tests/AutoMapperTest/SymfonyUId/expected.array-to-object-v4.data b/tests/AutoMapperTest/SymfonyUId/expected.array-to-object-v4.data index b2b51368..5fe80b46 100644 --- a/tests/AutoMapperTest/SymfonyUId/expected.array-to-object-v4.data +++ b/tests/AutoMapperTest/SymfonyUId/expected.array-to-object-v4.data @@ -1,5 +1,5 @@ AutoMapper\Tests\AutoMapperTest\SymfonyUId\SymfonyUuidUser { - -uuid: Symfony\Component\Uid\Uuid { + -uuid: Symfony\Component\Uid\UuidV4 { #uid: "9dbee72c-ebe5-450e-843c-bb06ea7fd4be" toBase58: "LUnjmkZGWrPYUEHdNgy1eD" toBase32: "4XQVKJSTZ58M788F5V0VN7ZN5Y" diff --git a/tests/AutoMapperTest/SymfonyUidSubclass/expected.data b/tests/AutoMapperTest/SymfonyUidSubclass/expected.data new file mode 100644 index 00000000..6cbcb52d --- /dev/null +++ b/tests/AutoMapperTest/SymfonyUidSubclass/expected.data @@ -0,0 +1,4 @@ +[ + "class" => "Symfony\Component\Uid\UuidV4" + "value" => "9dbee72c-ebe5-450e-843c-bb06ea7fd4be" +] diff --git a/tests/AutoMapperTest/SymfonyUidSubclass/map.php b/tests/AutoMapperTest/SymfonyUidSubclass/map.php new file mode 100644 index 00000000..7d91c726 --- /dev/null +++ b/tests/AutoMapperTest/SymfonyUidSubclass/map.php @@ -0,0 +1,36 @@ + object: the concrete UuidV4 target type must be preserved + $source = new Source(Uuid::fromString('9dbee72c-ebe5-450e-843c-bb06ea7fd4be')); + + $target = $autoMapper->map($source, Target::class); + + return [ + 'class' => $target->id::class, + 'value' => (string) $target->id, + ]; +})(); diff --git a/tests/AutoMapperTest/UnionSourceProperty/expected.datetime-date-string-status.data b/tests/AutoMapperTest/UnionSourceProperty/expected.datetime-date-string-status.data new file mode 100644 index 00000000..79e06fd7 --- /dev/null +++ b/tests/AutoMapperTest/UnionSourceProperty/expected.datetime-date-string-status.data @@ -0,0 +1,6 @@ +AutoMapper\Tests\AutoMapperTest\UnionSourceProperty\Target { + +date: DateTimeImmutable @1643760000 { + date: 2022-02-02 00:00:00.0 +00:00 + } + +status: "inactive" +} diff --git a/tests/AutoMapperTest/UnionSourceProperty/expected.string-date-enum-status.data b/tests/AutoMapperTest/UnionSourceProperty/expected.string-date-enum-status.data new file mode 100644 index 00000000..d42b2ce1 --- /dev/null +++ b/tests/AutoMapperTest/UnionSourceProperty/expected.string-date-enum-status.data @@ -0,0 +1,6 @@ +AutoMapper\Tests\AutoMapperTest\UnionSourceProperty\Target { + +date: DateTimeImmutable @1609459200 { + date: 2021-01-01 00:00:00.0 +00:00 + } + +status: "active" +} diff --git a/tests/AutoMapperTest/UnionSourceProperty/map.php b/tests/AutoMapperTest/UnionSourceProperty/map.php new file mode 100644 index 00000000..40400df5 --- /dev/null +++ b/tests/AutoMapperTest/UnionSourceProperty/map.php @@ -0,0 +1,42 @@ + \DateTimeImmutable, enum -> string) + $source = new Source(); + $source->date = '2021-01-01T00:00:00+00:00'; + $source->status = Status::Active; + + yield 'string-date-enum-status' => $autoMapper->map($source, Target::class); + + $source = new Source(); + $source->date = new \DateTime('2022-02-02T00:00:00+00:00'); + $source->status = 'inactive'; + + yield 'datetime-date-string-status' => $autoMapper->map($source, Target::class); +})(); diff --git a/tests/AutoMapperTest/VariadicConstructor/expected.with-values.data b/tests/AutoMapperTest/VariadicConstructor/expected.with-values.data new file mode 100644 index 00000000..4c9d0d83 --- /dev/null +++ b/tests/AutoMapperTest/VariadicConstructor/expected.with-values.data @@ -0,0 +1,7 @@ +AutoMapper\Tests\AutoMapperTest\VariadicConstructor\Target { + +tags: [ + "dev" + "php" + ] + +name: "john" +} diff --git a/tests/AutoMapperTest/VariadicConstructor/expected.without-values.data b/tests/AutoMapperTest/VariadicConstructor/expected.without-values.data new file mode 100644 index 00000000..b55aea78 --- /dev/null +++ b/tests/AutoMapperTest/VariadicConstructor/expected.without-values.data @@ -0,0 +1,4 @@ +AutoMapper\Tests\AutoMapperTest\VariadicConstructor\Target { + +tags: [] + +name: "jane" +} diff --git a/tests/AutoMapperTest/VariadicConstructor/map.php b/tests/AutoMapperTest/VariadicConstructor/map.php new file mode 100644 index 00000000..723c738f --- /dev/null +++ b/tests/AutoMapperTest/VariadicConstructor/map.php @@ -0,0 +1,46 @@ +tags = $tags; + } +} + +return (function () { + $autoMapper = AutoMapperBuilder::buildAutoMapper(); + + $source = new Source(); + $source->tags = ['dev', 'php']; + + // variadic constructor parameter receives each value as its own argument + yield 'with-values' => $autoMapper->map($source, Target::class); + + // absent variadic values: the parameter is optional, no exception + yield 'without-values' => $autoMapper->map(new SourceWithoutTags(), Target::class); +})(); diff --git a/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php b/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php index 6c2a83bb..a482b3a5 100644 --- a/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php +++ b/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php @@ -4,8 +4,14 @@ namespace AutoMapper\Tests\Bundle\DependencyInjection; +use AutoMapper\Event\GenerateMapperEvent; +use AutoMapper\EventListener\Doctrine\DoctrineProviderListener; +use AutoMapper\EventListener\ObjectMapper\MapSourceListener; +use AutoMapper\EventListener\ObjectMapper\MapTargetListener; +use AutoMapper\EventListener\Symfony\ClassDiscriminatorListener; use AutoMapper\Loader\FileLoader; use AutoMapper\Loader\FileReloadStrategy; +use AutoMapper\Symfony\Bundle\CacheWarmup\CacheWarmer; use AutoMapper\Symfony\Bundle\DependencyInjection\AutoMapperExtension; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -38,6 +44,62 @@ public static function provideReloadStrategyConfiguration(): iterable yield 'Applies configured reload strategy if provided' => [['reload_strategy' => FileReloadStrategy::NEVER->value], true, FileReloadStrategy::NEVER]; } + public function testEvalLoaderDoesNotRegisterTheCacheWarmer(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load(['loader' => ['eval' => true]]); + + // there is no cache directory with the eval loader: the cache warmer must not be registered, + // otherwise the container fails to compile on the missing automapper.cache_dir parameter + $this->assertContainerBuilderNotHasService(CacheWarmer::class); + + $this->container->compile(); + } + + public function testFileLoaderRegistersTheCacheWarmer(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load(); + + $this->assertContainerBuilderHasService(CacheWarmer::class); + $this->assertContainerBuilderHasParameter('automapper.cache_dir'); + } + + public function testObjectMapperListenersRunAfterDoctrineAndDiscriminator(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load([ + 'object_mapper' => true, + 'doctrine' => true, + 'serializer_attributes' => true, + ]); + + $mapSource = $this->generateMapperListenerPriority(MapSourceListener::class); + $mapTarget = $this->generateMapperListenerPriority(MapTargetListener::class); + $doctrine = $this->generateMapperListenerPriority(DoctrineProviderListener::class); + $discriminator = $this->generateMapperListenerPriority(ClassDiscriminatorListener::class); + + // higher priority runs first: MapSource/MapTarget must run after the Doctrine and discriminator + // listeners so their stopPropagation for Map-attributed classes does not suppress them + self::assertLessThan($doctrine, $mapSource); + self::assertLessThan($doctrine, $mapTarget); + self::assertLessThan($discriminator, $mapSource); + self::assertLessThan($discriminator, $mapTarget); + } + + private function generateMapperListenerPriority(string $serviceId): int + { + $definition = $this->container->getDefinition($serviceId); + + foreach ($definition->getTag('kernel.event_listener') as $tag) { + if (($tag['event'] ?? null) === GenerateMapperEvent::class) { + return (int) ($tag['priority'] ?? 0); + } + } + + self::fail(\sprintf('Service "%s" has no GenerateMapperEvent listener tag.', $serviceId)); + } + protected function getContainerExtensions(): array { return [new AutoMapperExtension()]; diff --git a/tests/Bundle/ServiceInstantiationTest.php b/tests/Bundle/ServiceInstantiationTest.php index 4fdc9918..c86b1e75 100644 --- a/tests/Bundle/ServiceInstantiationTest.php +++ b/tests/Bundle/ServiceInstantiationTest.php @@ -319,7 +319,8 @@ public static function mapProvider(): iterable $b = new B('test'); $b->transform = 'TEST'; $b->baz = 'me'; - $b->nomap = false; + // A::$nomap is false so its `if: 'boolval'` condition skips the mapping, B keeps its default value + $b->nomap = true; $b->concat = 'shouldtestme'; $b->relation = $d; $b->relationNotMapped = $d; diff --git a/tests/Doctrine/DoctrineTest.php b/tests/Doctrine/DoctrineTest.php index 6092c281..655f0d6d 100644 --- a/tests/Doctrine/DoctrineTest.php +++ b/tests/Doctrine/DoctrineTest.php @@ -119,6 +119,27 @@ public function testAutoMappingMultipleIdentifiers(): void $this->assertEquals('Bar', $review->user->name); } + public function testMetadataNotAlreadyLoaded(): void + { + $book = new Book(); + + $this->entityManager->persist($book); + $this->entityManager->flush(); + + // a fresh entity manager: its metadata factory has not loaded any metadata yet, like on the + // first request of a process or during cache:warmup + $coldEntityManager = new EntityManager($this->entityManager->getConnection(), $this->entityManager->getConfiguration()); + $this->assertFalse($coldEntityManager->getMetadataFactory()->hasMetadataFor(Book::class)); + + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'ColdMetadataMapper_', objectManager: $coldEntityManager); + + $mappedBook = $autoMapper->map(['id' => $book->id, 'author' => 'John Doe'], Book::class); + + // the doctrine provider must have been used to fetch the managed entity + $this->assertTrue($coldEntityManager->contains($mappedBook)); + $this->assertEquals('John Doe', $mappedBook->author); + } + public function testDisabledProvider(): void { $foo = new Foo(); diff --git a/tests/Loader/FileLoaderTest.php b/tests/Loader/FileLoaderTest.php new file mode 100644 index 00000000..74cbe36f --- /dev/null +++ b/tests/Loader/FileLoaderTest.php @@ -0,0 +1,120 @@ +directory = sys_get_temp_dir() . \DIRECTORY_SEPARATOR . uniqid('automapper_file_loader_', true); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->directory); + } + + public function testRegistryKeepsEntriesWrittenByOtherProcesses(): void + { + // a first process generates a mapper, its hash is stored in the registry + $autoMapper = AutoMapper::create(cacheDirectory: $this->directory); + $autoMapper->map(new FirstDummy(), 'array'); + + $registry = require $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; + self::assertIsArray($registry); + self::assertCount(1, $registry); + + // a fresh instance (e.g. the cache warmer) generates another mapper without having read the registry first + $freshAutoMapper = AutoMapper::create(cacheDirectory: $this->directory); + $loader = (new \ReflectionProperty(AutoMapper::class, 'classLoader'))->getValue($freshAutoMapper); + self::assertInstanceOf(FileLoader::class, $loader); + + $metadataRegistry = new MetadataRegistry(new Configuration()); + $metadataRegistry->register(SecondDummy::class, 'array'); + $loader->buildMappers($metadataRegistry); + + // both hashes must be present, the first entry must not have been discarded + $registry = require $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; + self::assertIsArray($registry); + self::assertCount(2, $registry); + } + + public function testCacheDirectoryIsCreatedRecursively(): void + { + $nestedDirectory = $this->directory . \DIRECTORY_SEPARATOR . 'nested' . \DIRECTORY_SEPARATOR . 'deep'; + + $autoMapper = AutoMapper::create(cacheDirectory: $nestedDirectory); + $mapped = $autoMapper->map(new NestedDirectoryDummy(), 'array'); + + self::assertSame(['foo' => 'foo'], $mapped); + self::assertDirectoryExists($nestedDirectory); + } + + public function testCorruptedRegistryIsIgnored(): void + { + mkdir($this->directory, 0755, true); + file_put_contents($this->directory . \DIRECTORY_SEPARATOR . 'registry.php', "directory); + $mapped = $autoMapper->map(new CorruptedRegistryDummy(), 'array'); + + self::assertSame(['foo' => 'foo'], $mapped); + + // the registry has been rebuilt + $registry = require $this->directory . \DIRECTORY_SEPARATOR . 'registry.php'; + self::assertIsArray($registry); + self::assertCount(1, $registry); + } + + public function testNoTemporaryFileIsLeftBehind(): void + { + $autoMapper = AutoMapper::create(cacheDirectory: $this->directory); + $autoMapper->map(new TemporaryFileDummy(), 'array'); + + foreach (new \DirectoryIterator($this->directory) as $file) { + if ($file->isDot()) { + continue; + } + + self::assertStringEndsWith('.php', $file->getFilename(), 'Only fully written php files should be present in the cache directory.'); + } + } +} diff --git a/tests/Normalizer/AutoMapperNormalizerTest.php b/tests/Normalizer/AutoMapperNormalizerTest.php index 50a615bc..4390ac55 100644 --- a/tests/Normalizer/AutoMapperNormalizerTest.php +++ b/tests/Normalizer/AutoMapperNormalizerTest.php @@ -126,6 +126,14 @@ public function getIterator(): \Traversable $stdClass->name = 'Jack'; $stdClass->age = 37; self::assertFalse($this->normalizer->supportsNormalization($stdClass)); + + // value objects handled by dedicated symfony normalizers must not be claimed by AutoMapper + self::assertFalse($this->normalizer->supportsNormalization(new \DateTimeImmutable())); + self::assertFalse($this->normalizer->supportsNormalization(new \DateTime())); + self::assertFalse($this->normalizer->supportsNormalization(new \DateInterval('P1D'))); + self::assertFalse($this->normalizer->supportsNormalization(new \DateTimeZone('UTC'))); + self::assertFalse($this->normalizer->supportsNormalization(Fixtures\AddressType::FLAT)); + self::assertFalse($this->normalizer->supportsNormalization(\Symfony\Component\Uid\Uuid::v4())); } public function testSupportsDenormalization(): void @@ -136,6 +144,28 @@ public function testSupportsDenormalization(): void $user = ['id' => 1, 'name' => 'Jack', 'age' => 37]; self::assertTrue($this->normalizer->supportsDenormalization($user, Fixtures\User::class)); self::assertTrue($this->normalizer->supportsDenormalization($user, \stdClass::class)); + + // value objects handled by dedicated symfony normalizers must not be claimed by AutoMapper + self::assertFalse($this->normalizer->supportsDenormalization('now', \DateTimeImmutable::class)); + self::assertFalse($this->normalizer->supportsDenormalization('now', \DateTimeInterface::class)); + self::assertFalse($this->normalizer->supportsDenormalization('flat', Fixtures\AddressType::class)); + } + + public function testDoesNotPreemptDedicatedNormalizers(): void + { + // a serializer stack where AutoMapper has a higher priority than the core normalizers + $serializer = new \Symfony\Component\Serializer\Serializer([ + $this->normalizer, + new DateTimeNormalizer(), + new \Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer(), + ]); + + // the dedicated normalizers must win, AutoMapper must not produce a structure dump + self::assertSame( + '2021-01-01T00:00:00+00:00', + $serializer->normalize(new \DateTimeImmutable('2021-01-01T00:00:00+00:00')) + ); + self::assertSame('flat', $serializer->normalize(Fixtures\AddressType::FLAT)); } public function testNormalizeWithNoReturnType(): void diff --git a/tests/ObjectMapper/ObjectMapperTest.php b/tests/ObjectMapper/ObjectMapperTest.php index 443bf614..c63ad698 100644 --- a/tests/ObjectMapper/ObjectMapperTest.php +++ b/tests/ObjectMapper/ObjectMapperTest.php @@ -129,7 +129,8 @@ public static function mapProvider(): iterable $b = new B('test'); $b->transform = 'TEST'; $b->baz = 'me'; - $b->nomap = false; + // A::$nomap is false so its `if: 'boolval'` condition skips the mapping, B keeps its default value + $b->nomap = true; $b->concat = 'shouldtestme'; $b->relation = $d; $b->relationNotMapped = $d; diff --git a/tests/Transformer/ApiPlatform/JsonLdContextTransformerTest.php b/tests/Transformer/ApiPlatform/JsonLdContextTransformerTest.php new file mode 100644 index 00000000..ef8ae5ac --- /dev/null +++ b/tests/Transformer/ApiPlatform/JsonLdContextTransformerTest.php @@ -0,0 +1,61 @@ +createMock(ContextBuilderInterface::class); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + + // the source is NOT a resource: getResourceClass must never be called when a computed class is given + $resourceClassResolver->expects(self::never())->method('isResourceClass'); + $resourceClassResolver->expects(self::never())->method('getResourceClass'); + + $contextBuilder->expects(self::once()) + ->method('getResourceContextUri') + ->with('App\\Entity\\Book') + ->willReturn('/contexts/Book'); + + $transformer = new JsonLdContextTransformer($contextBuilder, $resourceClassResolver); + + $result = $transformer->transform(null, new \stdClass(), [], 'App\\Entity\\Book'); + + self::assertSame('/contexts/Book', $result); + } + + public function testResolvesResourceClassWhenNotComputed(): void + { + $contextBuilder = $this->createMock(ContextBuilderInterface::class); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + + $source = new \stdClass(); + + $resourceClassResolver->method('isResourceClass')->with(\stdClass::class)->willReturn(true); + $resourceClassResolver->method('getResourceClass')->with($source)->willReturn('App\\Entity\\Book'); + + $contextBuilder->expects(self::once()) + ->method('getResourceContextUri') + ->with('App\\Entity\\Book') + ->willReturn('/contexts/Book'); + + $transformer = new JsonLdContextTransformer($contextBuilder, $resourceClassResolver); + + self::assertSame('/contexts/Book', $transformer->transform(null, $source, [])); + } +} diff --git a/tests/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactoryTest.php b/tests/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactoryTest.php new file mode 100644 index 00000000..04fd9c54 --- /dev/null +++ b/tests/Transformer/ApiPlatform/JsonLdObjectToIdTransformerFactoryTest.php @@ -0,0 +1,73 @@ +createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $classMetadata = $this->createMock(ClassMetadataInterface::class); + $classMetadata->method('getAttributesMetadata')->willReturn([]); + + $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); + $classMetadataFactory->method('getMetadataFor')->willReturn($classMetadata); + + return new JsonLdObjectToIdTransformerFactory($resourceClassResolver, $classMetadataFactory); + } + + /** + * @return iterable + */ + public static function provideTargetTypes(): iterable + { + // the transformer may output a nested array (groups) or an IRI, so it only applies to targets + // able to hold both, not to concrete scalar/object properties + yield 'mixed target is supported' => [Type::mixed(), true]; + yield 'array target is supported' => [Type::array(), true]; + yield 'string target is not supported' => [Type::string(), false]; + yield 'int target is not supported' => [Type::int(), false]; + yield 'object target is not supported' => [Type::object(\stdClass::class), false]; + } + + #[DataProvider('provideTargetTypes')] + public function testTargetTypeGating(Type $targetType, bool $supported): void + { + $factory = $this->createFactory(); + + $source = new SourcePropertyMetadata('foo', type: Type::object(\stdClass::class)); + $target = new TargetPropertyMetadata('foo', type: $targetType); + $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + + $transformer = $factory->getTransformer($source, $target, $mapperMetadata); + + if ($supported) { + self::assertInstanceOf(JsonLdObjectToIdTransformer::class, $transformer); + } else { + self::assertNull($transformer); + } + } +} diff --git a/tests/Transformer/MultipleTransformerFactoryTest.php b/tests/Transformer/MultipleTransformerFactoryTest.php index bbb2ee26..865c297d 100644 --- a/tests/Transformer/MultipleTransformerFactoryTest.php +++ b/tests/Transformer/MultipleTransformerFactoryTest.php @@ -7,7 +7,6 @@ use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; -use AutoMapper\Transformer\BuiltinTransformer; use AutoMapper\Transformer\BuiltinTransformerFactory; use AutoMapper\Transformer\ChainTransformerFactory; use AutoMapper\Transformer\MultipleTransformer; @@ -32,12 +31,14 @@ public function testGetTransformer(): void self::assertNotNull($transformer); self::assertInstanceOf(MultipleTransformer::class, $transformer); + // the DateInterval branch has no transformer, the string branch must still be guarded at runtime + // so a DateInterval value is not blindly transformed: a MultipleTransformer is returned $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::union(Type::string(), Type::object(\DateInterval::class))); $targetMapperMetadata = new TargetPropertyMetadata('foo'); $transformer = $factory->getTransformer($sourceMapperMetadata, $targetMapperMetadata, $mapperMetadata); self::assertNotNull($transformer); - self::assertInstanceOf(BuiltinTransformer::class, $transformer); + self::assertInstanceOf(MultipleTransformer::class, $transformer); } public function testNoTransformerIfNoSubTransformer(): void