Skip to content

Commit 58a0bfa

Browse files
committed
[ObjectMapper] Auto backed Enum conversion
1 parent 32f6f6c commit 58a0bfa

File tree

10 files changed

+206
-3
lines changed

10 files changed

+206
-3
lines changed

src/Symfony/Component/ObjectMapper/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* The component is not marked as `@experimental` anymore
88
* Add `ObjectMapperAwareInterface` to set the owning object mapper instance
99
* Add a `MapCollection` transform that calls the Mapper over iterable properties
10+
* Automatic backed Enum to scalar conversion
1011

1112
7.3
1213
---

src/Symfony/Component/ObjectMapper/ObjectMapper.php

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ private function doMap(object $source, object|string|null $target, \WeakMap $obj
151151

152152
$targetPropertyName = $mapping->target ?? $propertyName;
153153
$value = $this->getSourceValue($source, $mappedTarget, $value, $objectMap, $mapping);
154-
$this->storeValue($targetPropertyName, $mapToProperties, $ctorArguments, $value);
154+
$this->storeValue($targetPropertyName, $mapToProperties, $ctorArguments, $value, $targetRefl, $sourcePropertyName);
155155
}
156156

157157
if (!$mappings && $targetRefl->hasProperty($propertyName)) {
@@ -161,7 +161,7 @@ private function doMap(object $source, object|string|null $target, \WeakMap $obj
161161
}
162162

163163
$value = $this->getSourceValue($source, $mappedTarget, $this->getRawValue($source, $propertyName), $objectMap);
164-
$this->storeValue($propertyName, $mapToProperties, $ctorArguments, $value);
164+
$this->storeValue($propertyName, $mapToProperties, $ctorArguments, $value, $targetRefl, $propertyName);
165165
}
166166
}
167167

