diff --git a/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php b/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php index 0789b7e9f92c8..756571fb34992 100644 --- a/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php +++ b/src/Symfony/Component/ObjectMapper/Metadata/ReflectionObjectMapperMetadataFactory.php @@ -13,6 +13,7 @@ use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Transform\MapEnum; /** * @internal @@ -29,21 +30,119 @@ public function create(object $object, ?string $property = null, array $context try { $key = $object::class.($property ?? ''); - if (isset($this->attributesCache[$key])) { - return $this->attributesCache[$key]; + // Base mappings + if (!isset($this->attributesCache[$key])) { + $refl = $this->reflectionClassCache[$object::class] ??= new \ReflectionClass($object); + $attributes = ($property ? $refl->getProperty($property) : $refl)->getAttributes(Map::class, \ReflectionAttribute::IS_INSTANCEOF); + $mappings = []; + foreach ($attributes as $attribute) { + $map = $attribute->newInstance(); + $mappings[] = new Mapping($map->target, $map->source, $map->if, $map->transform); + } + $this->attributesCache[$key] = $mappings; } - $refl = $this->reflectionClassCache[$object::class] ??= new \ReflectionClass($object); - $attributes = ($property ? $refl->getProperty($property) : $refl)->getAttributes(Map::class, \ReflectionAttribute::IS_INSTANCEOF); - $mappings = []; - foreach ($attributes as $attribute) { - $map = $attribute->newInstance(); - $mappings[] = new Mapping($map->target, $map->source, $map->if, $map->transform); + $mappings = $this->attributesCache[$key]; + + // Enrich mappings with EnumTransformer (if target context is provided) + if ($property && isset($context['target_refl']) && $context['target_refl'] instanceof \ReflectionClass) { + $mappings = $this->enrichWithEnumTransformer($object, $property, $mappings, $context); } - return $this->attributesCache[$key] = $mappings; + return $mappings; } catch (\ReflectionException $e) { throw new MappingException($e->getMessage(), $e->getCode(), $e); } } + + /** + * @param list $mappings + * + * @return list + */ + private function enrichWithEnumTransformer(object $object, string $property, array $mappings, array $context): array + { + $targetRefl = $context['target_refl']; + $sourceRefl = $this->reflectionClassCache[$object::class] ??= new \ReflectionClass($object); + + if (!$sourceRefl->hasProperty($property)) { + return $mappings; + } + + $sourceProperty = $sourceRefl->getProperty($property); + $sourceType = $sourceProperty->getType(); + + if (!$sourceType instanceof \ReflectionNamedType) { + return $mappings; + } + + $sourceTypeName = $sourceType->getName(); + + $enrichedMappings = []; + foreach ($mappings as $mapping) { + $targetPropertyName = $mapping->target ?? $property; + + if (!$targetRefl->hasProperty($targetPropertyName)) { + $enrichedMappings[] = $mapping; + continue; + } + + $targetProperty = $targetRefl->getProperty($targetPropertyName); + $targetType = $targetProperty->getType(); + + if (!$targetType instanceof \ReflectionNamedType) { + $enrichedMappings[] = $mapping; + continue; + } + + $targetTypeName = $targetType->getName(); + $enumTransformer = $this->detectEnumTransformer($sourceTypeName, $targetTypeName); + + if (null === $enumTransformer) { + $enrichedMappings[] = $mapping; + continue; + } + + $transforms = $mapping->transform; + if (null === $transforms) { + $transforms = $enumTransformer; + } elseif (\is_array($transforms)) { + array_unshift($transforms, $enumTransformer); + } else { + $transforms = [$enumTransformer, $transforms]; + } + + $enrichedMappings[] = new Mapping($mapping->target, $mapping->source, $mapping->if, $transforms); + } + + if (empty($mappings) && $targetRefl->hasProperty($property)) { + $targetProperty = $targetRefl->getProperty($property); + $targetType = $targetProperty->getType(); + + if ($targetType instanceof \ReflectionNamedType) { + $enumTransformer = $this->detectEnumTransformer($sourceTypeName, $targetType->getName()); + + if (null !== $enumTransformer) { + $enrichedMappings[] = new Mapping(null, null, null, $enumTransformer); + } + } + } + + return $enrichedMappings ?: $mappings; + } + + private function detectEnumTransformer(string $sourceTypeName, string $targetTypeName): ?MapEnum + { + // BackedEnum -> scalar (int or string) + if (is_a($sourceTypeName, \BackedEnum::class, true) && \in_array($targetTypeName, ['int', 'string'], true)) { + return new MapEnum($targetTypeName); + } + + // scalar -> BackedEnum + if (\in_array($sourceTypeName, ['int', 'string'], true) && is_a($targetTypeName, \BackedEnum::class, true)) { + return new MapEnum($targetTypeName); + } + + return null; + } } diff --git a/src/Symfony/Component/ObjectMapper/ObjectMapper.php b/src/Symfony/Component/ObjectMapper/ObjectMapper.php index 4fbc89a405228..fc5e9de605ce0 100644 --- a/src/Symfony/Component/ObjectMapper/ObjectMapper.php +++ b/src/Symfony/Component/ObjectMapper/ObjectMapper.php @@ -133,7 +133,7 @@ private function doMap(object $source, object|string|null $target, \WeakMap $obj } $propertyName = $property->getName(); - $mappings = $this->metadataFactory->create($readMetadataFrom, $propertyName); + $mappings = $this->metadataFactory->create($readMetadataFrom, $propertyName, ['target_refl' => $targetRefl]); foreach ($mappings as $mapping) { $sourcePropertyName = $propertyName; if ($mapping->source && (!$refl->hasProperty($propertyName) || !isset($source->$propertyName))) { diff --git a/src/Symfony/Component/ObjectMapper/Transform/MapEnum.php b/src/Symfony/Component/ObjectMapper/Transform/MapEnum.php new file mode 100644 index 0000000000000..d62e7003b5d79 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Transform/MapEnum.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Transform; + +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * Transforms values between BackedEnum and their scalar representation. + * + * This transformer handles bidirectional conversion: + * - BackedEnum -> int|string (extracts the backing value) + * - int|string -> BackedEnum (creates enum from scalar) + * + * @implements TransformCallableInterface + * + * @author Julien Robic + */ +final class MapEnum implements TransformCallableInterface +{ + /** + * @param string $targetType The target type: 'int', 'string', or a BackedEnum class-string + */ + public function __construct( + private readonly string $targetType, + ) { + } + + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + if (null === $value) { + return null; + } + + // BackedEnum -> scalar + if ($value instanceof \BackedEnum && \in_array($this->targetType, ['int', 'string'], true)) { + return $this->fromBackedEnum($value); + } + + // Pure enum -> scalar (not allowed) + if ($value instanceof \UnitEnum && !$value instanceof \BackedEnum && \in_array($this->targetType, ['int', 'string'], true)) { + throw new MappingTransformException(\sprintf('Cannot convert pure enum "%s" to scalar type "%s". Only BackedEnum can be converted to scalar values.', $value::class, $this->targetType)); + } + + // scalar -> BackedEnum + if (is_a($this->targetType, \BackedEnum::class, true) && !\is_object($value)) { + return $this->toBackedEnum($value); + } + + // scalar -> pure enum (not allowed) + if (is_a($this->targetType, \UnitEnum::class, true) && !is_a($this->targetType, \BackedEnum::class, true) && !\is_object($value)) { + throw new MappingTransformException(\sprintf('Cannot convert "%s" to pure enum "%s". Pure enums cannot be created from scalar values.', get_debug_type($value), $this->targetType)); + } + + return $value; + } + + private function fromBackedEnum(\BackedEnum $enum): int|string + { + $backingType = get_debug_type($enum->value); + + if ($backingType !== $this->targetType) { + throw new MappingTransformException(\sprintf('Cannot convert "%s"-backed enum "%s" to "%s".', $backingType, $enum::class, $this->targetType)); + } + + return $enum->value; + } + + /** + * @return \BackedEnum + */ + private function toBackedEnum(int|string $value): \BackedEnum + { + $refl = new \ReflectionEnum($this->targetType); + $backingType = $refl->getBackingType(); + $expectedType = $backingType instanceof \ReflectionNamedType ? $backingType->getName() : (string) $backingType; + $actualType = get_debug_type($value); + + if ($expectedType !== $actualType) { + throw new MappingTransformException(\sprintf('Cannot convert "%s" to "%s"-backed enum "%s".', $actualType, $expectedType, $this->targetType)); + } + + try { + return $this->targetType::from($value); + } catch (\ValueError $e) { + throw new MappingTransformException(\sprintf('Invalid value "%s" for enum "%s": %s', $value, $this->targetType, $e->getMessage()), 0, $e); + } + } +}