Skip to content

Commit 6cc59c0

Browse files
authored
Merge pull request #4 from KurtThiemann/backed-enums
Support serializing and deserializing backed enums
2 parents 87b7426 + 5fdbdfe commit 6cc59c0

11 files changed

+274
-10
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ A PHP library for (de-)serialization using attributes and reflection.
99
- [Required](#required)
1010
- [Allow Null](#allow-null)
1111
- [Item Type](#item-type)
12+
- [Serializer and Deserializer](#serializer-and-deserializer)
13+
- [Item Serializer and Item Deserializer](#item-serializer-and-item-deserializer)
1214
- [Exceptions](#exceptions)
1315
- [SerializationException](#serializationexception)
1416
- [InvalidInputException](#invalidinputexception)
1517
- [MissingPropertyException](#missingpropertyexception)
1618
- [IncorrectTypeException](#incorrecttypeexception)
1719
- [UnsupportedTypeException](#unsupportedtypeexception)
20+
- [InvalidEnumBackingException](#invalidenumbackingexception)
1821
- [Custom Serializers](#custom-serializers)
1922

2023
### Installation
@@ -70,6 +73,9 @@ $example = ExampleClass::tryFromJson('{ "name": "John", "age": 25, "last_name":
7073
> [!NOTE]
7174
> Deserialization is not supported for intersection types as there is no way to determine the correct type.
7275
76+
> [!NOTE]
77+
> Serialization and deserialization of enums is only supported for backed enums.
78+
7379
If you prefer you can also serialize and deserialize manually.
7480

7581
```php
@@ -206,6 +212,7 @@ Both of these exceptions extend [InvalidInputException](#invalidinputexception).
206212

207213
During deserialization, the following additional exceptions may be thrown:
208214
- [UnsupportedTypeException](#unsupportedtypeexception)
215+
- [InvalidEnumBackingException](#invalidenumbackingexception)
209216
- [JsonException](https://www.php.net/manual/en/class.jsonexception.php)
210217

211218
JsonException is a built-in PHP exception that is thrown when an error occurs during JSON encoding or decoding.
@@ -234,6 +241,11 @@ As noted above, deserializing intersection types is not supported.
234241
If an intersection type is encountered during deserialization, this exception is thrown.
235242
It's also thrown if a php built-in type is encountered that is not yet supported by the library.
236243

244+
#### InvalidEnumBackingException
245+
This exception is thrown if an enum is deserialized with an invalid backing value.
246+
This can happen if the value that is deserialized is not scalar,
247+
or if the target enum does not have a matching case.
248+
237249
### Custom Serializers
238250
If you want to write a serializer for a different format, you can use the ArraySerializer and ArrayDeserializer class.
239251
These convert the object to an associative array and vice versa.

src/ArrayDeserializer.php

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22

33
namespace Aternos\Serializer;
44

5+
use Aternos\Serializer\Exceptions\InvalidEnumBackingException;
56
use Aternos\Serializer\Exceptions\SerializationException;
67
use Aternos\Serializer\Exceptions\IncorrectTypeException;
78
use Aternos\Serializer\Exceptions\MissingPropertyException;
89
use Aternos\Serializer\Exceptions\UnsupportedTypeException;
910
use InvalidArgumentException;
1011
use ReflectionClass;
12+
use ReflectionEnum;
1113
use ReflectionException;
1214
use ReflectionIntersectionType;
1315
use ReflectionNamedType;
1416
use ReflectionProperty;
1517
use ReflectionUnionType;
1618
use TypeError;
19+
use ValueError;
1720

1821
/**
1922
* Deserializes arrays into objects using the Serialize attribute.
@@ -48,22 +51,29 @@ public function __construct(
4851
* @throws IncorrectTypeException if the type of the property is incorrect
4952
* @throws MissingPropertyException if a required property is missing
5053
* @throws UnsupportedTypeException if the type of the property is unsupported
54+
* @throws InvalidEnumBackingException if the target class is an enum, but the serialized data is not a valid backing value
5155
*/
5256
public function deserialize(
5357
mixed $data,
5458
string $path = "",
5559
): object
5660
{
57-
if (!is_array($data)) {
58-
throw new InvalidArgumentException("Data must be an array.");
59-
}
6061
try {
6162
$reflectionClass = new ReflectionClass($this->class);
62-
$result = new $this->class;
6363
} catch (ReflectionException) {
6464
throw new InvalidArgumentException("Class '" . $this->class . "' does not exist.");
6565
}
6666

67+
if ($reflectionClass->isEnum()) {
68+
return $this->parseEnum($data, $path);
69+
}
70+
71+
if (!is_array($data)) {
72+
throw new IncorrectTypeException($path, $this->class, $data);
73+
}
74+
75+
$result = new $this->class;
76+
6777
foreach ($reflectionClass->getProperties() as $property) {
6878
$this->deserializeProperty($data, $path, $property, $result);
6979
}
@@ -81,6 +91,7 @@ public function deserialize(
8191
* @throws IncorrectTypeException if the type of the property is incorrect
8292
* @throws MissingPropertyException if the property is required but missing
8393
* @throws UnsupportedTypeException if the type of the property is unsupported
94+
* @throws InvalidEnumBackingException if the target class is an enum, but the serialized data is not a valid backing value
8495
*/
8596
protected function deserializeProperty(
8697
array $data,
@@ -208,6 +219,7 @@ protected function parseUnionType(ReflectionUnionType $unionType, mixed $value,
208219
* @throws IncorrectTypeException if the type of the property is incorrect
209220
* @throws UnsupportedTypeException if the type of the property is unsupported
210221
* @throws MissingPropertyException if a required property is missing
222+
* @throws InvalidEnumBackingException if the target class is an enum, but the serialized data is not a valid backing value
211223
*/
212224
protected function parseNamedType(
213225
ReflectionNamedType $type,
@@ -226,10 +238,6 @@ protected function parseNamedType(
226238
return $value;
227239
}
228240

229-
if (!is_array($value)) {
230-
throw new IncorrectTypeException($propertyPath, $type->getName(), $value);
231-
}
232-
233241
if ($type->getName() === "self") {
234242
$deserializer = $this;
235243
} else {
@@ -261,4 +269,33 @@ protected function isBuiltInTypeValid(string $type, mixed $value, string $path):
261269
default => throw new UnsupportedTypeException($path, $type),
262270
};
263271
}
272+
273+
/**
274+
* @param mixed $value
275+
* @param string $path
276+
* @return mixed
277+
* @throws InvalidEnumBackingException
278+
* @throws UnsupportedTypeException
279+
* @noinspection PhpDocMissingThrowsInspection
280+
*/
281+
protected function parseEnum(mixed $value, string $path): mixed
282+
{
283+
/** @noinspection PhpUnhandledExceptionInspection - It has already been verified that the enum exists */
284+
$reflectionEnum = new ReflectionEnum($this->class);
285+
286+
if (!$reflectionEnum->isBacked()) {
287+
throw new UnsupportedTypeException($path, $this->class, "Enums must be backed by a scalar type.");
288+
}
289+
290+
$backingType = $reflectionEnum->getBackingType();
291+
if (!$backingType->isBuiltin() || !$this->isBuiltInTypeValid($backingType->getName(), $value, $path)) {
292+
throw new InvalidEnumBackingException($this->class, $backingType->getName(), $value);
293+
}
294+
295+
try {
296+
return $this->class::from($value);
297+
} catch (ValueError) {
298+
throw new InvalidEnumBackingException($this->class, $backingType->getName(), $value);
299+
}
300+
}
264301
}

src/ArraySerializer.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ public function serialize(object $item): array
7171
$value = $customSerializer->serialize($value);
7272
} else if ($value instanceof JsonSerializable) {
7373
$value = $value->jsonSerialize();
74+
} else if ($value instanceof \UnitEnum) {
75+
if (!$value instanceof \BackedEnum) {
76+
throw new IncorrectTypeException($property->getName(), "BackedEnum", $value);
77+
}
78+
$value = $value->value;
7479
} else if (is_object($value)) {
7580
$value = $this->serialize($value);
7681
} else if (is_array($value) && $customSerializer = $attribute->getItemSerializer()) {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Aternos\Serializer\Exceptions;
4+
5+
class InvalidEnumBackingException extends InvalidInputException
6+
{
7+
/**
8+
* @param class-string $enumType
9+
* @param string $backingType
10+
* @param mixed $actualValue
11+
*/
12+
public function __construct(
13+
protected string $enumType,
14+
protected string $backingType,
15+
protected mixed $actualValue
16+
)
17+
{
18+
$options = [];
19+
foreach ($enumType::cases() as $case) {
20+
$options[] = $case->value;
21+
}
22+
23+
parent::__construct("Invalid backing value for enum '" . $enumType . "' expected: type '" . $backingType .
24+
"' (" . implode(", ", $options) . ") found: "
25+
. var_export($actualValue, true));
26+
}
27+
28+
/**
29+
* @return string
30+
*/
31+
public function getEnumType(): string
32+
{
33+
return $this->enumType;
34+
}
35+
36+
/**
37+
* @return string
38+
*/
39+
public function getBackingType(): string
40+
{
41+
return $this->backingType;
42+
}
43+
44+
/**
45+
* @return mixed
46+
*/
47+
public function getActualValue(): mixed
48+
{
49+
return $this->actualValue;
50+
}
51+
}

tests/src/BackedEnumTestClass.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Aternos\Serializer\Test\Src;
4+
5+
use Aternos\Serializer\Serialize;
6+
7+
class BackedEnumTestClass
8+
{
9+
#[Serialize]
10+
protected TestBackedEnum $enum = TestBackedEnum::A;
11+
12+
public function getEnum(): TestBackedEnum
13+
{
14+
return $this->enum;
15+
}
16+
}

tests/src/EnumTestClass.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Aternos\Serializer\Test\Src;
4+
5+
use Aternos\Serializer\Serialize;
6+
7+
class EnumTestClass
8+
{
9+
#[Serialize]
10+
protected TestEnum $enum = TestEnum::A;
11+
12+
public function getEnum(): TestEnum
13+
{
14+
return $this->enum;
15+
}
16+
}

tests/src/TestBackedEnum.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Aternos\Serializer\Test\Src;
4+
5+
enum TestBackedEnum : string
6+
{
7+
case A = "a";
8+
case B = "b";
9+
case C = "c";
10+
}

tests/src/TestEnum.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Aternos\Serializer\Test\Src;
4+
5+
enum TestEnum
6+
{
7+
case A;
8+
case B;
9+
case C;
10+
}

tests/tests/DeserializerTest.php

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44

55
use Aternos\Serializer\ArrayDeserializer;
66
use Aternos\Serializer\Exceptions\IncorrectTypeException;
7+
use Aternos\Serializer\Exceptions\InvalidEnumBackingException;
78
use Aternos\Serializer\Exceptions\MissingPropertyException;
89
use Aternos\Serializer\Exceptions\UnsupportedTypeException;
910
use Aternos\Serializer\Json\JsonDeserializer;
1011
use Aternos\Serializer\Serialize;
1112
use Aternos\Serializer\Test\Src\ArrayDeserializerAccessor;
1213
use Aternos\Serializer\Test\Src\ArrayTests;
14+
use Aternos\Serializer\Test\Src\BackedEnumTestClass;
1315
use Aternos\Serializer\Test\Src\BuiltInTypeTestClass;
1416
use Aternos\Serializer\Test\Src\CustomSerializerInvalidTypeTestClass;
1517
use Aternos\Serializer\Test\Src\CustomSerializerTestClass;
1618
use Aternos\Serializer\Test\Src\DefaultValueTestClass;
19+
use Aternos\Serializer\Test\Src\EnumTestClass;
1720
use Aternos\Serializer\Test\Src\IntersectionTestClass;
1821
use Aternos\Serializer\Test\Src\SecondTestClass;
1922
use Aternos\Serializer\Test\Src\SerializerTestClass;
23+
use Aternos\Serializer\Test\Src\TestBackedEnum;
2024
use Aternos\Serializer\Test\Src\TestClass;
2125
use Aternos\Serializer\Test\Src\UnionIntersectionTestClass;
2226
use InvalidArgumentException;
@@ -30,6 +34,7 @@
3034
#[UsesClass(MissingPropertyException::class)]
3135
#[UsesClass(UnsupportedTypeException::class)]
3236
#[UsesClass(JsonDeserializer::class)]
37+
#[UsesClass(InvalidEnumBackingException::class)]
3338
class DeserializerTest extends TestCase
3439
{
3540
public function testDeserialize(): void
@@ -489,8 +494,7 @@ public function testUnknownBuiltInType(): void
489494
public function testArrayDeserializerArgumentIsNotAnArray(): void
490495
{
491496
$deserializer = new ArrayDeserializerAccessor(TestClass::class);
492-
$this->expectException(InvalidArgumentException::class);
493-
$this->expectExceptionMessage("Data must be an array.");
497+
$this->expectException(IncorrectTypeException::class);
494498
$deserializer->deserialize("not-an-array");
495499
}
496500

@@ -512,4 +516,34 @@ public function testCustomDeserializerReturnsInvalidType(): void
512516
$this->expectExceptionMessage("Expected '.testClass' to be 'Aternos\Serializer\Test\Src\TestClass' found: \Aternos\Serializer\Test\Src\BuiltInTypeTestClass::");
513517
$deserializer->deserialize('{"testClass":"Tzo0ODoiQXRlcm5vc1xTZXJpYWxpemVyXFRlc3RcU3JjXEJ1aWx0SW5UeXBlVGVzdENsYXNzIjo4OntzOjM6ImludCI7TjtzOjU6ImZsb2F0IjtOO3M6Njoic3RyaW5nIjtOO3M6NToiYXJyYXkiO047czo2OiJvYmplY3QiO047czo0OiJzZWxmIjtOO3M6NToiZmFsc2UiO047czo0OiJ0cnVlIjtOO30="}');
514518
}
519+
520+
public function testDeserializeBackedEnum(): void
521+
{
522+
$deserializer = new ArrayDeserializerAccessor(BackedEnumTestClass::class);
523+
$this->assertEquals(TestBackedEnum::A, $deserializer->deserialize(["enum" => "a"])->getEnum());
524+
}
525+
526+
public function testDeserializeUnbackedEnum(): void
527+
{
528+
$deserializer = new ArrayDeserializerAccessor(EnumTestClass::class);
529+
$this->expectException(UnsupportedTypeException::class);
530+
$this->expectExceptionMessage("Unsupported type 'Aternos\Serializer\Test\Src\TestEnum' for property '.enum': Enums must be backed by a scalar type.");
531+
$deserializer->deserialize(["enum" => "a"]);
532+
}
533+
534+
public function testDeserializeEnumWithInvalidBackingValue(): void
535+
{
536+
$deserializer = new ArrayDeserializerAccessor(BackedEnumTestClass::class);
537+
$this->expectException(InvalidEnumBackingException::class);
538+
$this->expectExceptionMessage("Invalid backing value for enum 'Aternos\Serializer\Test\Src\TestBackedEnum' expected: type 'string' (a, b, c) found: 'd'");
539+
$deserializer->deserialize(["enum" => "d"]);
540+
}
541+
542+
public function testDeserializeEnumWithInvalidBackingType(): void
543+
{
544+
$deserializer = new ArrayDeserializerAccessor(TestBackedEnum::class);
545+
$this->expectException(InvalidEnumBackingException::class);
546+
$this->expectExceptionMessage("Invalid backing value for enum 'Aternos\Serializer\Test\Src\TestBackedEnum' expected: type 'string' (a, b, c) found: 0");
547+
$this->assertEquals(TestBackedEnum::A, $deserializer->deserialize(0));
548+
}
515549
}

0 commit comments

Comments
 (0)