@@ -238,6 +238,7 @@ private function getSourceValue(object $source, object $target, mixed $value, \W
238238

239239
if (
240240
\is_object($value)
241+
&& !($value instanceof \UnitEnum)
241242
&& ($innerMetadata = $this->metadataFactory->create($value))
242243
&& ($mapTo = $this->getMapTarget($innerMetadata, $value, $source, $target))
243244
&& (\is_string($mapTo->target) && class_exists($mapTo->target))
@@ -277,8 +278,13 @@ private function getSourceValue(object $source, object $target, mixed $value, \W
277278
* @param array<string, mixed> $mapToProperties
278279
* @param array<string, mixed> $ctorArguments
279280
*/
280-
private function storeValue(string $propertyName, array &$mapToProperties, array &$ctorArguments, mixed $value): void
281+
private function storeValue(string $propertyName, array &$mapToProperties, array &$ctorArguments, mixed $value, \ReflectionClass $targetRefl, ?string $sourcePropertyName = null): void
281282
{
283+
if ($this->needsEnumConversion($value, $targetRefl, $propertyName)) {
284+
$property = $targetRefl->getProperty($propertyName);
285+
$value = $this->convertEnumValue($value, $property);
286+
}
287+
282288
if (\array_key_exists($propertyName, $ctorArguments)) {
283289
$ctorArguments[$propertyName] = $value;
284290

@@ -397,4 +403,108 @@ public function withObjectMapper(ObjectMapperInterface $objectMapper): static
397403

398404
return $clone;
399405
}
406+
407+
private function needsEnumConversion(mixed $value, \ReflectionClass $targetRefl, string $propertyName): bool
408+
{
409+
if ($value instanceof \UnitEnum && !$value instanceof \BackedEnum) {
410+
$property = $targetRefl->getProperty($propertyName);
411+
$targetType = $property->getType();
412+
413+
if ($targetType instanceof \ReflectionNamedType && \in_array($targetType->getName(), ['string', 'int'], true)) {
414+
throw new MappingTransformException(\sprintf('Cannot map pure enum "%s" to scalar type "%s" on property "%s". Only BackedEnum can be converted to scalar values.', $value::class, $targetType->getName(), $propertyName));
415+
}
416+
417+
return false;
418+
}
419+
420+
if (!($value instanceof \BackedEnum || \is_int($value) || \is_string($value))) {
421+
return false;
422+
}
423+
424+
$property = $targetRefl->getProperty($propertyName);
425+
$targetType = $property->getType();
426+
427+
if (!$targetType instanceof \ReflectionNamedType) {
428+
return false;
429+
}
430+
431+
$targetTypeName = $targetType->getName();
432+
433+
if (enum_exists($targetTypeName)) {
434+
return true;
435+
}
436+
437+
if ($value instanceof \BackedEnum && \in_array($targetTypeName, ['string', 'int'], true)) {
438+
return true;
439+
}
440+
441+
return false;
442+
}
443+
444+
/**
445+
* @return int|string|\BackedEnum the target-expected value
446+
*/
447+
private function convertEnumValue(mixed $sourceValue, \ReflectionProperty $targetProperty): int|string|\BackedEnum
448+
{
449+
$targetType = $targetProperty->getType();
450+
$targetEnumTypeName = $targetType instanceof \ReflectionNamedType ? $targetType->getName() : (string) $targetType;
451+
452+
if ($sourceValue instanceof \BackedEnum && \in_array($targetEnumTypeName, ['string', 'int'], true)) {
453+
return $this->convertFromBackedEnum($sourceValue, $targetEnumTypeName);
454+
}
455+
456+
if (is_a($targetEnumTypeName, \BackedEnum::class, true) && !\is_object($sourceValue)) {
457+
return $this->convertToBackedEnum($sourceValue, $targetEnumTypeName);
458+
}
459+
460+
if (is_a($targetEnumTypeName, \UnitEnum::class, true) && !is_a($targetEnumTypeName, \BackedEnum::class, true) && !\is_object($sourceValue)) {
461+
throw new MappingTransformException(\sprintf('Cannot map "%s" to "%s" on property "%s". Pure enums cannot be mapped from scalar values.', get_debug_type($sourceValue), $targetEnumTypeName, $targetProperty->getName()));
462+
}
463+
464+
return $sourceValue;
465+
}
466+
467+
/**
468+
* @template T of \BackedEnum
469+
*
470+
* @param class-string<T> $targetEnumClass
471+
*
472+
* @return T
473+
*/
474+
private function convertToBackedEnum(mixed $value, string $targetEnumClass): \BackedEnum
475+
{
476+
if ($value instanceof $targetEnumClass) {
477+
return $value;
478+
}
479+
480+
$targetBackingType = (new \ReflectionEnum($targetEnumClass))->getBackingType();
481+
482+
/** @var \ReflectionNamedType $targetBackingType */
483+
$expectedType = $targetBackingType->getName();
484+
$actualType = get_debug_type($value);
485+
486+
if ($expectedType !== $actualType) {
487+
throw new MappingTransformException(\sprintf('Cannot convert value of type "%s" to "%s"-backed enum "%s".', $actualType, $expectedType, $targetEnumClass));
488+
}
489+
490+
try {
491+
return $targetEnumClass::from($value);
492+
} catch (\ValueError $e) {
493+
throw new MappingTransformException(\sprintf('Invalid value "%s" for enum "%s": "%s"', $value, $targetEnumClass, $e->getMessage()), 0, $e);
494+
}
495+
}
496+
497+
/**
498+
* @param 'int'|'string' $targetType
499+
*/
500+
private function convertFromBackedEnum(\BackedEnum $enum, string $targetType): int|string
501+
{
502+
$backingType = get_debug_type($enum->value);
503+
504+
if ($backingType !== $targetType) {
505+
throw new MappingTransformException(\sprintf('Cannot convert "%s" backed enum "%s" to "%s".', $backingType, $enum::class, $targetType));
506+
}
507+
508+
return $enum->value;
509+
}
400510
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
class DtoWithIntBackedEnum
7+
{
8+
public function __construct(
9+
public Priority $property,
10+
) {
11+
}
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
class DtoWithIntProperty
6+
{
7+
public function __construct(
8+
public int $property,
9+
) {
10+
}
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
class DtoWithPureEnum
7+
{
8+
public function __construct(
9+
public Role $property,
10+
) {
11+
}
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
class DtoWithStringBackedEnum
7+
{
8+
public function __construct(
9+
public Status $property,
10+
) {
11+
}
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
class DtoWithStringProperty
7+
{
8+
public function __construct(
9+
public string $property,
10+
) {
11+
}
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
enum Priority: int
7+
{
8+
case Low = 1;
9+
case Medium = 2;
10+
case High = 3;
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
enum Role
7+
{
8+
case Admin;
9+
case User;
10+
case Guest;
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
4+
namespace Symfony\Component\ObjectMapper\Tests\Fixtures\EnumMapping;
5+
6+
enum Status: string
7+
{
8+
case IsActive = 'active';
9+
case IsInactive = 'inactive';
10+
case IsPending = 'pending';
11+
}

0 commit comments

Comments
 (0)