diff --git a/README.md b/README.md index 488aebe..74fa8dd 100644 --- a/README.md +++ b/README.md @@ -50,31 +50,386 @@ $primitiveAdapter = $serializer->adapter( PrimitiveTypeAdapter::class, NamedType::wrap(Item::class, [Carbon::class]) ); -// -> ['int' => 123, ...] -$primitiveAdapter->serialize(new Item(...)); +$primitiveAdapter->serialize(new Item(...)) // -> ['int' => 123, ...] $jsonAdapter = $serializer->adapter( JsonTypeAdapter::class, NamedType::wrap(Item::class, [PrimitiveType::int()]) ); -// new Item(123, ...) -$jsonAdapter->deserialize('{"int": 123, ...}'); +$jsonAdapter->deserialize('{"int": 123, ...}') // -> new Item(123, ...) ``` -## Documentation +### Custom mappers -Basic documentation is available in [docs/](docs). For examples, you can look at the -test suite: [tests/Integration](tests/Integration). +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)); + } +} +``` ## Why this over everything else? -There are some alternatives to this, but they usually lack one of the following: +There are some alternatives to this, but all of them will lack at least one of these: -- 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 +- doesn't rely on inheritance, 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 89ddbdd..b50c400 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,9 @@ ], "require": { "php": ">=8.2", + "php-ds/php-ds": "^1.3.0", "good-php/reflection": "^1.0", - "illuminate/support": "^10.0 || ^11.0" + "tenantcloud/php-standard": "^2.0" }, "require-dev": { "pestphp/pest": "^2.8", @@ -21,8 +22,7 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-webmozart-assert": "^1.2", "phpstan/phpstan-mockery": "^1.1", - "phake/phake": "^4.2", - "tenantcloud/php-standard": "^2.2" + "phake/phake": "^4.2" }, "autoload": { "psr-4": { diff --git a/docs/adding-more-formats.md b/docs/adding-more-formats.md deleted file mode 100644 index bfd2efa..0000000 --- a/docs/adding-more-formats.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index d3afd5f..0000000 --- a/docs/error-handling.md +++ /dev/null @@ -1,16 +0,0 @@ -## 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 deleted file mode 100644 index a052c03..0000000 --- a/docs/flattening.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index 7fa8c3f..0000000 --- a/docs/handling-unexpected-values.md +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index c10a8c6..0000000 --- a/docs/key-naming.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 deleted file mode 100644 index 91c1b65..0000000 --- a/docs/polymorphic-types.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 deleted file mode 100644 index 6c5c6af..0000000 --- a/docs/type-adapters.md +++ /dev/null @@ -1,107 +0,0 @@ -# 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 deleted file mode 100644 index b123218..0000000 --- a/docs/type-mappers.md +++ /dev/null @@ -1,67 +0,0 @@ -# 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 9a40ed9..0f82034 100644 --- a/src/SerializerBuilder.php +++ b/src/SerializerBuilder.php @@ -30,7 +30,6 @@ 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 @@ -150,13 +149,8 @@ public function build(): Serializer $typeAdapterRegistryBuilder = $this->typeAdapterRegistryBuilder() ->addFactoryLast(new NullableTypeAdapterFactory()) ->addMapperLast(new ScalarMapper()) - ->addMapperLast(new BackedEnumMapper()); - - if (class_exists(ValueEnum::class)) { - $typeAdapterRegistryBuilder = $typeAdapterRegistryBuilder->addMapperLast(new ValueEnumMapper()); - } - - $typeAdapterRegistryBuilder = $typeAdapterRegistryBuilder + ->addMapperLast(new BackedEnumMapper()) + ->addMapperLast(new ValueEnumMapper()) ->addMapperLast(new ArrayMapper()) ->addMapperLast(new CollectionMapper()) ->addMapperLast(new DateTimeMapper()) diff --git a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php index 9795935..a68c223 100644 --- a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php +++ b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php @@ -8,7 +8,7 @@ class UnexpectedEnumValueException extends RuntimeException implements UnexpectedValueException { /** - * @param list $expectedValues + * @param array $expectedValues */ public function __construct( public readonly string|int $value, diff --git a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php deleted file mode 100644 index 80256ea..0000000 --- a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php +++ /dev/null @@ -1,27 +0,0 @@ - $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 a3f735f..b9ecb3e 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. This should have been handled by NullableTypeAdapter.'); + Assert::notNull($value, 'Value for #[Flatten] property cannot be null.'); $serialized = $this->typeAdapter->serialize($value); diff --git a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php deleted file mode 100644 index 482837b..0000000 --- a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php +++ /dev/null @@ -1,65 +0,0 @@ -> - */ -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 deleted file mode 100644 index 9690a4c..0000000 --- a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactoryBuilder.php +++ /dev/null @@ -1,51 +0,0 @@ - */ - 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 deleted file mode 100644 index ffca79f..0000000 --- a/src/TypeAdapter/Primitive/Polymorphic/PolymorphicTypeAdapter.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -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 b975e74..691299b 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -8,28 +8,21 @@ 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; @@ -41,9 +34,11 @@ class JsonSerializationTest extends TestCase */ public function testSerializes(string|Type $type, mixed $data, string $expectedSerialized): void { - $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); + $adapter = (new SerializerBuilder()) + ->build() + ->adapter(JsonTypeAdapter::class, $type); - self::assertJsonStringEqualsJsonString($expectedSerialized, $adapter->serialize($data)); + self::assertSame($expectedSerialized, $adapter->serialize($data)); } public static function serializesProvider(): iterable @@ -51,89 +46,67 @@ public static function serializesProvider(): iterable yield 'int' => [ 'int', 123, - <<<'JSON' - 123 - JSON, + '123', ]; yield 'float' => [ 'float', 123.45, - <<<'JSON' - 123.45 - JSON, + '123.45', ]; yield 'bool' => [ 'bool', true, - <<<'JSON' - true - JSON, + 'true', ]; yield 'string' => [ 'string', 'text', - <<<'JSON' - "text" - JSON, + '"text"', ]; yield 'nullable string' => [ new NullableType(PrimitiveType::string()), 'text', - <<<'JSON' - "text" - JSON, + '"text"', ]; yield 'nullable string with null value' => [ new NullableType(PrimitiveType::string()), null, - <<<'JSON' - null - JSON, + 'null', ]; yield 'DateTime' => [ DateTime::class, new DateTime('2020-01-01 00:00:00'), - <<<'JSON' - "2020-01-01T00:00:00.000000Z" - JSON, + '"2020-01-01T00:00:00.000000Z"', ]; yield 'nullable DateTime' => [ new NullableType(new NamedType(DateTime::class)), new DateTime('2020-01-01 00:00:00'), - <<<'JSON' - "2020-01-01T00:00:00.000000Z" - JSON, + '"2020-01-01T00:00:00.000000Z"', ]; yield 'nullable DateTime with null value' => [ new NullableType(new NamedType(DateTime::class)), null, - <<<'JSON' - null - JSON, + 'null', ]; yield 'backed enum' => [ BackedEnumStub::class, BackedEnumStub::ONE, - <<<'JSON' - "one" - JSON, + '"one"', ]; yield 'value enum' => [ ValueEnumStub::class, ValueEnumStub::$ONE, - <<<'JSON' - "one" - JSON, + '"one"', ]; yield 'array of DateTime' => [ @@ -141,11 +114,7 @@ public static function serializesProvider(): iterable new NamedType(DateTime::class) ), [new DateTime('2020-01-01 00:00:00')], - <<<'JSON' - [ - "2020-01-01T00:00:00.000000Z" - ] - JSON, + '["2020-01-01T00:00:00.000000Z"]', ]; yield 'Collection of DateTime' => [ @@ -157,11 +126,7 @@ public static function serializesProvider(): iterable ]) ), new Collection([new DateTime('2020-01-01 00:00:00')]), - <<<'JSON' - [ - "2020-01-01T00:00:00.000000Z" - ] - JSON, + '["2020-01-01T00:00:00.000000Z"]', ]; yield 'ClassStub with all fields' => [ @@ -180,39 +145,9 @@ public static function serializesProvider(): iterable MissingValue::INSTANCE, new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00'), - ['Some key' => 'Some value'], - [ - new FromToChange(from: 'fr', to: 't'), - new RemovedChange(field: 'avatar'), - ] + ['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, + '{"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"}}', ]; yield 'ClassStub with empty optional and null nullable' => [ @@ -232,20 +167,7 @@ public static function serializesProvider(): iterable new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00') ), - <<<'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, + '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","nullable":null,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z","other":{}}', ]; } @@ -254,7 +176,9 @@ public static function serializesProvider(): iterable */ public function testDeserializes(string|Type $type, mixed $expectedData, string $serialized): void { - $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); + $adapter = (new SerializerBuilder()) + ->build() + ->adapter(JsonTypeAdapter::class, $type); self::assertEquals($expectedData, $adapter->deserialize($serialized)); } @@ -264,97 +188,73 @@ public static function deserializesProvider(): iterable yield 'int' => [ 'int', 123, - <<<'JSON' - 123 - JSON, + '123', ]; yield 'float' => [ 'float', 123.45, - <<<'JSON' - 123.45 - JSON, + '123.45', ]; yield 'float with int value' => [ 'float', 123.0, - <<<'JSON' - 123 - JSON, + '123', ]; yield 'bool' => [ 'bool', true, - <<<'JSON' - true - JSON, + 'true', ]; yield 'string' => [ 'string', 'text', - <<<'JSON' - "text" - JSON, + '"text"', ]; yield 'nullable string' => [ new NullableType(PrimitiveType::string()), 'text', - <<<'JSON' - "text" - JSON, + '"text"', ]; yield 'nullable string with null value' => [ new NullableType(PrimitiveType::string()), null, - <<<'JSON' - null - JSON, + 'null', ]; yield 'DateTime' => [ DateTime::class, new DateTime('2020-01-01 00:00:00'), - <<<'JSON' - "2020-01-01T00:00:00.000000Z" - JSON, + '"2020-01-01T00:00:00.000000Z"', ]; yield 'nullable DateTime' => [ new NullableType(new NamedType(DateTime::class)), new DateTime('2020-01-01 00:00:00'), - <<<'JSON' - "2020-01-01T00:00:00.000000Z" - JSON, + '"2020-01-01T00:00:00.000000Z"', ]; yield 'nullable DateTime with null value' => [ new NullableType(new NamedType(DateTime::class)), null, - <<<'JSON' - null - JSON, + 'null', ]; yield 'backed enum' => [ BackedEnumStub::class, BackedEnumStub::ONE, - <<<'JSON' - "one" - JSON, + '"one"', ]; yield 'value enum' => [ ValueEnumStub::class, ValueEnumStub::$ONE, - <<<'JSON' - "one" - JSON, + '"one"', ]; yield 'array of DateTime' => [ @@ -362,11 +262,7 @@ public static function deserializesProvider(): iterable new NamedType(DateTime::class) ), [new DateTime('2020-01-01 00:00:00')], - <<<'JSON' - [ - "2020-01-01T00:00:00.000000Z" - ] - JSON, + '["2020-01-01T00:00:00.000000Z"]', ]; yield 'Collection of DateTime' => [ @@ -378,11 +274,7 @@ public static function deserializesProvider(): iterable ]) ), new Collection([new DateTime('2020-01-01 00:00:00')]), - <<<'JSON' - [ - "2020-01-01T00:00:00.000000Z" - ] - JSON, + '["2020-01-01T00:00:00.000000Z"]', ]; yield 'ClassStub with all fields' => [ @@ -400,40 +292,9 @@ public static function deserializesProvider(): iterable 123, MissingValue::INSTANCE, new NestedStub('flattened'), - new CarbonImmutable('2020-01-01 00:00:00'), - ['Some key' => 'Some value'], - [ - new FromToChange(from: 'fr', to: 't'), - new RemovedChange(field: 'avatar'), - ] + new CarbonImmutable('2020-01-01 00:00:00') ), - <<<'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, + '{"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"}', ]; yield 'ClassStub with empty optional and null nullable' => [ @@ -453,18 +314,7 @@ public static function deserializesProvider(): iterable new NestedStub('flattened'), new CarbonImmutable('2020-01-01 00:00:00') ), - <<<'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, + '{"primitive":1,"nested":{"Field":"something"},"date":"2020-01-01T00:00:00.000000Z","nullable":null,"Field":"flattened","carbonImmutable":"2020-01-01T00:00:00.000000Z"}', ]; yield 'ClassStub with the least default fields' => [ @@ -484,36 +334,19 @@ public static function deserializesProvider(): iterable new NestedStub(), new CarbonImmutable('2020-01-01 00:00:00') ), - <<<'JSON' - { - "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"}', ]; yield '#[UseDefaultForUnexpected] with unexpected values' => [ new NamedType(UseDefaultStub::class), new UseDefaultStub(), - <<<'JSON' - { - "null": "unknown value", - "enum": "also unknown" - } - JSON, + '{"null":"unknown value","enum":"also unknown"}', ]; yield '#[UseDefaultForUnexpected] with expected values' => [ new NamedType(UseDefaultStub::class), new UseDefaultStub(BackedEnumStub::ONE, BackedEnumStub::TWO), - <<<'JSON' - { - "null": "one", - "enum": "two" - } - JSON, + '{"null":"one","enum":"two"}', ]; } @@ -522,7 +355,9 @@ public static function deserializesProvider(): iterable */ public function testDeserializesWithAnException(Throwable $expectedException, string|Type $type, string $serialized): void { - $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); + $adapter = (new SerializerBuilder()) + ->build() + ->adapter(JsonTypeAdapter::class, $type); try { $adapter->deserialize($serialized); @@ -538,89 +373,67 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'int' => [ new UnexpectedTypeException('123', PrimitiveType::integer()), 'int', - <<<'JSON' - "123" - JSON, + '"123"', ]; yield 'float' => [ new UnexpectedTypeException(true, PrimitiveType::float()), 'float', - <<<'JSON' - true - JSON, + 'true', ]; yield 'bool' => [ new UnexpectedTypeException(0, PrimitiveType::boolean()), 'bool', - <<<'JSON' - 0 - JSON, + '0', ]; yield 'string' => [ new UnexpectedTypeException(123, PrimitiveType::string()), 'string', - <<<'JSON' - 123 - JSON, + '123', ]; yield 'null' => [ new UnexpectedTypeException(null, PrimitiveType::string()), 'string', - <<<'JSON' - null - JSON, + 'null', ]; yield 'nullable string' => [ new UnexpectedTypeException(123, PrimitiveType::string()), new NullableType(PrimitiveType::string()), - <<<'JSON' - 123 - JSON, + '123', ]; 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, - <<<'JSON' - "2020 dasd" - JSON, + '"2020 dasd"', ]; yield 'backed enum type' => [ new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), BackedEnumStub::class, - <<<'JSON' - true - JSON, + 'true', ]; yield 'backed enum value' => [ new UnexpectedEnumValueException('five', ['one', 'two']), BackedEnumStub::class, - <<<'JSON' - "five" - JSON, + '"five"', ]; yield 'value enum type' => [ new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), ValueEnumStub::class, - <<<'JSON' - true - JSON, + 'true', ]; yield 'value enum value' => [ new UnexpectedEnumValueException('five', ['one', 'two']), ValueEnumStub::class, - <<<'JSON' - "five" - JSON, + '"five"', ]; yield 'array of DateTime #1' => [ @@ -628,9 +441,7 @@ public static function deserializesWithAnExceptionProvider(): iterable PrimitiveType::array( new NamedType(DateTime::class) ), - <<<'JSON' - ["2020 dasd"] - JSON, + '["2020 dasd"]', ]; yield 'array of DateTime #2' => [ @@ -638,9 +449,7 @@ public static function deserializesWithAnExceptionProvider(): iterable PrimitiveType::array( new NamedType(DateTime::class) ), - <<<'JSON' - ["2020-01-01T00:00:00.000000Z", null] - JSON, + '["2020-01-01T00:00:00.000000Z", null]', ]; yield 'associative array of DateTime' => [ @@ -649,11 +458,7 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), PrimitiveType::string(), ), - <<<'JSON' - { - "nested": null - } - JSON, + '{"nested": null}', ]; yield 'Collection of DateTime #1' => [ @@ -665,9 +470,7 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - <<<'JSON' - [null] - JSON, + '[null]', ]; yield 'Collection of DateTime #2' => [ @@ -682,9 +485,7 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - <<<'JSON' - [null, null] - JSON, + '[null, null]', ]; yield 'ClassStub with wrong primitive type' => [ @@ -695,16 +496,7 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - <<<'JSON' - { - "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"}', ]; yield 'ClassStub with wrong nested field type' => [ @@ -715,95 +507,13 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class), ]) ), - <<<'JSON' - { - "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"}', ]; 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)]), - <<<'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, + '{"primitive":1,"nested":{"Field":"something"},"date":[{"Field":123}],"nullable":null,"carbonImmutable":"2020-01-01T00:00:00.000000Z"}', ]; } - - 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 9e1959e..5b64620 100644 --- a/tests/Stubs/ClassStub.php +++ b/tests/Stubs/ClassStub.php @@ -6,7 +6,6 @@ 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 @@ -29,7 +28,5 @@ 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 deleted file mode 100644 index 10135e2..0000000 --- a/tests/Stubs/Polymorphic/Change.php +++ /dev/null @@ -1,7 +0,0 @@ -