From 18dac4a83fe0c52406bcfd28e83078a264427aa0 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Mon, 10 Mar 2025 18:34:34 +0200 Subject: [PATCH 1/9] docs: Separate docs --- README.md | 373 +---------------------------- 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/type-adapters.md | 107 +++++++++ docs/type-mappers.md | 67 ++++++ 8 files changed, 365 insertions(+), 364 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/type-adapters.md create mode 100644 docs/type-mappers.md diff --git a/README.md b/README.md index 74fa8dd..75fe446 100644 --- a/README.md +++ b/README.md @@ -50,382 +50,27 @@ $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 +- 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 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/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); + } +} +``` From f41c00d97a0af352892a2a65a5d96f83cacfff0b Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Wed, 12 Mar 2025 17:05:15 +0200 Subject: [PATCH 2/9] feat: Polymorphic type adapter and less required dependencies --- README.md | 2 +- composer.json | 6 +- docs/polymorphic-types.md | 66 +++ 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 + 15 files changed, 684 insertions(+), 73 deletions(-) create mode 100644 docs/polymorphic-types.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 75fe446..488aebe 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ test suite: [tests/Integration](tests/Integration). There are some alternatives to this, but they usually lack one of the following: +- 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 @@ -77,4 +78,3 @@ There are some alternatives to this, but they usually lack one of the following: - 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/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/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..60b08bf --- /dev/null +++ b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php @@ -0,0 +1,27 @@ + $expectedTypeNames + */ + public function __construct( + public readonly string $typeNameField, + public readonly string|int $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 @@ + Date: Wed, 12 Mar 2025 17:18:56 +0200 Subject: [PATCH 3/9] fix: Don't allow int in UnexpectedPolymorphicTypeException --- .../Exception/UnexpectedPolymorphicTypeException.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php index 60b08bf..80256ea 100644 --- a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php +++ b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php @@ -8,11 +8,11 @@ class UnexpectedPolymorphicTypeException extends RuntimeException implements UnexpectedValueException { /** - * @param list $expectedTypeNames + * @param list $expectedTypeNames */ public function __construct( public readonly string $typeNameField, - public readonly string|int $value, + public readonly string $value, public readonly array $expectedTypeNames, Throwable $previous = null ) { From 81ed10174634374835585d9a3177e758fd7ba902 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Thu, 13 Mar 2025 13:09:48 +0200 Subject: [PATCH 4/9] refactor: Upgrade to reflection 2.x --- composer.json | 2 +- phpstan.neon | 3 +- .../ClassPropertiesPrimitiveTypeAdapter.php | 5 ++- ...sPropertiesPrimitiveTypeAdapterFactory.php | 4 +-- .../MapperMethod/InstanceMapperMethod.php | 33 ++++++++++++------- ...pperMethodsPrimitiveTypeAdapterFactory.php | 18 +++++----- ...hodsPrimitiveTypeAdapterFactoryFactory.php | 12 ++++--- 7 files changed, 44 insertions(+), 33 deletions(-) diff --git a/composer.json b/composer.json index 89ddbdd..842cf83 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ ], "require": { "php": ">=8.2", - "good-php/reflection": "^1.0", + "good-php/reflection": "dev-update-deps", "illuminate/support": "^10.0 || ^11.0" }, "require-dev": { diff --git a/phpstan.neon b/phpstan.neon index eb8fe97..dc514dc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,5 +10,4 @@ parameters: - src ignoreErrors: - # There's no extension for that :( -# - '#Call to an undefined method Pest\\Expectations\\Expectation|Pest\\Expectations\\Support\\Extendable::#i' + - '#Parameter (\#|\$).* expects list<(.*)>, array given.#i' diff --git a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php index ffa7b85..de2cc80 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php +++ b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php @@ -10,7 +10,6 @@ use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\BoundClassProperty; use GoodPhp\Serialization\TypeAdapter\Primitive\PrimitiveTypeAdapter; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; /** * @template T of object @@ -21,12 +20,12 @@ final class ClassPropertiesPrimitiveTypeAdapter implements PrimitiveTypeAdapter { /** * @param class-string $className - * @param Collection> $properties + * @param list> $properties */ public function __construct( private readonly Hydrator $hydrator, private readonly string $className, - private readonly Collection $properties, + private readonly array $properties, ) {} public function serialize(mixed $value): mixed diff --git a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php index 09a2f5b..1b296d0 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php @@ -43,14 +43,14 @@ public function create(string $typeAdapterType, Type $type, Attributes $attribut return new ClassPropertiesPrimitiveTypeAdapter( $this->hydrator, $className, - $reflection->properties()->map(function (PropertyReflection $property) use ($serializer, $typeAdapterType) { + array_map(function (PropertyReflection $property) use ($serializer, $typeAdapterType) { $serializedName = $this->namingStrategy->translate($property); return PropertyMappingException::rethrow( $serializedName, fn () => $this->boundClassPropertyFactory->create($typeAdapterType, $serializedName, $property, $serializer), ); - }) + }, $reflection->properties()) ); } } diff --git a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php index 79b7cb8..c472f8d 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php +++ b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php @@ -38,7 +38,7 @@ public function accepts(NamedType $type, Attributes $attributes, Serializer $ser public function invoke(mixed $value, Type $type, Attributes $attributes, Serializer $serializer, MapperMethodsPrimitiveTypeAdapterFactory $skipPast): mixed { - $map = [ + $injectables = [ MapperMethodsPrimitiveTypeAdapterFactory::class => $skipPast, Serializer::class => $serializer, Type::class => $type, @@ -50,17 +50,7 @@ public function invoke(mixed $value, Type $type, Attributes $attributes, Seriali return $this->method->invoke( $this->adapter, $value, - ...$this->method - ->parameters() - ->slice(1) - ->map(function (FunctionParameterReflection $parameter) use ($map) { - $type = $parameter->type(); - - Assert::isInstanceOf($type, NamedType::class); - Assert::keyExists($map, $type->name); - - return $map[$type->name]; - }) + ...$this->invokeParameters($injectables), ); } catch (TypeError $e) { if (!str_contains($e->getMessage(), 'Argument #1')) { @@ -71,4 +61,23 @@ public function invoke(mixed $value, Type $type, Attributes $attributes, Seriali throw new UnexpectedTypeException($value, $this->method->parameters()->firstOrFail()->type()); } } + + /** + * @param array $injectables + * + * @return list + */ + private function invokeParameters(array $injectables): array + { + $parameters = array_slice($this->method->parameters(), 1); + + return array_map(function (FunctionParameterReflection $parameter) use ($injectables) { + $type = $parameter->type(); + + Assert::isInstanceOf($type, NamedType::class); + Assert::keyExists($injectables, $type->name); + + return $injectables[$type->name]; + }, $parameters); + } } diff --git a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php index ebcb983..fe9b71d 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php @@ -9,7 +9,7 @@ use GoodPhp\Serialization\TypeAdapter\Primitive\MapperMethods\MapperMethod\MapperMethod; use GoodPhp\Serialization\TypeAdapter\Primitive\PrimitiveTypeAdapter; use GoodPhp\Serialization\TypeAdapter\TypeAdapterFactory; -use Illuminate\Support\Collection; +use Illuminate\Support\Arr; use Webmozart\Assert\Assert; /** @@ -18,12 +18,12 @@ final class MapperMethodsPrimitiveTypeAdapterFactory implements TypeAdapterFactory { public function __construct( - /** @var Collection */ - private readonly Collection $toMappers, - /** @var Collection */ - private readonly Collection $fromMappers, + /** @var list */ + private readonly array $toMappers, + /** @var list */ + private readonly array $fromMappers, ) { - Assert::true($this->toMappers->isNotEmpty() || $this->fromMappers->isNotEmpty()); + Assert::true($this->toMappers || $this->fromMappers); } public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?MapperMethodsPrimitiveTypeAdapter @@ -53,10 +53,10 @@ public function create(string $typeAdapterType, Type $type, Attributes $attribut } /** - * @param Collection $mappers + * @param list $mappers */ - private function findMapper(Collection $mappers, NamedType $type, Attributes $attributes, Serializer $serializer): ?MapperMethod + private function findMapper(array $mappers, NamedType $type, Attributes $attributes, Serializer $serializer): ?MapperMethod { - return $mappers->first(fn (MapperMethod $method) => $method->accepts($type, $attributes, $serializer)); + return Arr::first($mappers, fn (MapperMethod $method) => $method->accepts($type, $attributes, $serializer)); } } diff --git a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactoryFactory.php b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactoryFactory.php index d39d595..1ce875c 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactoryFactory.php +++ b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactoryFactory.php @@ -25,22 +25,26 @@ public function create(object $adapter): MapperMethodsPrimitiveTypeAdapterFactor Assert::isInstanceOf($reflection, ClassReflection::class); return new MapperMethodsPrimitiveTypeAdapterFactory( - $reflection->methods() + collect($reflection->methods()) ->filter(fn (MethodReflection $method) => $method->attributes()->has(MapTo::class)) + ->values() ->map(fn (MethodReflection $method) => $this->mapperMethodFactory->createTo( $adapter, $method, /* @phpstan-ignore-next-line argument.type */ $method->attributes()->sole(MapTo::class), - )), - $reflection->methods() + )) + ->all(), + collect($reflection->methods()) ->filter(fn (MethodReflection $method) => $method->attributes()->has(MapFrom::class)) + ->values() ->map(fn (MethodReflection $method) => $this->mapperMethodFactory->createFrom( $adapter, $method, /* @phpstan-ignore-next-line argument.type */ $method->attributes()->sole(MapFrom::class) - )), + )) + ->all(), ); } } From dd26fb999cecac07d6e2ae14ebcf31ca8caa2ab6 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Mon, 7 Jul 2025 15:13:15 +0200 Subject: [PATCH 5/9] fix: Failing tests --- composer.json | 16 ++-- .../MapperMethod/InstanceMapperMethod.php | 2 +- tests/Integration/JsonSerializationTest.php | 84 +++++++------------ 3 files changed, 39 insertions(+), 63 deletions(-) diff --git a/composer.json b/composer.json index 842cf83..2256825 100644 --- a/composer.json +++ b/composer.json @@ -11,16 +11,16 @@ "require": { "php": ">=8.2", "good-php/reflection": "dev-update-deps", - "illuminate/support": "^10.0 || ^11.0" + "illuminate/support": "^10.0 || ^11.0 || ^12.0" }, "require-dev": { - "pestphp/pest": "^2.8", - "php-cs-fixer/shim": "~3.19.2", - "tenantcloud/php-cs-fixer-rule-sets": "~3.0.0", - "phpstan/phpstan": "~1.10.21", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpstan/phpstan-mockery": "^1.1", + "pestphp/pest": "^3.8", + "php-cs-fixer/shim": "~3.80.0", + "tenantcloud/php-cs-fixer-rule-sets": "~3.4.1", + "phpstan/phpstan": "~2.1.17", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpstan/phpstan-mockery": "^2.0", "phake/phake": "^4.2", "tenantcloud/php-standard": "^2.2" }, diff --git a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php index c472f8d..0a6e161 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php +++ b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php @@ -58,7 +58,7 @@ public function invoke(mixed $value, Type $type, Attributes $attributes, Seriali } /* @phpstan-ignore-next-line argument.type */ - throw new UnexpectedTypeException($value, $this->method->parameters()->firstOrFail()->type()); + throw new UnexpectedTypeException($value, $this->method->parameters()[0]->type()); } } diff --git a/tests/Integration/JsonSerializationTest.php b/tests/Integration/JsonSerializationTest.php index b975e74..437810d 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -3,6 +3,7 @@ namespace Tests\Integration; use Carbon\CarbonImmutable; +use DateMalformedStringException; use DateTime; use Exception; use GoodPhp\Reflection\Type\Combinatorial\UnionType; @@ -149,13 +150,10 @@ public static function serializesProvider(): iterable ]; yield 'Collection of DateTime' => [ - new NamedType( - Collection::class, - new Collection([ - PrimitiveType::integer(), - new NamedType(DateTime::class), - ]) - ), + new NamedType(Collection::class, [ + PrimitiveType::integer(), + new NamedType(DateTime::class), + ]), new Collection([new DateTime('2020-01-01 00:00:00')]), <<<'JSON' [ @@ -165,12 +163,9 @@ public static function serializesProvider(): iterable ]; yield 'ClassStub with all fields' => [ - new NamedType( - ClassStub::class, - new Collection([ - new NamedType(DateTime::class), - ]) - ), + new NamedType(ClassStub::class, [ + new NamedType(DateTime::class), + ]), new ClassStub( 1, new NestedStub(), @@ -216,11 +211,9 @@ public static function serializesProvider(): iterable ]; yield 'ClassStub with empty optional and null nullable' => [ - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) + ] ), new ClassStub( 1, @@ -370,12 +363,10 @@ public static function deserializesProvider(): iterable ]; yield 'Collection of DateTime' => [ - new NamedType( - Collection::class, - new Collection([ + new NamedType(Collection::class, [ PrimitiveType::integer(), new NamedType(DateTime::class), - ]) + ] ), new Collection([new DateTime('2020-01-01 00:00:00')]), <<<'JSON' @@ -386,11 +377,9 @@ public static function deserializesProvider(): iterable ]; yield 'ClassStub with all fields' => [ - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) + ] ), new ClassStub( 1, @@ -437,11 +426,9 @@ public static function deserializesProvider(): iterable ]; yield 'ClassStub with empty optional and null nullable' => [ - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) + ] ), new ClassStub( 1, @@ -468,12 +455,9 @@ public static function deserializesProvider(): iterable ]; yield 'ClassStub with the least default fields' => [ - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) - ), + ]), new ClassStub( 1, new NestedStub(), @@ -584,7 +568,7 @@ public static function deserializesWithAnExceptionProvider(): iterable ]; yield 'DateTime' => [ - new Exception('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database'), + new DateMalformedStringException('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" @@ -592,7 +576,7 @@ public static function deserializesWithAnExceptionProvider(): iterable ]; yield 'backed enum type' => [ - new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), + new UnexpectedTypeException(true, new UnionType([PrimitiveType::string(), PrimitiveType::integer()])), BackedEnumStub::class, <<<'JSON' true @@ -608,7 +592,7 @@ public static function deserializesWithAnExceptionProvider(): iterable ]; yield 'value enum type' => [ - new UnexpectedTypeException(true, new UnionType(new Collection([PrimitiveType::string(), PrimitiveType::integer()]))), + new UnexpectedTypeException(true, new UnionType([PrimitiveType::string(), PrimitiveType::integer()])), ValueEnumStub::class, <<<'JSON' true @@ -624,7 +608,7 @@ public static function deserializesWithAnExceptionProvider(): iterable ]; yield 'array of DateTime #1' => [ - new CollectionItemMappingException(0, new Exception('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database')), + new CollectionItemMappingException(0, new DateMalformedStringException('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database')), PrimitiveType::array( new NamedType(DateTime::class) ), @@ -658,12 +642,10 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'Collection of DateTime #1' => [ new CollectionItemMappingException(0, new UnexpectedTypeException(null, PrimitiveType::string())), - new NamedType( - Collection::class, - new Collection([ + new NamedType(Collection::class, [ PrimitiveType::integer(), new NamedType(DateTime::class), - ]) + ] ), <<<'JSON' [null] @@ -675,12 +657,10 @@ public static function deserializesWithAnExceptionProvider(): iterable new CollectionItemMappingException(0, new UnexpectedTypeException(null, PrimitiveType::string())), new CollectionItemMappingException(1, new UnexpectedTypeException(null, PrimitiveType::string())), ]), - new NamedType( - Collection::class, - new Collection([ + new NamedType(Collection::class, [ PrimitiveType::integer(), new NamedType(DateTime::class), - ]) + ] ), <<<'JSON' [null, null] @@ -689,11 +669,9 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'ClassStub with wrong primitive type' => [ new PropertyMappingException('primitive', new UnexpectedTypeException('1', PrimitiveType::integer())), - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) + ] ), <<<'JSON' { @@ -709,11 +687,9 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'ClassStub with wrong nested field type' => [ new PropertyMappingException('nested.Field', new UnexpectedTypeException(123, PrimitiveType::string())), - new NamedType( - ClassStub::class, - new Collection([ + new NamedType(ClassStub::class, [ new NamedType(DateTime::class), - ]) + ] ), <<<'JSON' { From ee52703d4b85437390722c10547ad5c2f42d4191 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Mon, 7 Jul 2025 15:13:34 +0200 Subject: [PATCH 6/9] refactor: Code style --- src/Serializer.php | 2 +- .../Cache/MemoizingTypeAdapterRegistry.php | 2 +- .../Factory/FactoryTypeAdapterRegistry.php | 2 +- .../Registry/TypeAdapterNotFoundException.php | 2 +- .../Registry/TypeAdapterRegistry.php | 2 +- .../TypeAdapterRegistrySerializer.php | 2 +- .../UnexpectedEnumValueException.php | 2 +- .../UnexpectedPolymorphicTypeException.php | 2 +- .../Exception/UnexpectedTypeException.php | 2 +- .../Exception/UnexpectedValueException.php | 4 +- src/TypeAdapter/Json/JsonTypeAdapter.php | 4 +- .../Primitive/BuiltIn/ArrayMapper.php | 10 ++-- .../ClassPropertiesPrimitiveTypeAdapter.php | 2 +- .../ClassProperties/MissingValueException.php | 2 +- .../Property/Flattening/Flatten.php | 4 +- .../Property/UseDefaultForUnexpected.php | 4 +- .../MapperMethod/InstanceMapperMethod.php | 2 +- .../Primitive/PrimitiveTypeAdapter.php | 4 +- src/TypeAdapter/TypeAdapterFactory.php | 2 - tests/Integration/JsonSerializationTest.php | 50 +++++++++++-------- tests/Stubs/ClassStub.php | 2 +- tests/Stubs/Polymorphic/Change.php | 4 +- 22 files changed, 54 insertions(+), 58 deletions(-) diff --git a/src/Serializer.php b/src/Serializer.php index c176b5b..c7a8c3d 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -26,5 +26,5 @@ public function reflector(): Reflector; * * @return A */ - public function adapter(string $typeAdapterType, Type|string $type, Attributes $attributes = new ArrayAttributes(), TypeAdapterFactory $skipPast = null): TypeAdapter; + public function adapter(string $typeAdapterType, Type|string $type, Attributes $attributes = new ArrayAttributes(), ?TypeAdapterFactory $skipPast = null): TypeAdapter; } diff --git a/src/Serializer/Registry/Cache/MemoizingTypeAdapterRegistry.php b/src/Serializer/Registry/Cache/MemoizingTypeAdapterRegistry.php index ce6a988..275f925 100644 --- a/src/Serializer/Registry/Cache/MemoizingTypeAdapterRegistry.php +++ b/src/Serializer/Registry/Cache/MemoizingTypeAdapterRegistry.php @@ -29,7 +29,7 @@ public function __construct( * * @return TypeAdapterType */ - public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), TypeAdapterFactory $skipPast = null): TypeAdapter + public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), ?TypeAdapterFactory $skipPast = null): TypeAdapter { $this->resolved[$typeAdapterType][(string) $type] ??= new WeakMap(); diff --git a/src/Serializer/Registry/Factory/FactoryTypeAdapterRegistry.php b/src/Serializer/Registry/Factory/FactoryTypeAdapterRegistry.php index 593b787..0934431 100644 --- a/src/Serializer/Registry/Factory/FactoryTypeAdapterRegistry.php +++ b/src/Serializer/Registry/Factory/FactoryTypeAdapterRegistry.php @@ -30,7 +30,7 @@ public function __construct( * * @return TypeAdapterType */ - public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), TypeAdapterFactory $skipPast = null): TypeAdapter + public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), ?TypeAdapterFactory $skipPast = null): TypeAdapter { if ($skipPast) { $skipPastIndex = array_search($skipPast, $this->factories, true); diff --git a/src/Serializer/Registry/TypeAdapterNotFoundException.php b/src/Serializer/Registry/TypeAdapterNotFoundException.php index cf8567a..4cfbd42 100644 --- a/src/Serializer/Registry/TypeAdapterNotFoundException.php +++ b/src/Serializer/Registry/TypeAdapterNotFoundException.php @@ -14,7 +14,7 @@ final class TypeAdapterNotFoundException extends RuntimeException /** * @param TypeAdapterFactory>|null $skipPast */ - public function __construct(string $typeAdapterType, Type $type, Attributes $attributes, ?TypeAdapterFactory $skipPast, Throwable $previous = null) + public function __construct(string $typeAdapterType, Type $type, Attributes $attributes, ?TypeAdapterFactory $skipPast, ?Throwable $previous = null) { $message = "A matching type adapter of type '{$typeAdapterType}' for type '{$type}' " . ($attributes->has() ? 'with attributes ' . $attributes : '') . diff --git a/src/Serializer/Registry/TypeAdapterRegistry.php b/src/Serializer/Registry/TypeAdapterRegistry.php index 170ea1d..6c29dd3 100644 --- a/src/Serializer/Registry/TypeAdapterRegistry.php +++ b/src/Serializer/Registry/TypeAdapterRegistry.php @@ -19,5 +19,5 @@ interface TypeAdapterRegistry * * @return TypeAdapterType */ - public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), TypeAdapterFactory $skipPast = null): TypeAdapter; + public function forType(string $typeAdapterType, Serializer $serializer, Type $type, Attributes $attributes = new ArrayAttributes(), ?TypeAdapterFactory $skipPast = null): TypeAdapter; } diff --git a/src/Serializer/TypeAdapterRegistrySerializer.php b/src/Serializer/TypeAdapterRegistrySerializer.php index d4bdecb..923fb3a 100644 --- a/src/Serializer/TypeAdapterRegistrySerializer.php +++ b/src/Serializer/TypeAdapterRegistrySerializer.php @@ -19,7 +19,7 @@ public function __construct( private readonly Reflector $reflector, ) {} - public function adapter(string $typeAdapterType, Type|string $type, Attributes $attributes = new ArrayAttributes(), TypeAdapterFactory $skipPast = null): TypeAdapter + public function adapter(string $typeAdapterType, Type|string $type, Attributes $attributes = new ArrayAttributes(), ?TypeAdapterFactory $skipPast = null): TypeAdapter { if (is_string($type)) { $type = new NamedType($type); diff --git a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php index 9795935..132c4fb 100644 --- a/src/TypeAdapter/Exception/UnexpectedEnumValueException.php +++ b/src/TypeAdapter/Exception/UnexpectedEnumValueException.php @@ -13,7 +13,7 @@ class UnexpectedEnumValueException extends RuntimeException implements Unexpecte public function __construct( public readonly string|int $value, public readonly array $expectedValues, - Throwable $previous = null + ?Throwable $previous = null ) { parent::__construct( 'Expected one of [' . diff --git a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php index 80256ea..b460286 100644 --- a/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php +++ b/src/TypeAdapter/Exception/UnexpectedPolymorphicTypeException.php @@ -14,7 +14,7 @@ public function __construct( public readonly string $typeNameField, public readonly string $value, public readonly array $expectedTypeNames, - Throwable $previous = null + ?Throwable $previous = null ) { parent::__construct( "Only the following polymorphic types for field '{$this->typeNameField}' are allowed: [" . diff --git a/src/TypeAdapter/Exception/UnexpectedTypeException.php b/src/TypeAdapter/Exception/UnexpectedTypeException.php index 5a91936..74f3ed6 100644 --- a/src/TypeAdapter/Exception/UnexpectedTypeException.php +++ b/src/TypeAdapter/Exception/UnexpectedTypeException.php @@ -11,7 +11,7 @@ class UnexpectedTypeException extends RuntimeException public function __construct( public readonly mixed $value, public readonly Type $expectedType, - Throwable $previous = null + ?Throwable $previous = null ) { parent::__construct( "Expected value of type '{$expectedType}', but got '" . diff --git a/src/TypeAdapter/Exception/UnexpectedValueException.php b/src/TypeAdapter/Exception/UnexpectedValueException.php index 647680d..73be550 100644 --- a/src/TypeAdapter/Exception/UnexpectedValueException.php +++ b/src/TypeAdapter/Exception/UnexpectedValueException.php @@ -4,6 +4,4 @@ use Throwable; -interface UnexpectedValueException extends Throwable -{ -} +interface UnexpectedValueException extends Throwable {} diff --git a/src/TypeAdapter/Json/JsonTypeAdapter.php b/src/TypeAdapter/Json/JsonTypeAdapter.php index 726502c..3650306 100644 --- a/src/TypeAdapter/Json/JsonTypeAdapter.php +++ b/src/TypeAdapter/Json/JsonTypeAdapter.php @@ -9,6 +9,4 @@ * * @extends TypeAdapter */ -interface JsonTypeAdapter extends TypeAdapter -{ -} +interface JsonTypeAdapter extends TypeAdapter {} diff --git a/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php b/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php index 238ac06..85d90bd 100644 --- a/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php +++ b/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php @@ -18,10 +18,10 @@ final class ArrayMapper /** * @template T * - * @param array $value + * @param list $value * @param NamedType $type * - * @return array + * @return list */ #[MapTo(PrimitiveTypeAdapter::class)] public function to(array $value, Type $type, Serializer $serializer): array|stdClass @@ -49,10 +49,10 @@ public function to(array $value, Type $type, Serializer $serializer): array|stdC } /** - * @param array $value - * @param NamedType $type + * @param list $value + * @param NamedType $type * - * @return array + * @return list */ #[MapFrom(PrimitiveTypeAdapter::class)] public function from(array $value, Type $type, Serializer $serializer): array diff --git a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php index de2cc80..215f1f0 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php +++ b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapter.php @@ -19,7 +19,7 @@ final class ClassPropertiesPrimitiveTypeAdapter implements PrimitiveTypeAdapter { /** - * @param class-string $className + * @param class-string $className * @param list> $properties */ public function __construct( diff --git a/src/TypeAdapter/Primitive/ClassProperties/MissingValueException.php b/src/TypeAdapter/Primitive/ClassProperties/MissingValueException.php index e561c10..6330966 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/MissingValueException.php +++ b/src/TypeAdapter/Primitive/ClassProperties/MissingValueException.php @@ -7,7 +7,7 @@ class MissingValueException extends RuntimeException { - public function __construct(Throwable $previous = null) + public function __construct(?Throwable $previous = null) { parent::__construct('Missing value.', 0, $previous); } diff --git a/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/Flatten.php b/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/Flatten.php index db89b74..2eebd80 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/Flatten.php +++ b/src/TypeAdapter/Primitive/ClassProperties/Property/Flattening/Flatten.php @@ -5,6 +5,4 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -class Flatten -{ -} +class Flatten {} diff --git a/src/TypeAdapter/Primitive/ClassProperties/Property/UseDefaultForUnexpected.php b/src/TypeAdapter/Primitive/ClassProperties/Property/UseDefaultForUnexpected.php index c5690da..a1118ca 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/Property/UseDefaultForUnexpected.php +++ b/src/TypeAdapter/Primitive/ClassProperties/Property/UseDefaultForUnexpected.php @@ -5,6 +5,4 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -class UseDefaultForUnexpected -{ -} +class UseDefaultForUnexpected {} diff --git a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php index 0a6e161..4094f04 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php +++ b/src/TypeAdapter/Primitive/MapperMethods/MapperMethod/InstanceMapperMethod.php @@ -21,8 +21,8 @@ final class InstanceMapperMethod implements MapperMethod { /** - * @param AdapterType $adapter * @param MethodReflection> $method + * @param AdapterType $adapter */ public function __construct( private readonly MethodReflection $method, diff --git a/src/TypeAdapter/Primitive/PrimitiveTypeAdapter.php b/src/TypeAdapter/Primitive/PrimitiveTypeAdapter.php index d708186..8a08c14 100644 --- a/src/TypeAdapter/Primitive/PrimitiveTypeAdapter.php +++ b/src/TypeAdapter/Primitive/PrimitiveTypeAdapter.php @@ -9,6 +9,4 @@ * * @extends TypeAdapter */ -interface PrimitiveTypeAdapter extends TypeAdapter -{ -} +interface PrimitiveTypeAdapter extends TypeAdapter {} diff --git a/src/TypeAdapter/TypeAdapterFactory.php b/src/TypeAdapter/TypeAdapterFactory.php index c7f76bb..b4e02b1 100644 --- a/src/TypeAdapter/TypeAdapterFactory.php +++ b/src/TypeAdapter/TypeAdapterFactory.php @@ -14,8 +14,6 @@ interface TypeAdapterFactory { /** * @param class-string> $typeAdapterType - * - * @return T|null */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter; } diff --git a/tests/Integration/JsonSerializationTest.php b/tests/Integration/JsonSerializationTest.php index 437810d..405484a 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -5,7 +5,6 @@ use Carbon\CarbonImmutable; use DateMalformedStringException; use DateTime; -use Exception; use GoodPhp\Reflection\Type\Combinatorial\UnionType; use GoodPhp\Reflection\Type\NamedType; use GoodPhp\Reflection\Type\PrimitiveType; @@ -24,6 +23,7 @@ use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\PropertyMappingException; use GoodPhp\Serialization\TypeAdapter\Primitive\Polymorphic\ClassPolymorphicTypeAdapterFactory; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Stubs\BackedEnumStub; use Tests\Stubs\ClassStub; @@ -37,9 +37,7 @@ class JsonSerializationTest extends TestCase { - /** - * @dataProvider serializesProvider - */ + #[DataProvider('serializesProvider')] public function testSerializes(string|Type $type, mixed $data, string $expectedSerialized): void { $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); @@ -211,7 +209,9 @@ public static function serializesProvider(): iterable ]; yield 'ClassStub with empty optional and null nullable' => [ - new NamedType(ClassStub::class, [ + new NamedType( + ClassStub::class, + [ new NamedType(DateTime::class), ] ), @@ -242,9 +242,7 @@ public static function serializesProvider(): iterable ]; } - /** - * @dataProvider deserializesProvider - */ + #[DataProvider('deserializesProvider')] public function testDeserializes(string|Type $type, mixed $expectedData, string $serialized): void { $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); @@ -363,7 +361,9 @@ public static function deserializesProvider(): iterable ]; yield 'Collection of DateTime' => [ - new NamedType(Collection::class, [ + new NamedType( + Collection::class, + [ PrimitiveType::integer(), new NamedType(DateTime::class), ] @@ -377,7 +377,9 @@ public static function deserializesProvider(): iterable ]; yield 'ClassStub with all fields' => [ - new NamedType(ClassStub::class, [ + new NamedType( + ClassStub::class, + [ new NamedType(DateTime::class), ] ), @@ -426,7 +428,9 @@ public static function deserializesProvider(): iterable ]; yield 'ClassStub with empty optional and null nullable' => [ - new NamedType(ClassStub::class, [ + new NamedType( + ClassStub::class, + [ new NamedType(DateTime::class), ] ), @@ -456,8 +460,8 @@ public static function deserializesProvider(): iterable yield 'ClassStub with the least default fields' => [ new NamedType(ClassStub::class, [ - new NamedType(DateTime::class), - ]), + new NamedType(DateTime::class), + ]), new ClassStub( 1, new NestedStub(), @@ -501,9 +505,7 @@ public static function deserializesProvider(): iterable ]; } - /** - * @dataProvider deserializesWithAnExceptionProvider - */ + #[DataProvider('deserializesWithAnExceptionProvider')] public function testDeserializesWithAnException(Throwable $expectedException, string|Type $type, string $serialized): void { $adapter = $this->serializer()->adapter(JsonTypeAdapter::class, $type); @@ -642,7 +644,9 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'Collection of DateTime #1' => [ new CollectionItemMappingException(0, new UnexpectedTypeException(null, PrimitiveType::string())), - new NamedType(Collection::class, [ + new NamedType( + Collection::class, + [ PrimitiveType::integer(), new NamedType(DateTime::class), ] @@ -657,7 +661,9 @@ public static function deserializesWithAnExceptionProvider(): iterable new CollectionItemMappingException(0, new UnexpectedTypeException(null, PrimitiveType::string())), new CollectionItemMappingException(1, new UnexpectedTypeException(null, PrimitiveType::string())), ]), - new NamedType(Collection::class, [ + new NamedType( + Collection::class, + [ PrimitiveType::integer(), new NamedType(DateTime::class), ] @@ -669,7 +675,9 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'ClassStub with wrong primitive type' => [ new PropertyMappingException('primitive', new UnexpectedTypeException('1', PrimitiveType::integer())), - new NamedType(ClassStub::class, [ + new NamedType( + ClassStub::class, + [ new NamedType(DateTime::class), ] ), @@ -687,7 +695,9 @@ public static function deserializesWithAnExceptionProvider(): iterable yield 'ClassStub with wrong nested field type' => [ new PropertyMappingException('nested.Field', new UnexpectedTypeException(123, PrimitiveType::string())), - new NamedType(ClassStub::class, [ + new NamedType( + ClassStub::class, + [ new NamedType(DateTime::class), ] ), diff --git a/tests/Stubs/ClassStub.php b/tests/Stubs/ClassStub.php index 9e1959e..c767574 100644 --- a/tests/Stubs/ClassStub.php +++ b/tests/Stubs/ClassStub.php @@ -21,7 +21,7 @@ public function __construct( public NestedStub $nested, #[SerializedName('date')] public mixed $generic, - public int|null|MissingValue $optional, + public int|MissingValue|null $optional, public ?int $nullable, public int|MissingValue $nonNullOptional, #[Flatten] diff --git a/tests/Stubs/Polymorphic/Change.php b/tests/Stubs/Polymorphic/Change.php index 10135e2..b1fe820 100644 --- a/tests/Stubs/Polymorphic/Change.php +++ b/tests/Stubs/Polymorphic/Change.php @@ -2,6 +2,4 @@ namespace Tests\Stubs\Polymorphic; -interface Change -{ -} +interface Change {} From 543d4054078618a83935051b96e5e5c180a30224 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Tue, 8 Jul 2025 15:55:37 +0200 Subject: [PATCH 7/9] fix: Failing PHPStan --- composer.json | 2 +- .../Json/FromPrimitiveJsonTypeAdapterFactory.php | 3 +++ src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php | 12 ++++++------ .../Primitive/BuiltIn/BackedEnumMapper.php | 1 + .../BuiltIn/Nullable/NullableTypeAdapterFactory.php | 3 +++ .../ClassPropertiesPrimitiveTypeAdapterFactory.php | 3 +++ .../MapperMethodsPrimitiveTypeAdapterFactory.php | 3 +++ .../Primitive/PhpStandard/ValueEnumMapper.php | 1 + .../ClassPolymorphicTypeAdapterFactory.php | 3 +++ src/TypeAdapter/TypeAdapterFactory.php | 2 ++ 10 files changed, 26 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 2256825..4fd446d 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ ], "require": { "php": ">=8.2", - "good-php/reflection": "dev-update-deps", + "good-php/reflection": "^2.0", "illuminate/support": "^10.0 || ^11.0 || ^12.0" }, "require-dev": { diff --git a/src/TypeAdapter/Json/FromPrimitiveJsonTypeAdapterFactory.php b/src/TypeAdapter/Json/FromPrimitiveJsonTypeAdapterFactory.php index 4dc5e40..4a89f0b 100644 --- a/src/TypeAdapter/Json/FromPrimitiveJsonTypeAdapterFactory.php +++ b/src/TypeAdapter/Json/FromPrimitiveJsonTypeAdapterFactory.php @@ -15,6 +15,9 @@ */ final class FromPrimitiveJsonTypeAdapterFactory implements TypeAdapterFactory { + /** + * @return JsonTypeAdapter|null + */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?JsonTypeAdapter { if ($typeAdapterType !== JsonTypeAdapter::class) { diff --git a/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php b/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php index 85d90bd..283bb7a 100644 --- a/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php +++ b/src/TypeAdapter/Primitive/BuiltIn/ArrayMapper.php @@ -18,10 +18,10 @@ final class ArrayMapper /** * @template T * - * @param list $value - * @param NamedType $type + * @param array $value + * @param NamedType $type * - * @return list + * @return array|stdClass */ #[MapTo(PrimitiveTypeAdapter::class)] public function to(array $value, Type $type, Serializer $serializer): array|stdClass @@ -49,10 +49,10 @@ public function to(array $value, Type $type, Serializer $serializer): array|stdC } /** - * @param list $value - * @param NamedType $type + * @param array $value + * @param NamedType $type * - * @return list + * @return array */ #[MapFrom(PrimitiveTypeAdapter::class)] public function from(array $value, Type $type, Serializer $serializer): array diff --git a/src/TypeAdapter/Primitive/BuiltIn/BackedEnumMapper.php b/src/TypeAdapter/Primitive/BuiltIn/BackedEnumMapper.php index b719ba5..e2d08c4 100644 --- a/src/TypeAdapter/Primitive/BuiltIn/BackedEnumMapper.php +++ b/src/TypeAdapter/Primitive/BuiltIn/BackedEnumMapper.php @@ -25,6 +25,7 @@ public function to(BackedEnum $value): string|int #[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))] public function from(string|int $value, Type $type): BackedEnum { + /** @var class-string $enumClass */ $enumClass = $type->name; $enum = $enumClass::tryFrom($value); diff --git a/src/TypeAdapter/Primitive/BuiltIn/Nullable/NullableTypeAdapterFactory.php b/src/TypeAdapter/Primitive/BuiltIn/Nullable/NullableTypeAdapterFactory.php index ad78e6b..8fc6ff1 100644 --- a/src/TypeAdapter/Primitive/BuiltIn/Nullable/NullableTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/BuiltIn/Nullable/NullableTypeAdapterFactory.php @@ -14,6 +14,9 @@ */ class NullableTypeAdapterFactory implements TypeAdapterFactory { + /** + * @return NullableTypeAdapter|null + */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?NullableTypeAdapter { if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NullableType) { diff --git a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php index 1b296d0..1fc94f1 100644 --- a/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/ClassProperties/ClassPropertiesPrimitiveTypeAdapterFactory.php @@ -25,6 +25,9 @@ public function __construct( private readonly BoundClassPropertyFactory $boundClassPropertyFactory, ) {} + /** + * @return ClassPropertiesPrimitiveTypeAdapter|null + */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?ClassPropertiesPrimitiveTypeAdapter { if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NamedType) { diff --git a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php index fe9b71d..48ff28a 100644 --- a/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/MapperMethods/TypeAdapter/MapperMethodsPrimitiveTypeAdapterFactory.php @@ -26,6 +26,9 @@ public function __construct( Assert::true($this->toMappers || $this->fromMappers); } + /** + * @return MapperMethodsPrimitiveTypeAdapter|null + */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?MapperMethodsPrimitiveTypeAdapter { if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NamedType) { diff --git a/src/TypeAdapter/Primitive/PhpStandard/ValueEnumMapper.php b/src/TypeAdapter/Primitive/PhpStandard/ValueEnumMapper.php index a41ea10..62448f6 100644 --- a/src/TypeAdapter/Primitive/PhpStandard/ValueEnumMapper.php +++ b/src/TypeAdapter/Primitive/PhpStandard/ValueEnumMapper.php @@ -39,6 +39,7 @@ public function to(ValueEnum $value): string|int #[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(ValueEnum::class))] public function from(string|int $value, Type $type): ValueEnum { + /** @var class-string> $enumClass */ $enumClass = $type->name; try { diff --git a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php index 482837b..9038030 100644 --- a/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php +++ b/src/TypeAdapter/Primitive/Polymorphic/ClassPolymorphicTypeAdapterFactory.php @@ -35,6 +35,9 @@ public static function for(string $parentClassName, string $serializedTypeNameFi return new ClassPolymorphicTypeAdapterFactoryBuilder($parentClassName, $serializedTypeNameField); } + /** + * @return PolymorphicTypeAdapter|null + */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?PolymorphicTypeAdapter { if ( diff --git a/src/TypeAdapter/TypeAdapterFactory.php b/src/TypeAdapter/TypeAdapterFactory.php index b4e02b1..8fb4fa9 100644 --- a/src/TypeAdapter/TypeAdapterFactory.php +++ b/src/TypeAdapter/TypeAdapterFactory.php @@ -14,6 +14,8 @@ interface TypeAdapterFactory { /** * @param class-string> $typeAdapterType + * + * @return TypeAdapter|null */ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter; } From e05483b413dcc9d3a6e55dcfead0423c4c4f729d Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Tue, 8 Jul 2025 16:03:26 +0200 Subject: [PATCH 8/9] fix: Failing tests --- .github/workflows/tests.yml | 6 ++--- tests/Integration/JsonSerializationTest.php | 28 ++++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0d88225..57d2779 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2 ] + php: [ 8.2, 8.3, 8.4 ] steps: - name: Checkout code @@ -50,7 +50,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip tools: composer:v2 coverage: none @@ -76,7 +76,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2 ] + php: [ 8.2, 8.3, 8.4 ] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/tests/Integration/JsonSerializationTest.php b/tests/Integration/JsonSerializationTest.php index 405484a..9d8c288 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -569,13 +569,15 @@ public static function deserializesWithAnExceptionProvider(): iterable JSON, ]; - yield 'DateTime' => [ - new DateMalformedStringException('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database'), - DateTime::class, - <<<'JSON' + if (version_compare(PHP_VERSION, '8.3', '>=')) { + yield 'DateTime' => [ + new DateMalformedStringException('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, - ]; + ]; + } yield 'backed enum type' => [ new UnexpectedTypeException(true, new UnionType([PrimitiveType::string(), PrimitiveType::integer()])), @@ -609,15 +611,17 @@ public static function deserializesWithAnExceptionProvider(): iterable JSON, ]; - yield 'array of DateTime #1' => [ - new CollectionItemMappingException(0, new DateMalformedStringException('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database')), - PrimitiveType::array( - new NamedType(DateTime::class) - ), - <<<'JSON' + if (version_compare(PHP_VERSION, '8.3', '>=')) { + yield 'array of DateTime #1' => [ + new CollectionItemMappingException(0, new DateMalformedStringException('Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database')), + PrimitiveType::array( + new NamedType(DateTime::class) + ), + <<<'JSON' ["2020 dasd"] JSON, - ]; + ]; + } yield 'array of DateTime #2' => [ new CollectionItemMappingException(1, new UnexpectedTypeException(null, PrimitiveType::string())), From ae19f1df37d0fa7d03c5e033efa9f84547ff4040 Mon Sep 17 00:00:00 2001 From: Alex Wells Date: Tue, 8 Jul 2025 17:09:49 +0200 Subject: [PATCH 9/9] refactor: Code style --- tests/Integration/JsonSerializationTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Integration/JsonSerializationTest.php b/tests/Integration/JsonSerializationTest.php index 9d8c288..5991e39 100644 --- a/tests/Integration/JsonSerializationTest.php +++ b/tests/Integration/JsonSerializationTest.php @@ -574,8 +574,8 @@ public static function deserializesWithAnExceptionProvider(): iterable new DateMalformedStringException('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" + JSON, ]; } @@ -618,8 +618,8 @@ public static function deserializesWithAnExceptionProvider(): iterable new NamedType(DateTime::class) ), <<<'JSON' - ["2020 dasd"] - JSON, + ["2020 dasd"] + JSON, ]; }