From 5008589a34ce1d950d36e1f3ab8d9c08721c3639 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Wed, 28 May 2025 14:57:22 +0300 Subject: [PATCH] Revert "Revert "Merge pull request #8 from good-php/polymorphic-adapter" (#9)" This reverts commit aae982b9f0fb0ec599f7a5acdf3ad53b336f615d. --- README.md | 375 +--------------- composer.json | 6 +- docs/adding-more-formats.md | 27 ++ docs/error-handling.md | 16 + docs/flattening.md | 33 ++ docs/handling-unexpected-values.md | 57 +++ docs/key-naming.md | 49 ++ docs/polymorphic-types.md | 66 +++ docs/type-adapters.md | 107 +++++ docs/type-mappers.md | 67 +++ src/SerializerBuilder.php | 10 +- .../UnexpectedEnumValueException.php | 2 +- .../UnexpectedPolymorphicTypeException.php | 27 ++ .../FlatteningBoundClassProperty.php | 2 +- .../ClassPolymorphicTypeAdapterFactory.php | 65 +++ ...ssPolymorphicTypeAdapterFactoryBuilder.php | 51 +++ .../Polymorphic/PolymorphicTypeAdapter.php | 75 ++++ tests/Integration/JsonSerializationTest.php | 420 +++++++++++++++--- tests/Stubs/ClassStub.php | 3 + tests/Stubs/Polymorphic/Change.php | 7 + tests/Stubs/Polymorphic/FromToChange.php | 11 + tests/Stubs/Polymorphic/RemovedChange.php | 10 + 22 files changed, 1049 insertions(+), 437 deletions(-) create mode 100644 docs/adding-more-formats.md create mode 100644 docs/error-handling.md create mode 100644 docs/flattening.md create mode 100644 docs/handling-unexpected-values.md create mode 100644 docs/key-naming.md create mode 100644 docs/polymorphic-types.md create mode 100644 docs/type-adapters.md create mode 100644 docs/type-mappers.md create mode 100644 src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php create mode 100644 src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php create mode 100644 src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactoryBuilder.php create mode 100644 src/TypeAdapter/Primitive/Polymorphic/PolymorphicTypeAdapter.php create mode 100644 tests/Stubs/Polymorphic/Change.php create mode 100644 tests/Stubs/Polymorphic/FromToChange.php create mode 100644 tests/Stubs/Polymorphic/RemovedChange.php diff --git a/README.md b/README.md index 74fa8dd..488aebe 100644 --- a/README.md +++ b/README.md @@ -50,386 +50,31 @@ $primitiveAdapter = $serializer->adapter( PrimitiveTypeAdapter::class, NamedType::wrap(Item::class, [Carbon::class]) ); -$primitiveAdapter->serialize(new Item(...)) // -> ['int' => 123, ...] +// -> ['int' => 123, ...] +$primitiveAdapter->serialize(new Item(...)); $jsonAdapter = $serializer->adapter( JsonTypeAdapter::class, NamedType::wrap(Item::class, [PrimitiveType::int()]) ); -$jsonAdapter->deserialize('{"int": 123, ...}') // -> new Item(123, ...) +// new Item(123, ...) +$jsonAdapter->deserialize('{"int": 123, ...}'); ``` -### Custom mappers +## Documentation -Mappers are the simplest form customizing serialization of types. All you have -to do is to mark a method with either `#[MapTo()]` or `#[MapFrom]` attribute, -specify the type in question as first parameter or return type and the serializer -will handle the rest automatically. A single mapper may have as many map methods as you wish. - -```php -final class DateTimeMapper -{ - #[MapTo(PrimitiveTypeAdapter::class)] - public function serialize(DateTime $value): string - { - return $value->format(DateTimeInterface::RFC3339_EXTENDED); - } - - #[MapFrom(PrimitiveTypeAdapter::class)] - public function deserialize(string $value): DateTime - { - return new DateTime($value); - } -} - -$serializer = (new SerializerBuilder()) - ->addMapperLast(new DateTimeMapper()) - ->build(); -``` - -With mappers, you can even handle complex types - such as generics or inheritance: - -```php -final class ArrayMapper -{ - #[MapTo(PrimitiveTypeAdapter::class)] - public function to(array $value, Type $type, Serializer $serializer): array - { - $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]); - - return array_map(fn ($item) => $itemAdapter->serialize($item), $value); - } - - #[MapFrom(PrimitiveTypeAdapter::class)] - public function from(array $value, Type $type, Serializer $serializer): array - { - $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]); - - return array_map(fn ($item) => $itemAdapter->deserialize($item), $value); - } -} - -final class BackedEnumMapper -{ - #[MapTo(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))] - public function to(BackedEnum $value): string|int - { - return $value->value; - } - - #[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))] - public function from(string|int $value, Type $type): BackedEnum - { - $enumClass = $type->name; - - return $enumClass::tryFrom($value); - } -} -``` - -## Type adapter factories - -Besides type mappers which satisfy most of the needs, you can use type adapter factories -to precisely control how each type is serialized. - -The idea is the following: when building a serializer, you add all of the factories you want -to use in order of priority: - -```php -(new SerializerBuilder()) - ->addMapperLast(new TestMapper()) // #2 - then this one - ->addFactoryLast(new TestFactory()) // #3 - and this one last - ->addFactory(new TestFactory()) // #1 - attempted first -``` - -A factory has the following signature: - -```php -public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter -``` - -If you return `null`, the next factory is called. Otherwise, the returned type adapter is used. - -The serialized is entirely built using type adapter factories. Every type that is -supported out-of-the-box also has it's factory and can be overwritten just by doing -`->addFactoryLast()`. Type mappers are also just fancy adapter factories under the hood. - -This is how you can use them: - -```php -class NullableTypeAdapterFactory implements TypeAdapterFactory -{ - public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter - { - if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NullableType) { - return null; - } - - return new NullableTypeAdapter( - $serializer->adapter($typeAdapterType, $type->innerType, $attributes), - ); - } -} - -class NullableTypeAdapter implements PrimitiveTypeAdapter -{ - public function __construct( - private readonly PrimitiveTypeAdapter $delegate, - ) { - } - - public function serialize(mixed $value): mixed - { - if ($value === null) { - return null; - } - - return $this->delegate->serialize($value); - } - - public function deserialize(mixed $value): mixed - { - if ($value === null) { - return null; - } - - return $this->delegate->deserialize($value); - } -} -``` - -In this example, `NullableTypeAdapterFactory` handles all nullable types. When a non-nullable -type is given, it returns `null`. That means that the next in "queue" type adapter will be -called. When a nullable is given, it returns a new type adapter instance which has two -methods: `serialize` and `deserialize`. They do exactly what they're called. - -## Naming of keys - -By default, serializer preserves the naming of keys, but this is easily customizable (in order of priority): - -- specify a custom property name using the `#[SerializedName]` attribute -- specify a custom naming strategy per class using the `#[SerializedName]` attribute -- specify a custom global (default) naming strategy (use one of the built-in or write your own) - -Here's an example: - -```php -(new SerializerBuilder())->namingStrategy(BuiltInNamingStrategy::SNAKE_CASE); - -// Uses snake_case by default -class Item1 { - public function __construct( - public int $keyName, // appears as "key_name" in serialized data - #[SerializedName('second_key')] public int $firstKey, // second_key - #[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] public int $thirdKey, // THIRD_KEY - ) {} -} - -// Uses PASCAL_CASE by default -#[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] -class Item2 { - public function __construct( - public int $keyName, // KEY_NAME - ) {} -} -``` - -Out of the box, strategies for `snake_case`, `camelCase` and `PascalCase` are provided, -but it's trivial to implement your own: - -```php -class PrefixedNaming implements NamingStrategy { - public function __construct( - private readonly string $prefix, - ) {} - - public function translate(PropertyReflection $property): string - { - return $this->prefix . $property->name(); - } -} - -#[SerializedName(new PrefixedNaming('$'))] -class SiftTrackData {} -``` - -## Required, nullable, optional and default values - -By default, if a property is missing in serialized payload: - -- nullable properties are just set to null -- properties with a default value - use the default value -- optional properties are set to `MissingValue::INSTANCE` -- any other throw an exception - -Here's an example: - -```php -class Item { - public function __construct( - public ?int $first, // set to null - public bool $second = true, // set to true - public Item $third = new Item(...), // set to Item instance - public int|MissingValue $fourth, // set to MissingValue::INSTANCE - public int $fifth, // required, throws if missing - ) {} -} - -// all keys missing -> throws for 'fifth' property -$adapter->deserialize([]) - -// only required property -> uses null, default values and optional -$adapter->deserialize(['fifth' => 123]); - -// all properties -> fills all values -$adapter->deserialize(['first' => 123, 'second' => false, ...]); -``` - -## Flattening - -Sometimes the same set of keys/types is shared between multiple other models. You could -use inheritance for this, but we believe in composition over inheritance and hence provide -a simple way to achieve the same behaviour without using inheritance: - -Here's an example: - -```php -class Pagination { - public function __construct( - public readonly int $perPage, - public readonly int $total, - ) {} -} - -class UsersPaginatedList { - public function __construct( - #[Flatten] - public readonly Pagination $pagination, - /** @var User[] */ - public readonly array $users, - ) {} -} - -// {"perPage": 25, "total": 100, "users": []} -$adapter->serialize( - new UsersPaginatedList( - pagination: new Pagination(25, 100), - users: [], - ) -); -``` - -## Use default value for unexpected - -There are situations where you're deserializing data from a third party that doesn't have an API documentation -or one that can't keep a backwards compatibility promise. One such case is when a third party uses an enum -and you expect that new enum values might get added in the future by them. For example, imagine this structure: - -```php -enum CardType: string -{ - case CLUBS = 'clubs'; - case DIAMONDS = 'diamonds'; - case HEARTS = 'hearts'; - case SPADES = 'spades'; -} - -readonly class Card { - public function __construct( - public CardType $type, - public string $value, - ) {} -} -``` - -If you get an unexpected value for `type`, you'll get an exception: - -```php -// UnexpectedEnumValueException: Expected one of [clubs, diamonds, hearts, spades], but got 'joker' -$adapter->deserialize('{"type": "joker"}'); -``` - -So if you suspect that might happen, add a default value you wish to use (anything) and -a `#[UseDefaultForUnexpected]` attribute: - -```php -readonly class Card { - public function __construct( - #[UseDefaultForUnexpected] - public CardType $type = null, - // Can be any other valid default value - #[UseDefaultForUnexpected] - public CardType $type2 = CardType::SPADES, - ) {} -} -``` - -Whenever that happens, a default value will be used instead. Optionally, you can also log such cases: - -```php -$serializer = (new SerializerBuilder()) - ->reportUnexpectedDefault(function (BoundClassProperty $property, UnexpectedValueException $e) { - $log->warning("Serializer used a default for unexpected value: {$e->getMessage()}", [ - 'property' => $property->serializedName(), - 'exception' => $e, - ]); - }) - ->build(); -``` - -## Error handling - -This is expected to be used with client-provided data, so good error descriptions is a must. -These are some of the errors you'll get: - -- Expected value of type 'int', but got 'string' -- Expected value of type 'string', but got 'NULL' -- Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database -- Expected value of type 'string|int', but got 'boolean' -- Expected one of [one, two], but got 'five' -- Could not map item at key '1': Expected value of type 'string', but got 'NULL' -- Could not map item at key '0': Expected value of type 'string', but got 'NULL' (and 1 more errors)." -- Could not map property at path 'nested.field': Expected value of type 'string', but got 'integer' - -All of these are just a chain of PHP exceptions with `previous` exceptions. Besides -those messages, you have all of the thrown exceptions with necessary information. - -## More formats - -You can add support for more formats as you wish with your own type adapters. -All of the existing adapters are at your disposal: - -```php -interface XmlTypeAdapter extends TypeAdapter {} - -final class FromPrimitiveXmlTypeAdapter implements XmlTypeAdapter -{ - public function __construct( - private readonly PrimitiveTypeAdapter $primitiveAdapter, - ) { - } - - public function serialize(mixed $value): mixed - { - return xml_encode($this->primitiveAdapter->serialize($value)); - } - - public function deserialize(mixed $value): mixed - { - return $this->primitiveAdapter->deserialize(xml_decode($value)); - } -} -``` +Basic documentation is available in [docs/](docs). For examples, you can look at the +test suite: [tests/Integration](tests/Integration). ## Why this over everything else? -There are some alternatives to this, but all of them will lack at least one of these: +There are some alternatives to this, but they usually lack one of the following: -- doesn't rely on inheritance, hence allows serializing third-party classes +- stupid simple internal structure: no node tree, no value/JSON wrappers, no in-repo custom reflection implementation, no PHP parsing +- doesn't rely on inheritance of serializable classes, hence allows serializing third-party classes - parses existing PHPDoc information instead of duplicating it through attributes - supports generic types which are quite useful for wrapper types - allows simple extension through mappers and complex stuff through type adapters - produces developer-friendly error messages for invalid data - correctly handles optional (missing keys) and `null` values as separate concerns - simple to extend with additional formats -- simple internal structure: no node tree, no value/JSON wrappers, no custom reflection / PHP parsing, no inherent limitations diff --git a/composer.json b/composer.json index b50c400..89ddbdd 100644 --- a/composer.json +++ b/composer.json @@ -10,9 +10,8 @@ ], "require": { "php": ">=8.2", - "php-ds/php-ds": "^1.3.0", "good-php/reflection": "^1.0", - "tenantcloud/php-standard": "^2.0" + "illuminate/support": "^10.0 || ^11.0" }, "require-dev": { "pestphp/pest": "^2.8", @@ -22,7 +21,8 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-webmozart-assert": "^1.2", "phpstan/phpstan-mockery": "^1.1", - "phake/phake": "^4.2" + "phake/phake": "^4.2", + "tenantcloud/php-standard": "^2.2" }, "autoload": { "psr-4": { diff --git a/docs/adding-more-formats.md b/docs/adding-more-formats.md new file mode 100644 index 0000000..bfd2efa --- /dev/null +++ b/docs/adding-more-formats.md @@ -0,0 +1,27 @@ +# Adding more formats + +Different formats are supported through "type adapter types". These are just interfaces, but their class +name is passed to all type adapter factories and type mappers. For example, this is how you could implement +basic XML support that uses all of the existing adapters that target "primitive" format: + +```php +interface XmlTypeAdapter extends TypeAdapter {} + +final class FromPrimitiveXmlTypeAdapter implements XmlTypeAdapter +{ + public function __construct( + private readonly PrimitiveTypeAdapter $primitiveAdapter, + ) { + } + + public function serialize(mixed $value): mixed + { + return xml_encode($this->primitiveAdapter->serialize($value)); + } + + public function deserialize(mixed $value): mixed + { + return $this->primitiveAdapter->deserialize(xml_decode($value)); + } +} +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..d3afd5f --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,16 @@ +## Error handling + +This is expected to be used with client-provided data, so good error descriptions is a must. +These are some of the errors you'll get: + +- Expected value of type 'int', but got 'string' +- Expected value of type 'string', but got 'NULL' +- Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database +- Expected value of type 'string|int', but got 'boolean' +- Expected one of [one, two], but got 'five' +- Could not map item at key '1': Expected value of type 'string', but got 'NULL' +- Could not map item at key '0': Expected value of type 'string', but got 'NULL' (and 1 more errors)." +- Could not map property at path 'nested.field': Expected value of type 'string', but got 'integer' + +All of these are just a chain of PHP exceptions with `previous` exceptions. Besides +those messages, you have all the thrown exceptions with necessary information. diff --git a/docs/flattening.md b/docs/flattening.md new file mode 100644 index 0000000..a052c03 --- /dev/null +++ b/docs/flattening.md @@ -0,0 +1,33 @@ +# Flattening + +Sometimes the same set of keys/types is shared between multiple other models. You could +use inheritance for this, but we believe in composition over inheritance and hence provide +a simple way to achieve the same behaviour without using inheritance: + +To "flatten" a nested variable, use `#[Flatten]` attribute: + +```php +class Pagination { + public function __construct( + public readonly int $perPage, + public readonly int $total, + ) {} +} + +class UsersPaginatedList { + public function __construct( + #[Flatten] + public readonly Pagination $pagination, + /** @var User[] */ + public readonly array $users, + ) {} +} + +// {"perPage": 25, "total": 100, "users": []} +$adapter->serialize( + new UsersPaginatedList( + pagination: new Pagination(25, 100), + users: [], + ) +); +``` diff --git a/docs/handling-unexpected-values.md b/docs/handling-unexpected-values.md new file mode 100644 index 0000000..7fa8c3f --- /dev/null +++ b/docs/handling-unexpected-values.md @@ -0,0 +1,57 @@ +# Use default for unexpected values + +There are situations where you're deserializing data from a third party that doesn't have an API documentation +or one that can't keep a backwards compatibility promise. One such case is when a third party uses an enum +and you expect that new enum values might get added in the future by them. For example, imagine this structure: + +```php +enum CardType: string +{ + case CLUBS = 'clubs'; + case DIAMONDS = 'diamonds'; + case HEARTS = 'hearts'; + case SPADES = 'spades'; +} + +readonly class Card { + public function __construct( + public CardType $type, + public string $value, + ) {} +} +``` + +If you get an unexpected value for `type`, you'll get an exception: + +```php +// UnexpectedEnumValueException: Expected one of [clubs, diamonds, hearts, spades], but got 'joker' +$adapter->deserialize('{"type": "joker"}'); +``` + +So if you suspect that might happen, add a default value you wish to use (anything) and +a `#[UseDefaultForUnexpected]` attribute: + +```php +readonly class Card { + public function __construct( + #[UseDefaultForUnexpected] + public CardType $type = null, + // Can be any other valid default value + #[UseDefaultForUnexpected] + public CardType $type2 = CardType::SPADES, + ) {} +} +``` + +Whenever that happens, a default value will be used instead. Optionally, you can also log such cases: + +```php +$serializer = (new SerializerBuilder()) + ->reportUnexpectedDefault(function (BoundClassProperty $property, UnexpectedValueException $e) { + $log->warning("Serializer used a default for unexpected value: {$e->getMessage()}", [ + 'property' => $property->serializedName(), + 'exception' => $e, + ]); + }) + ->build(); +``` diff --git a/docs/key-naming.md b/docs/key-naming.md new file mode 100644 index 0000000..c10a8c6 --- /dev/null +++ b/docs/key-naming.md @@ -0,0 +1,49 @@ +# Naming of keys + +By default, serializer preserves the naming of keys, but this is easily customizable (in order of priority): + +- specify a custom property name using the `#[SerializedName]` attribute +- specify a custom naming strategy per class using the `#[SerializedName]` attribute +- specify a custom global (default) naming strategy (use one of the built-in or write your own) + +Here's an example: + +```php +(new SerializerBuilder())->namingStrategy(BuiltInNamingStrategy::SNAKE_CASE); + +// Uses snake_case by default +class Item1 { + public function __construct( + public int $keyName, // appears as "key_name" in serialized data + #[SerializedName('second_key')] public int $firstKey, // second_key + #[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] public int $thirdKey, // THIRD_KEY + ) {} +} + +// Uses PASCAL_CASE by default +#[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] +class Item2 { + public function __construct( + public int $keyName, // KEY_NAME + ) {} +} +``` + +Out of the box, strategies for `snake_case`, `camelCase` and `PascalCase` are +available in `BuiltInNamingStrategy`, but it's trivial to implement your own: + +```php +class PrefixedNaming implements NamingStrategy { + public function __construct( + private readonly string $prefix, + ) {} + + public function translate(PropertyReflection $property): string + { + return $this->prefix . $property->name(); + } +} + +#[SerializedName(new PrefixedNaming('$'))] +class SiftTrackData {} +``` diff --git a/docs/polymorphic-types.md b/docs/polymorphic-types.md new file mode 100644 index 0000000..91c1b65 --- /dev/null +++ b/docs/polymorphic-types.md @@ -0,0 +1,66 @@ +# Polymorphic types + +When you have a class or an interface that has subclasses, and you want to reflect +that in the serialized format, you can use the built-in polymorphic type adapter. +For example, let's say you have this class structure: + +```php +interface Change {} + +final class FromToChange implements Change +{ + public function __construct( + public string $from, + public string $to, + ) {} +} + +final class RemovedChange implements Change +{ + public function __construct( + public string $field, + ) {} +} +``` + +To handle that, you'll need to do is register the parent class, along with +all of its subclasses and their serialized type names. Then, you'll be able +to serialize and deserialize the data, as long as it contains the type name: + +```php +$serializer = (new SerializerBuilder()) + ->addFactoryLast( + ClassPolymorphicTypeAdapterFactory::for(Change::class) + ->subClass(FromToChange::class, 'from_to') + ->subClass(RemovedChange::class, 'removed') + ->build() + ) + ->build(); + +$adapter = $serializer->adapter(JsonTypeAdapter::class, Change::class); + +// {"__typename": "from_to", "from": "fr", "to": "t"} +$adapter->serialize(new FromToChange(from: 'fr', to: 't')); + +// new FromToChange(from: 'fr', to: 't') +$adapter->deserialize('{"__typename": "from_to", "from": "fr", "to": "t"}'); +``` + +The serializer will use a special field (customizable in #2 argument of `ClassPolymorphicTypeAdapterFactory::for`) +to differentiate between types. Moreover, if you want to handle unexpected polymorphic types, +similarly to enums, you can use `#[UseDefaultForUnexpected]`: + +```php +class OtherClass { + public function __construct( + #[UseDefaultForUnexpected] + public readonly ?Change $change = null, + ) {} +} + +$adapter = $serializer->adapter(JsonTypeAdapter::class, OtherClass::class); + +// new OtherClass(change: null) +$adapter->deserialize('{"__typename": "some_other_unknown_type"}'); +``` + diff --git a/docs/type-adapters.md b/docs/type-adapters.md new file mode 100644 index 0000000..6c5c6af --- /dev/null +++ b/docs/type-adapters.md @@ -0,0 +1,107 @@ +# Type adapters & factories + +Besides type mappers which satisfy most of the needs, you can use type adapter factories +to precisely control how each type is serialized. + +The idea is the following: when building a serializer, you add all of the factories you want +to use in order of priority: + +```php +(new SerializerBuilder()) + ->addMapperLast(new TestMapper()) // #2 - then this one + ->addFactoryLast(new TestFactory()) // #3 - and this one last + ->addFactory(new TestFactory()) // #1 - attempted first +``` + +A factory has the following signature: + +```php +public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter +``` + +If you return `null`, the next factory is called. Otherwise, the returned type adapter is used. + +The serialized is entirely built using type adapter factories. Every type that is +supported out-of-the-box also has it's factory and can be overwritten just by doing +`->addFactoryLast()`. Type mappers are also just fancy adapter factories under the hood. + +This is how you can use them: + +```php +class NullableTypeAdapterFactory implements TypeAdapterFactory +{ + public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter + { + if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NullableType) { + return null; + } + + return new NullableTypeAdapter( + $serializer->adapter($typeAdapterType, $type->innerType, $attributes), + ); + } +} + +class NullableTypeAdapter implements PrimitiveTypeAdapter +{ + public function __construct( + private readonly PrimitiveTypeAdapter $delegate, + ) { + } + + public function serialize(mixed $value): mixed + { + if ($value === null) { + return null; + } + + return $this->delegate->serialize($value); + } + + public function deserialize(mixed $value): mixed + { + if ($value === null) { + return null; + } + + return $this->delegate->deserialize($value); + } +} +``` + +In this example, `NullableTypeAdapterFactory` handles all nullable types. When a non-nullable +type is given, it returns `null`. That means that the next in "queue" type adapter will be +called. When a nullable is given, it returns a new type adapter instance which has two +methods: `serialize` and `deserialize`. They do exactly what they're called. + +## Required, nullable, optional and default values + +By default, if a property is missing in serialized payload: + +- nullable properties are just set to null +- properties with a default value - use the default value +- optional properties are set to `MissingValue::INSTANCE` +- any other throw an exception + +Here's an example: + +```php +class Item { + public function __construct( + public ?int $first, // set to null + public bool $second = true, // set to true + public Item $third = new Item(...), // set to Item instance + public int|MissingValue $fourth, // set to MissingValue::INSTANCE + public int $fifth, // required, throws if missing + ) {} +} + +// all keys missing -> throws for 'fifth' property +$adapter->deserialize([]) + +// only required property -> uses null, default values and optional +$adapter->deserialize(['fifth' => 123]); + +// all properties -> fills all values +$adapter->deserialize(['first' => 123, 'second' => false, ...]); +``` diff --git a/docs/type-mappers.md b/docs/type-mappers.md new file mode 100644 index 0000000..b123218 --- /dev/null +++ b/docs/type-mappers.md @@ -0,0 +1,67 @@ +# Type mappers + +Mappers are the simplest form customizing serialization of types. All you have +to do is to mark a method with either `#[MapTo]` or `#[MapFrom]` attribute, +specify the type in question as first parameter or return type and the serializer +will handle the rest automatically. A single mapper may have as many map methods as you wish. + +```php +final class DateTimeMapper +{ + #[MapTo(PrimitiveTypeAdapter::class)] + public function serialize(DateTime $value): string + { + return $value->format(DateTimeInterface::RFC3339_EXTENDED); + } + + #[MapFrom(PrimitiveTypeAdapter::class)] + public function deserialize(string $value): DateTime + { + return new DateTime($value); + } +} + +$serializer = (new SerializerBuilder()) + ->addMapperLast(new DateTimeMapper()) + ->build(); +``` + +With mappers, you can even handle complex types - such as generics or inheritance: + +```php +final class ArrayMapper +{ + #[MapTo(PrimitiveTypeAdapter::class)] + public function to(array $value, Type $type, Serializer $serializer): array + { + $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]); + + return array_map(fn ($item) => $itemAdapter->serialize($item), $value); + } + + #[MapFrom(PrimitiveTypeAdapter::class)] + public function from(array $value, Type $type, Serializer $serializer): array + { + $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]); + + return array_map(fn ($item) => $itemAdapter->deserialize($item), $value); + } +} + +final class BackedEnumMapper +{ + #[MapTo(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))] + public function to(BackedEnum $value): string|int + { + return $value->value; + } + + #[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))] + public function from(string|int $value, Type $type): BackedEnum + { + $enumClass = $type->name; + + return $enumClass::tryFrom($value); + } +} +``` diff --git a/src/SerializerBuilder.php b/src/SerializerBuilder.php index 0f82034..9a40ed9 100644 --- a/src/SerializerBuilder.php +++ b/src/SerializerBuilder.php @@ -30,6 +30,7 @@ use GoodPhp\Serialization\TypeAdapter\Primitive\PhpStandard\ValueEnumMapper; use GoodPhp\Serialization\TypeAdapter\TypeAdapter; use GoodPhp\Serialization\TypeAdapter\TypeAdapterFactory; +use TenantCloud\Standard\Enum\ValueEnum; use Webmozart\Assert\Assert; final class SerializerBuilder @@ -149,8 +150,13 @@ public function build(): Serializer $typeAdapterRegistryBuilder = $this->typeAdapterRegistryBuilder() ->addFactoryLast(new NullableTypeAdapterFactory()) ->addMapperLast(new ScalarMapper()) - ->addMapperLast(new BackedEnumMapper()) - ->addMapperLast(new ValueEnumMapper()) + ->addMapperLast(new BackedEnumMapper()); + + if (class_exists(ValueEnum::class)) { + $typeAdapterRegistryBuilder = $typeAdapterRegistryBuilder->addMapperLast(new ValueEnumMapper()); + } + + $typeAdapterRegistryBuilder = $typeAdapterRegistryBuilder ->addMapperLast(new ArrayMapper()) ->addMapperLast(new CollectionMapper()) ->addMapperLast(new DateTimeMapper()) diff --git a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php index a68c223..9795935 100644 --- a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php +++ b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php @@ -8,7 +8,7 @@ class UnexpectedEnumValueException extends RuntimeException implements UnexpectedValueException { /** - * @param array $expectedValues + * @param list $expectedValues */ public function __construct( public readonly string|int $value, diff --git a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php new file mode 100644 index 0000000..80256ea --- /dev/null +++ b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php @@ -0,0 +1,27 @@ + $expectedTypeNames + */ + public function __construct( + public readonly string $typeNameField, + public readonly string $value, + public readonly array $expectedTypeNames, + Throwable $previous = null + ) { + parent::__construct( + "Only the following polymorphic types for field '{$this->typeNameField}' are allowed: [" . + implode(', ', $this->expectedTypeNames) . + "], but got '{$this->value}'", + 0, + $previous + ); + } +} diff --git a/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/FlatteningBoundClassProperty.php b/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/FlatteningBoundClassProperty.php index b9ecb3e..a3f735f 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/FlatteningBoundClassProperty.php +++ b/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/FlatteningBoundClassProperty.php @@ -33,7 +33,7 @@ public function serialize(object $object): array { $value = $this->property->get($object); - Assert::notNull($value, 'Value for #[Flatten] property cannot be null.'); + Assert::notNull($value, 'Value for #[Flatten] property cannot be null. This should have been handled by NullableTypeAdapter.'); $serialized = $this->typeAdapter->serialize($value); diff --git a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php new file mode 100644 index 0000000..482837b --- /dev/null +++ b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php @@ -0,0 +1,65 @@ +> + */ +class ClassPolymorphicTypeAdapterFactory implements TypeAdapterFactory +{ + /** + * @param class-string $parentClassName + * @param array $typeNameToClass + * @param array $classToTypeName + */ + public function __construct( + private readonly string $parentClassName, + private readonly string $serializedTypeNameField, + private readonly array $typeNameToClass, + private readonly array $classToTypeName, + ) {} + + /** + * @param class-string $parentClassName + */ + public static function for(string $parentClassName, string $serializedTypeNameField = '__typename'): ClassPolymorphicTypeAdapterFactoryBuilder + { + return new ClassPolymorphicTypeAdapterFactoryBuilder($parentClassName, $serializedTypeNameField); + } + + public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?PolymorphicTypeAdapter + { + if ( + $typeAdapterType !== PrimitiveTypeAdapter::class || + !$type instanceof NamedType || + $type->name !== $this->parentClassName + ) { + return null; + } + + /** @var PolymorphicTypeAdapter */ + return new PolymorphicTypeAdapter( + serializer: $serializer, + serializedTypeNameField: $this->serializedTypeNameField, + typeNameFromValue: function (mixed $value) { + Assert::object($value, 'Serializable polymorphic type must be an object.'); + + $className = $value::class; + $typeName = $this->classToTypeName[$className] ?? null; + + Assert::notNull($typeName, "Serializable polymorphic class '{$className}' was not registered."); + + return $typeName; + }, + typeNameRealTypeMap: $this->typeNameToClass, + ); + } +} diff --git a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactoryBuilder.php b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactoryBuilder.php new file mode 100644 index 0000000..9690a4c --- /dev/null +++ b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactoryBuilder.php @@ -0,0 +1,51 @@ + */ + private array $typeNameToClass = []; + + /** @var array */ + private array $classToTypeName = []; + + /** + * @param class-string $parentClassName + */ + public function __construct( + private readonly string $parentClassName, + private readonly string $serializedTypeNameField, + ) {} + + /** + * @param class-string $className + */ + public function subClass(string $className, string $typeName): self + { + if ($registeredClassName = $this->typeNameToClass[$typeName] ?? null) { + throw new InvalidArgumentException("Type name '{$typeName}' has already been registered and maps to class '{$registeredClassName}'."); + } + + if ($registeredType = $this->classToTypeName[$className] ?? null) { + throw new InvalidArgumentException("Class '{$className}' has already been registered and maps to type '{$registeredType}'."); + } + + $this->typeNameToClass[$typeName] = $className; + $this->classToTypeName[$className] = $typeName; + + return $this; + } + + public function build(): ClassPolymorphicTypeAdapterFactory + { + return new ClassPolymorphicTypeAdapterFactory( + parentClassName: $this->parentClassName, + serializedTypeNameField: $this->serializedTypeNameField, + typeNameToClass: $this->typeNameToClass, + classToTypeName: $this->classToTypeName, + ); + } +} diff --git a/src/TypeAdapter/Primitive/Polymorphic/PolymorphicTypeAdapter.php b/src/TypeAdapter/Primitive/Polymorphic/PolymorphicTypeAdapter.php new file mode 100644 index 0000000..ffca79f --- /dev/null +++ b/src/TypeAdapter/Primitive/Polymorphic/PolymorphicTypeAdapter.php @@ -0,0 +1,75 @@ + + */ +class PolymorphicTypeAdapter implements PrimitiveTypeAdapter +{ + /** + * @param callable(mixed): string $typeNameFromValue Return the serialized type name value for this object + * @param array $typeNameRealTypeMap Map of serialized type name to real reflection types + */ + public function __construct( + private readonly Serializer $serializer, + private readonly string $serializedTypeNameField, + private readonly mixed $typeNameFromValue, + private readonly array $typeNameRealTypeMap, + ) {} + + /** + * @return array + */ + public function serialize(mixed $value): array + { + // Returns the serialized type name value for this object - e.g. the "polymorphic name" + $typeName = ($this->typeNameFromValue)($value); + + $serialized = $this->adapterFromTypeName($typeName)->serialize($value); + + Assert::isMap($serialized, 'Polymorphic type must be serialized as an associative array.'); + + return [ + ...$serialized, + $this->serializedTypeNameField => $typeName, + ]; + } + + public function deserialize(mixed $value): mixed + { + if (!is_array($value) || ($value !== [] && !Arr::isAssoc($value))) { + throw new UnexpectedTypeException($value, PrimitiveType::array(MixedType::get(), PrimitiveType::string())); + } + + $typeName = Arr::pull($value, $this->serializedTypeNameField); + + if (!is_string($typeName)) { + throw new UnexpectedTypeException($typeName, PrimitiveType::string()); + } + + return $this->adapterFromTypeName($typeName)->deserialize($value); + } + + /** + * @return PrimitiveTypeAdapter + */ + private function adapterFromTypeName(string $typeName): PrimitiveTypeAdapter + { + $type = $this->typeNameRealTypeMap[$typeName] ?? throw new UnexpectedPolymorphicTypeException($this->serializedTypeNameField, $typeName, array_keys($this->typeNameRealTypeMap)); + + return $this->serializer->adapter(PrimitiveTypeAdapter::class, $type); + } +} diff --git a/tests/Integration/JsonSerializationTest.php b/tests/Integration/JsonSerializationTest.php index 691299b..b975e74 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -8,21 +8,28 @@ use GoodPhp\Reflection\Type\Combinatorial\UnionType; use GoodPhp\Reflection\Type\NamedType; use GoodPhp\Reflection\Type\PrimitiveType; +use GoodPhp\Reflection\Type\Special\MixedType; use GoodPhp\Reflection\Type\Special\NullableType; use GoodPhp\Reflection\Type\Type; use GoodPhp\Serialization\MissingValue; +use GoodPhp\Serialization\Serializer; use GoodPhp\Serialization\SerializerBuilder; use GoodPhp\Serialization\TypeAdapter\Exception\CollectionItemMappingException; use GoodPhp\Serialization\TypeAdapter\Exception\MultipleMappingException; use GoodPhp\Serialization\TypeAdapter\Exception\UnexpectedEnumValueException; +use GoodPhp\Serialization\TypeAdapter\Exception\UnexpectedPolymorphicTypeException; use GoodPhp\Serialization\TypeAdapter\Exception\UnexpectedTypeException; use GoodPhp\Serialization\TypeAdapter\Json\JsonTypeAdapter; use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\PropertyMappingException; +use GoodPhp\Serialization\TypeAdapter\Primitive\Polymorphic\ClassPolymorphicTypeAdapterFactory; use Illuminate\Support\Collection; use PHPUnit\Framework\TestCase; use Tests\Stubs\BackedEnumStub; use Tests\Stubs\ClassStub; use Tests\Stubs\NestedStub; +use Tests\Stubs\Polymorphic\Change; +use Tests\Stubs\Polymorphic\FromToChange; +use Tests\Stubs\Polymorphic\RemovedChange; use Tests\Stubs\UseDefaultStub; use Tests\Stubs\ValueEnumStub; use Throwable; @@ -34,11 +41,9 @@ class JsonSerializationTest extends TestCase */ public function testSerializes(string|Type $type, mixed $data, string $expectedSerialized): void { - $adapter = (new SerializerBuilder()) - ->build() - ->adapter(JsonTypeAdapter::class, $type); + $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); - self::assertSame($expectedSerialized, $adapter->serialize($data)); + self::assertJsonStringEqualsJsonString($expectedSerialized, $adapter->serialize($data)); } public static function serializesProvider(): iterable @@ -46,67 +51,89 @@ public static function serializesProvider(): iterable yield 'int' => [ 'int', 123, - '123', + <<<'JSON' + 123 + JSON, ]; yield 'float' => [ 'float', 123.45, - '123.45', + <<<'JSON' + 123.45 + JSON, ]; yield 'bool' => [ 'bool', true, - 'true', + <<<'JSON' + true + JSON, ]; yield 'string' => [ 'string', 'text', - '"text"', + <<<'JSON' + "text" + JSON, ]; yield 'nullable string' => [ new NullableType(PrimitiveType::string()), 'text', - '"text"', + <<<'JSON' + "text" + JSON, ]; yield 'nullable string with null value' => [ new NullableType(PrimitiveType::string()), null, - 'null', + <<<'JSON' + null + JSON, ]; yield 'DateTime' => [ DateTime::class, new DateTime('2020-01-01 00:00:00'), - '"2020-01-01T00:00:00.000000Z"', + <<<'JSON' + "2020-01-01T00:00:00.000000Z" + JSON, ]; yield 'nullable DateTime' => [ new NullableType(new NamedType(DateTime::class)), new DateTime('2020-01-01 00:00:00'), - '"2020-01-01T00:00:00.000000Z"', + <<<'JSON' + "2020-01-01T00:00:00.000000Z" + JSON, ]; yield 'nullable DateTime with null value' => [ new NullableType(new NamedType(DateTime::class)), null, - 'null', + <<<'JSON' + null + JSON, ]; yield 'backed enum' => [ BackedEnumStub::class, BackedEnumStub::ONE, - '"one"', + <<<'JSON' + "one" + JSON, ]; yield 'value enum' => [ ValueEnumStub::class, ValueEnumStub::$ONE, - '"one"', + <<<'JSON' + "one" + JSON, ]; yield 'array of DateTime' => [ @@ -114,7 +141,11 @@ public static function serializesProvider(): iterable new NamedType(DateTime::class) ), [new DateTime('2020-01-01 00:00:00')], - '["2020-01-01T00:00:00.000000Z"]', + <<<'JSON' + [ + "2020-01-01T00:00:00.000000Z" + ] + JSON, ]; yield 'Collection of DateTime' => [ @@ -126,7 +157,11 @@ public static function serializesProvider(): iterable ]) ), new Collection([new DateTime('2020-01-01 00:00:00')]), - '["2020-01-01T00:00:00.000000Z"]', + <<<'JSON' + [ + "2020-01-01T00:00:00.000000Z" + ] + JSON, ]; yield 'ClassStub with all fields' => [ @@ -145,9 +180,39 @@ public static function serializesProvider(): iterable MissingValue::INSTANCE, new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00'), - ['Some key' => 'Some value'] + ['Some key' => 'Some value'], + [ + new FromToChange(from: 'fr', to: 't'), + new RemovedChange(field: 'avatar'), + ] ), - '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","optional":123,"nullable":123,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z","other":{"Some key":"Some value"}}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": "something" + }, + "date": "2020-01-01T00:00:00.000000Z", + "optional": 123, + "nullable": 123, + "Field": "flattened", + "carbonImmutable": "2020-01-01T00:00:00.000000Z", + "other": { + "Some key": "Some value" + }, + "changes": [ + { + "__typename": "from_to", + "from": "fr", + "to": "t" + }, + { + "__typename": "removed", + "field": "avatar" + } + ] + } + JSON, ]; yield 'ClassStub with empty optional and null nullable' => [ @@ -167,7 +232,20 @@ public static function serializesProvider(): iterable new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00') ), - '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","nullable":null,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z","other":{}}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": "something" + }, + "date": "2020-01-01T00:00:00.000000Z", + "nullable": null, + "Field": "flattened", + "carbonImmutable": "2020-01-01T00:00:00.000000Z", + "other": {}, + "changes": [] + } + JSON, ]; } @@ -176,9 +254,7 @@ public static function serializesProvider(): iterable */ public function testDeserializes(string|Type $type, mixed $expectedData, string $serialized): void { - $adapter = (new SerializerBuilder()) - ->build() - ->adapter(JsonTypeAdapter::class, $type); + $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); self::assertEquals($expectedData, $adapter->deserialize($serialized)); } @@ -188,73 +264,97 @@ public static function deserializesProvider(): iterable yield 'int' => [ 'int', 123, - '123', + <<<'JSON' + 123 + JSON, ]; yield 'float' => [ 'float', 123.45, - '123.45', + <<<'JSON' + 123.45 + JSON, ]; yield 'float with int value' => [ 'float', 123.0, - '123', + <<<'JSON' + 123 + JSON, ]; yield 'bool' => [ 'bool', true, - 'true', + <<<'JSON' + true + JSON, ]; yield 'string' => [ 'string', 'text', - '"text"', + <<<'JSON' + "text" + JSON, ]; yield 'nullable string' => [ new NullableType(PrimitiveType::string()), 'text', - '"text"', + <<<'JSON' + "text" + JSON, ]; yield 'nullable string with null value' => [ new NullableType(PrimitiveType::string()), null, - 'null', + <<<'JSON' + null + JSON, ]; yield 'DateTime' => [ DateTime::class, new DateTime('2020-01-01 00:00:00'), - '"2020-01-01T00:00:00.000000Z"', + <<<'JSON' + "2020-01-01T00:00:00.000000Z" + JSON, ]; yield 'nullable DateTime' => [ new NullableType(new NamedType(DateTime::class)), new DateTime('2020-01-01 00:00:00'), - '"2020-01-01T00:00:00.000000Z"', + <<<'JSON' + "2020-01-01T00:00:00.000000Z" + JSON, ]; yield 'nullable DateTime with null value' => [ new NullableType(new NamedType(DateTime::class)), null, - 'null', + <<<'JSON' + null + JSON, ]; yield 'backed enum' => [ BackedEnumStub::class, BackedEnumStub::ONE, - '"one"', + <<<'JSON' + "one" + JSON, ]; yield 'value enum' => [ ValueEnumStub::class, ValueEnumStub::$ONE, - '"one"', + <<<'JSON' + "one" + JSON, ]; yield 'array of DateTime' => [ @@ -262,7 +362,11 @@ public static function deserializesProvider(): iterable new NamedType(DateTime::class) ), [new DateTime('2020-01-01 00:00:00')], - '["2020-01-01T00:00:00.000000Z"]', + <<<'JSON' + [ + "2020-01-01T00:00:00.000000Z" + ] + JSON, ]; yield 'Collection of DateTime' => [ @@ -274,7 +378,11 @@ public static function deserializesProvider(): iterable ]) ), new Collection([new DateTime('2020-01-01 00:00:00')]), - '["2020-01-01T00:00:00.000000Z"]', + <<<'JSON' + [ + "2020-01-01T00:00:00.000000Z" + ] + JSON, ]; yield 'ClassStub with all fields' => [ @@ -292,9 +400,40 @@ public static function deserializesProvider(): iterable 123, MissingValue::INSTANCE, new NestedStub('flattened'), - new CarbonImmutable('2020-01-01 00:00:00') + new CarbonImmutable('2020-01-01 00:00:00'), + ['Some key' => 'Some value'], + [ + new FromToChange(from: 'fr', to: 't'), + new RemovedChange(field: 'avatar'), + ] ), - '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","optional":123,"nullable":123,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": "something" + }, + "date": "2020-01-01T00:00:00.000000Z", + "optional": 123, + "nullable": 123, + "Field": "flattened", + "carbonImmutable": "2020-01-01T00:00:00.000000Z", + "other": { + "Some key": "Some value" + }, + "changes": [ + { + "__typename": "from_to", + "from": "fr", + "to": "t" + }, + { + "__typename": "removed", + "field": "avatar" + } + ] + } + JSON, ]; yield 'ClassStub with empty optional and null nullable' => [ @@ -314,7 +453,18 @@ public static function deserializesProvider(): iterable new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00') ), - '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","nullable":null,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": "something" + }, + "date": "2020-01-01T00:00:00.000000Z", + "nullable": null, + "Field": "flattened", + "carbonImmutable": "2020-01-01T00:00:00.000000Z" + } + JSON, ]; yield 'ClassStub with the least default fields' => [ @@ -334,19 +484,36 @@ public static function deserializesProvider(): iterable new NestedStub(), new CarbonImmutable('2020-01-01 00:00:00') ), - '{"primitive":1,"nested":{},"date":"2020-01-01T00:00:00.000000Z","carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": 1, + "nested": {}, + "date": "2020-01-01T00:00:00.000000Z", + "carbonImmutable": "2020-01-01T00:00:00.000000Z" + } + JSON, ]; yield '#[UseDefaultForUnexpected] with unexpected values' => [ new NamedType(UseDefaultStub::class), new UseDefaultStub(), - '{"null":"unknown value","enum":"also unknown"}', + <<<'JSON' + { + "null": "unknown value", + "enum": "also unknown" + } + JSON, ]; yield '#[UseDefaultForUnexpected] with expected values' => [ new NamedType(UseDefaultStub::class), new UseDefaultStub(BackedEnumStub::ONE, BackedEnumStub::TWO), - '{"null":"one","enum":"two"}', + <<<'JSON' + { + "null": "one", + "enum": "two" + } + JSON, ]; } @@ -355,9 +522,7 @@ public static function deserializesProvider(): iterable */ public function testDeserializesWithAnException(Throwable $expectedException, string|Type $type, string $serialized): void { - $adapter = (new SerializerBuilder()) - ->build() - ->adapter(JsonTypeAdapter::class, $type); + $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); try { $adapter->deserialize($serialized); @@ -373,67 +538,89 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'int' => [ new UnexpectedTypeException('123', PrimitiveType::integer()), 'int', - '"123"', + <<<'JSON' + "123" + JSON, ]; yield 'float' => [ new UnexpectedTypeException(true, PrimitiveType::float()), 'float', - 'true', + <<<'JSON' + true + JSON, ]; yield 'bool' => [ new UnexpectedTypeException(0, PrimitiveType::boolean()), 'bool', - '0', + <<<'JSON' + 0 + JSON, ]; yield 'string' => [ new UnexpectedTypeException(123, PrimitiveType::string()), 'string', - '123', + <<<'JSON' + 123 + JSON, ]; yield 'null' => [ new UnexpectedTypeException(null, PrimitiveType::string()), 'string', - 'null', + <<<'JSON' + null + JSON, ]; yield 'nullable string' => [ new UnexpectedTypeException(123, PrimitiveType::string()), new NullableType(PrimitiveType::string()), - '123', + <<<'JSON' + 123 + JSON, ]; yield 'DateTime' => [ new Exception('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database'), DateTime::class, - '"2020 dasd"', + <<<'JSON' + "2020 dasd" + JSON, ]; yield 'backed enum type' => [ new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), BackedEnumStub::class, - 'true', + <<<'JSON' + true + JSON, ]; yield 'backed enum value' => [ new UnexpectedEnumValueException('five', ['one', 'two']), BackedEnumStub::class, - '"five"', + <<<'JSON' + "five" + JSON, ]; yield 'value enum type' => [ new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), ValueEnumStub::class, - 'true', + <<<'JSON' + true + JSON, ]; yield 'value enum value' => [ new UnexpectedEnumValueException('five', ['one', 'two']), ValueEnumStub::class, - '"five"', + <<<'JSON' + "five" + JSON, ]; yield 'array of DateTime #1' => [ @@ -441,7 +628,9 @@ public static function deserializesWithAnExceptionProvider(): iterable PrimitiveType::array( new NamedType(DateTime::class) ), - '["2020 dasd"]', + <<<'JSON' + ["2020 dasd"] + JSON, ]; yield 'array of DateTime #2' => [ @@ -449,7 +638,9 @@ public static function deserializesWithAnExceptionProvider(): iterable PrimitiveType::array( new NamedType(DateTime::class) ), - '["2020-01-01T00:00:00.000000Z", null]', + <<<'JSON' + ["2020-01-01T00:00:00.000000Z", null] + JSON, ]; yield 'associative array of DateTime' => [ @@ -458,7 +649,11 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), PrimitiveType::string(), ), - '{"nested": null}', + <<<'JSON' + { + "nested": null + } + JSON, ]; yield 'Collection of DateTime #1' => [ @@ -470,7 +665,9 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - '[null]', + <<<'JSON' + [null] + JSON, ]; yield 'Collection of DateTime #2' => [ @@ -485,7 +682,9 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - '[null, null]', + <<<'JSON' + [null, null] + JSON, ]; yield 'ClassStub with wrong primitive type' => [ @@ -496,7 +695,16 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - '{"primitive":"1","nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": "1", + "nested": { + "Field": "something" + }, + "date": "2020-01-01T00:00:00.000000Z", + "carbonImmutable": "2020-01-01T00:00:00.000000Z" + } + JSON, ]; yield 'ClassStub with wrong nested field type' => [ @@ -507,13 +715,95 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - '{"primitive":1,"nested":{"Field":123},"date":"2020-01-01T00:00:00.000000Z","nullable":null,"carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": 123 + }, + "date": "2020-01-01T00:00:00.000000Z", + "nullable": null, + "carbonImmutable": "2020-01-01T00:00:00.000000Z" + } + JSON, ]; yield 'ClassStub with wrong nested array field type' => [ new PropertyMappingException('date.0.Field', new UnexpectedTypeException(123, PrimitiveType::string())), NamedType::wrap(ClassStub::class, [PrimitiveType::array(NestedStub::class)]), - '{"primitive":1,"nested":{"Field":"something"},"date":[{"Field":123}],"nullable":null,"carbonImmutable":"2020-01-01T00:00:00.000000Z"}', + <<<'JSON' + { + "primitive": 1, + "nested": { + "Field": "something" + }, + "date": [ + { + "Field": 123 + } + ], + "nullable": null, + "carbonImmutable": "2020-01-01T00:00:00.000000Z" + } + JSON, + ]; + + yield 'non object polymorphic type' => [ + new UnexpectedTypeException('five', PrimitiveType::array(MixedType::get(), PrimitiveType::string())), + Change::class, + <<<'JSON' + "five" + JSON, + ]; + + yield 'polymorphic object without type name field' => [ + new UnexpectedTypeException(null, PrimitiveType::string()), + Change::class, + <<<'JSON' + { + "field": "avatar" + } + JSON, + ]; + + yield 'polymorphic object with type name field of invalid type' => [ + new UnexpectedTypeException([ + 'not a string for sure', + ], PrimitiveType::string()), + Change::class, + <<<'JSON' + { + "__typename": ["not a string for sure"], + "field": "avatar" + } + JSON, + ]; + + yield 'polymorphic object with a non-existent sub type' => [ + new UnexpectedPolymorphicTypeException( + '__typename', + 'something_else', + ['from_to', 'removed'] + ), + Change::class, + <<<'JSON' + { + "__typename": "something_else", + "field": "avatar" + } + JSON, ]; } + + private function serializer(): Serializer + { + return (new SerializerBuilder()) + ->addFactoryLast( + ClassPolymorphicTypeAdapterFactory::for(Change::class) + ->subClass(FromToChange::class, 'from_to') + ->subClass(RemovedChange::class, 'removed') + ->build() + ) + ->build(); + } } diff --git a/tests/Stubs/ClassStub.php b/tests/Stubs/ClassStub.php index 5b64620..9e1959e 100644 --- a/tests/Stubs/ClassStub.php +++ b/tests/Stubs/ClassStub.php @@ -6,6 +6,7 @@ use GoodPhp\Serialization\MissingValue; use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\SerializedName; use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\Flattening\Flatten; +use Tests\Stubs\Polymorphic\Change; /** * @template T @@ -28,5 +29,7 @@ public function __construct( public readonly CarbonImmutable $carbonImmutable, /** @var array */ public readonly array $other = [], + /** @var list */ + public readonly array $changes = [], ) {} } diff --git a/tests/Stubs/Polymorphic/Change.php b/tests/Stubs/Polymorphic/Change.php new file mode 100644 index 0000000..10135e2 --- /dev/null +++ b/tests/Stubs/Polymorphic/Change.php @@ -0,0 +1,7 @@ +