diff --git a/README.md b/README.md index 0deb392..af6dd57 100644 --- a/README.md +++ b/README.md @@ -968,6 +968,37 @@ interface Product {} And both `Sale` and `Order` will still serialize with the appropriate key. +#### Type mapped enums + +When an enum is used as part of a type map, its backing value will be serialized with the `value` property. + +Consider the following enum: +```php +enum Genre: string { + case Action = 'action'; + case Adventure = 'adventure'; +} +``` + +And consider the following TypeMap +```php +use Crell\Serde\Attributes\StaticTypeMap; + +#[StaticTypeMap(key: 'type', map: [ + 'genre' => Genre::class, +])] +interface Book {} +``` + +Serializing the `Genre::Action` enum as part of the TypeMap will result in the following serialized representation: + +```json +{ + "type": "genre", + "value": "action" +} +``` + #### Dynamic type maps Type Maps may also be provided directly to the Serde object when it is created. Any object that implements `TypeMap` may be used. This is most useful when the list of possible classes is dynamic based on user configuration, database values, what plugins are installed in your application, etc. diff --git a/composer.json b/composer.json index 8c8307f..2232515 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "~8.2", - "crell/attributeutils": "~1.3", + "crell/attributeutils": "dev-master#a118b55 as 1.3.1", "crell/fp": "~1.0" }, "require-dev": { diff --git a/src/PropertyHandler/EnumExporter.php b/src/PropertyHandler/EnumExporter.php index fc9418d..bdd822a 100644 --- a/src/PropertyHandler/EnumExporter.php +++ b/src/PropertyHandler/EnumExporter.php @@ -5,17 +5,48 @@ namespace Crell\Serde\PropertyHandler; use Crell\Serde\Attributes\Field; +use Crell\Serde\CollectionItem; use Crell\Serde\DeformatterResult; use Crell\Serde\Deserializer; +use Crell\Serde\Dict; use Crell\Serde\Serializer; use Crell\Serde\TypeCategory; use Crell\Serde\TypeMismatch; use ReflectionEnum; +use function Crell\fp\pipe; +use function Crell\fp\reduce; class EnumExporter implements Exporter, Importer { public function exportValue(Serializer $serializer, Field $field, mixed $value, mixed $runningValue): mixed { + if ($field->typeCategory !== TypeCategory::UnitEnum && $map = $serializer->typeMapper->typeMapForField($field)) { + // This lets us read private values without messing with the Reflection API. + // The object_vars business is to let us differentiate between a value set to null + // and an uninitialized value, which in this rare case are meaningfully different. + // @todo This may benefit from caching get_object_vars(), but that will be tricky. + $propReader = (fn (string $prop): mixed + => array_key_exists($prop, get_object_vars($this)) ? $this->$prop : DeformatterResult::Missing)->bindTo($value, $value); + + /** @var Dict $dict */ + $dict = pipe( + $serializer->propertiesFor($value::class), + reduce(new Dict(), fn(Dict $dict, Field $f) => $dict), + ); + + $f = Field::create(serializedName: $map->keyField(), phpType: 'string'); + // The type map field MUST come first so that streaming deformatters + // can know their context. + $dict->items = [ + new CollectionItem(field: $f, value: $map->findIdentifier($value::class)), + ]; + + $result = $serializer->formatter->serializeObject($runningValue, $field, $dict, $serializer); + $result[0]['value'] = $value->value; + + return $result; + } + $scalar = $value->value ?? $value->name; // PHPStan can't handle match() without a default. @@ -33,6 +64,10 @@ public function canExport(Field $field, mixed $value, string $format): bool public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed { + if ($field->typeCategory !== TypeCategory::UnitEnum && $deserializer->typeMapper->typeMapForField($field) !== null) { + $source = [$source[0]['value']]; + } + // It's kind of amusing that both of these work, but they work. $val = match ($field->typeCategory) { TypeCategory::UnitEnum, TypeCategory::StringEnum => $deserializer->deformatter->deserializeString($source, $field), @@ -54,7 +89,6 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou // @phpstan-ignore-next-line TypeCategory::UnitEnum => (new ReflectionEnum($field->phpType))->getCase($val)->getValue(), TypeCategory::IntEnum, TypeCategory::StringEnum => $field->phpType::from($val), - default => throw TypeMismatch::create($field->phpName, $field->phpType, get_debug_type($source)), }; } diff --git a/src/TypeMapOnNonObjectField.php b/src/TypeMapOnNonObjectField.php index f7f12f6..593b7d9 100644 --- a/src/TypeMapOnNonObjectField.php +++ b/src/TypeMapOnNonObjectField.php @@ -17,7 +17,7 @@ public static function create(Field $field): self $new->field = $field; - $new->message = sprintf('Type maps may only be applied to object or array fields. Tried to apply type map to field %s of type %s. Honestly I do not know how you even got here.', $field->phpName ?? $field->serializedName, $field->phpType); + $new->message = sprintf('Type maps may only be applied to object, backed enums or array fields. Tried to apply type map to field %s of type %s. Honestly I do not know how you even got here.', $field->phpName ?? $field->serializedName, $field->phpType); return $new; } diff --git a/src/TypeMapper.php b/src/TypeMapper.php index 6559322..7c73e9e 100644 --- a/src/TypeMapper.php +++ b/src/TypeMapper.php @@ -20,7 +20,7 @@ public function __construct( public function typeMapForField(Field $field): ?TypeMap { - if (!in_array($field->typeCategory, [TypeCategory::Object, TypeCategory::Array], true)) { + if (!in_array($field->typeCategory, [TypeCategory::Object, TypeCategory::Array, TypeCategory::IntEnum, TypeCategory::StringEnum], true)) { throw TypeMapOnNonObjectField::create($field); } diff --git a/tests/ArrayBasedFormatterTestCases.php b/tests/ArrayBasedFormatterTestCases.php index c3f5155..01bbb63 100644 --- a/tests/ArrayBasedFormatterTestCases.php +++ b/tests/ArrayBasedFormatterTestCases.php @@ -77,7 +77,7 @@ protected function flattening_validate(mixed $serialized): void self::assertEquals('C', $toTest['c']); } - protected function static_type_map_validate(mixed $serialized): void + protected function static_typemap_validate(mixed $serialized): void { $toTest = $this->arrayify($serialized); diff --git a/tests/Records/TypeMappedEnum.php b/tests/Records/TypeMappedEnum.php new file mode 100644 index 0000000..860c9c0 --- /dev/null +++ b/tests/Records/TypeMappedEnum.php @@ -0,0 +1,11 @@ + TypeMappedEnum::class, + 'object' => TypeMappedObject::class, +])] +interface TypeMappedInterface +{ +} diff --git a/tests/Records/TypeMappedMixedElements.php b/tests/Records/TypeMappedMixedElements.php new file mode 100644 index 0000000..260011e --- /dev/null +++ b/tests/Records/TypeMappedMixedElements.php @@ -0,0 +1,17 @@ + $elements */ + public function __construct( + #[SequenceField(arrayType: TypeMappedInterface::class)] + public array $elements = [], + ) { + } +} diff --git a/tests/Records/TypeMappedObject.php b/tests/Records/TypeMappedObject.php new file mode 100644 index 0000000..d641c70 --- /dev/null +++ b/tests/Records/TypeMappedObject.php @@ -0,0 +1,12 @@ + [ + 'data' => new TypeMappedMixedElements([TypeMappedEnum::A]), + ]; + yield 'interface typemap for object' => [ + 'data' => new TypeMappedMixedElements([new TypeMappedObject(2)]), + ]; + yield 'interface typemap for enum and object' => [ + 'data' => new TypeMappedMixedElements([TypeMappedEnum::A, new TypeMappedObject(2)]), + ]; + } + /** * This tests an empty object value, which means something different in different formats. */ @@ -590,6 +609,22 @@ public function static_typemap(): void self::assertEquals($data, $result); } + #[Test, Group('typemap'), DataProvider('interface_typemap_and_enum_permutations')] + public function interface_typemap(TypeMappedMixedElements $data): void + { + $s = new SerdeCommon( + formatters: $this->formatters, + ); + + $serialized = $s->serialize($data, $this->format); + + $this->validateSerialized($serialized, __FUNCTION__); + + $result = $s->deserialize($serialized, from: $this->format, to: TypeMappedMixedElements::class); + + self::assertEquals($data, $result); + } + #[Test, Group('typemap')] public function dynamic_type_map(): void {