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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
],
"require": {
"php": "~8.2",
"crell/attributeutils": "~1.3",
"crell/attributeutils": "dev-master#a118b55 as 1.3.1",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Before merging this PR (if that is the direction we want to move towards) I will make sure I update this to a released version.

"crell/fp": "~1.0"
},
"require-dev": {
Expand Down
36 changes: 35 additions & 1 deletion src/PropertyHandler/EnumExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Expand All @@ -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)),
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/TypeMapOnNonObjectField.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/TypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/ArrayBasedFormatterTestCases.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
11 changes: 11 additions & 0 deletions tests/Records/TypeMappedEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

enum TypeMappedEnum: int implements TypeMappedInterface
{
case A = 1;
case B = 2;
}
15 changes: 15 additions & 0 deletions tests/Records/TypeMappedInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\StaticTypeMap;

#[StaticTypeMap(key: 'type', map: [
'enum' => TypeMappedEnum::class,
'object' => TypeMappedObject::class,
])]
interface TypeMappedInterface
{
}
17 changes: 17 additions & 0 deletions tests/Records/TypeMappedMixedElements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\SequenceField;

final readonly class TypeMappedMixedElements
{
/** @param list<TypeMappedInterface> $elements */
public function __construct(
#[SequenceField(arrayType: TypeMappedInterface::class)]
public array $elements = [],
) {
}
}
12 changes: 12 additions & 0 deletions tests/Records/TypeMappedObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

class TypeMappedObject implements TypeMappedInterface
{
public function __construct(
public int $id = 1,
) {}
}
35 changes: 35 additions & 0 deletions tests/SerdeTestCases.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@
use Crell\Serde\Records\TraversableInts;
use Crell\Serde\Records\TraversablePoints;
use Crell\Serde\Records\Traversables;
use Crell\Serde\Records\TypeMappedEnum;
use Crell\Serde\Records\TypeMappedMixedElements;
use Crell\Serde\Records\TypeMappedObject;
use Crell\Serde\Records\UnionTypeSubTypeField;
use Crell\Serde\Records\UnionTypeWithInterface;
use Crell\Serde\Records\UnixTimeExample;
Expand Down Expand Up @@ -522,6 +525,22 @@ public static function round_trip_flattening_examples(): iterable
];
}

/**
* specific permutations for cases of using type maps against an interface.
*/
public static function interface_typemap_and_enum_permutations(): iterable
{
yield 'interface typemap for enum' => [
'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.
*/
Expand Down Expand Up @@ -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
{
Expand Down
Loading