Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Exception\MappingException;
use Symfony\Component\ObjectMapper\Transform\MapEnum;

/**
* @internal
Expand All @@ -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<Mapping> $mappings
*
* @return list<Mapping>
*/
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;
}
Comment on lines +134 to +147

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using sdtClass as the $source value

$readMetadataFrom = $source;
$refl = $this->getSourceReflectionClass($source) ?? $targetRefl;
// When source contains no metadata, we read metadata on the target instead
if ($refl === $targetRefl) {
$readMetadataFrom = $mappedTarget;
}

will produce a $readMetadataFrom that is the same as $targetRefl.

In this case, $sourceTypeName and $targetTypeName will be equal, so neither of the two conditions will be applied

Maybe for this we should do something like?

if ($sourceTypeName === $targetTypeName && is_a($targetTypeName, \BackedEnum::class, true)) {
    return new MapEnum($targetTypeName);
}

}
2 changes: 1 addition & 1 deletion src/Symfony/Component/ObjectMapper/ObjectMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
98 changes: 98 additions & 0 deletions src/Symfony/Component/ObjectMapper/Transform/MapEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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<object, object>
*
* @author Julien Robic <ayte91@gmail.com>
*/
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);
}
}
}
Loading