From ba8fd3b696fa1b82068ba0e57a8a0d48522332d4 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Thu, 26 Sep 2024 22:18:27 +0200 Subject: [PATCH] Improve dernormalization --- src/Serializer/CallbackCasting.php | 60 +++++++++++----------- src/Serializer/CastToEnumTest.php | 78 +++++++++++++++++++---------- src/Serializer/CastToFloatTest.php | 41 ++++++++------- src/Serializer/CastToIntTest.php | 58 ++++++++++----------- src/Serializer/CastToStringTest.php | 70 ++++++++++++++++---------- src/Serializer/DenormalizerTest.php | 23 +++------ 6 files changed, 184 insertions(+), 146 deletions(-) diff --git a/src/Serializer/CallbackCasting.php b/src/Serializer/CallbackCasting.php index c240d939..b2b5b957 100644 --- a/src/Serializer/CallbackCasting.php +++ b/src/Serializer/CallbackCasting.php @@ -70,7 +70,7 @@ public function setOptions( } try { - $this->callback = self::resolveSupportedType($this->type); /* @phpstan-ignore-line */ + $this->callback = self::resolveTypeCallback($this->type); /* @phpstan-ignore-line */ $this->options = $options; return; @@ -85,31 +85,10 @@ public function setOptions( $this->type = self::aliases()[$this->alias]; } - /** @var Closure $callback */ - $callback = self::resolveAliasCallback($this->type); - $this->callback = $callback; + $this->callback = self::resolveAliasCallback($this->type); $this->options = $options; } - private static function resolveAliasCallback(string $type): Closure - { - foreach (self::aliases() as $alias => $registeredType) { - if ($type === $registeredType) { - return self::$aliases[$registeredType][$alias]; - } - - try { - $reflType = new ReflectionClass($type); /* @phpstan-ignore-line */ - if ($reflType->implementsInterface($registeredType)) { - return self::$aliases[$registeredType][$alias]; - } - } catch (Throwable) { - } - } - - throw new MappingFailed('The `'.$type.'` could not be resolved.'); - } - /** * @return TValue */ @@ -202,8 +181,8 @@ public static function unregisterAliases(): void public static function unregisterAll(): void { - self::$types = []; - self::$aliases = []; + self::unregisterTypes(); + self::unregisterAliases(); } public static function supportsAlias(?string $alias): bool @@ -218,7 +197,7 @@ public static function supportsType(?string $type): bool } try { - self::resolveSupportedType($type); /* @phpstan-ignore-line */ + self::resolveTypeCallback($type); /* @phpstan-ignore-line */ return true; } catch (Throwable) { @@ -258,8 +237,12 @@ public static function supports(ReflectionParameter|ReflectionProperty $reflecti foreach ($propertyTypeList as $propertyType) { $type = $propertyType->getName(); - if (null === $alias && self::supportsType($type)) { - return true; + if (null === $alias) { + if (self::supportsType($type)) { + return true; + } + + continue; } if (self::isAliasValid($type) || (Type::Mixed->value === $type && self::supportsAlias($alias))) { @@ -292,7 +275,7 @@ private static function isAliasValid(string $type): bool /** * @param class-string $type */ - private static function resolveSupportedType(string $type): Closure + private static function resolveTypeCallback(string $type): Closure { foreach (self::$types as $registeredType => $callback) { if ($type === $registeredType) { @@ -311,6 +294,25 @@ private static function resolveSupportedType(string $type): Closure throw new MappingFailed('The `'.$type.'` could not be resolved.'); } + private static function resolveAliasCallback(string $type): Closure + { + foreach (self::aliases() as $alias => $registeredType) { + if ($type === $registeredType) { + return self::$aliases[$registeredType][$alias]; + } + + try { + $reflType = new ReflectionClass($type); /* @phpstan-ignore-line */ + if ($reflType->implementsInterface($registeredType)) { + return self::$aliases[$registeredType][$alias]; + } + } catch (Throwable) { + } + } + + throw new MappingFailed('The `'.$type.'` could not be resolved.'); + } + /** * @throws MappingFailed * diff --git a/src/Serializer/CastToEnumTest.php b/src/Serializer/CastToEnumTest.php index 1bd27c42..ca3842ed 100644 --- a/src/Serializer/CastToEnumTest.php +++ b/src/Serializer/CastToEnumTest.php @@ -24,7 +24,11 @@ final class CastToEnumTest extends TestCase { public function testItCanConvertAStringBackedEnum(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')); + $class = new class () { + public Colour $colour; + }; + + $cast = new CastToEnum(new ReflectionProperty($class::class, 'colour')); $orange = $cast->toVariable('orange'); self::assertInstanceOf(Colour::class, $orange); @@ -34,7 +38,11 @@ public function testItCanConvertAStringBackedEnum(): void public function testItCanConvertAIntegerBackedEnum(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'dayOfTheWeek')); + $class = new class () { + public DayOfTheWeek $dayOfTheWeek; + }; + + $cast = new CastToEnum(new ReflectionProperty($class::class, 'dayOfTheWeek')); $monday = $cast->toVariable('1'); self::assertInstanceOf(DayOfTheWeek::class, $monday); @@ -44,7 +52,11 @@ public function testItCanConvertAIntegerBackedEnum(): void public function testItCanConvertAUnitEnum(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency')); + $class = new class () { + public Currency $currency; + }; + + $cast = new CastToEnum(new ReflectionProperty($class::class, 'currency')); $naira = $cast->toVariable('Naira'); self::assertInstanceOf(Currency::class, $naira); @@ -53,14 +65,22 @@ public function testItCanConvertAUnitEnum(): void public function testItReturnsNullWhenTheVariableIsNullable(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency')); + $class = new class () { + public ?Currency $nullableCurrency; + }; + + $cast = new CastToEnum(new ReflectionProperty($class::class, 'nullableCurrency')); self::assertNull($cast->toVariable(null)); } public function testItReturnsTheDefaultValueWhenTheVariableIsNullable(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency')); + $class = new class () { + public ?Currency $nullableCurrency; + }; + + $cast = new CastToEnum(new ReflectionProperty($class::class, 'nullableCurrency')); $cast->setOptions('Naira'); self::assertSame(Currency::Naira, $cast->toVariable(null)); @@ -70,19 +90,28 @@ public function testThrowsOnNullIfTheVariableIsNotNullable(): void { $this->expectException(TypeCastingFailed::class); - (new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency')))->toVariable(null); + $class = new class () { + public Currency $currency; + }; + + (new CastToEnum(new ReflectionProperty($class::class, 'currency')))->toVariable(null); } public function testThrowsIfTheValueIsNotRecognizedByTheEnum(): void { $this->expectException(TypeCastingFailed::class); - - (new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')))->toVariable('green'); + $class = new class () { + public Colour $colour; + }; + (new CastToEnum(new ReflectionProperty($class::class, 'colour')))->toVariable('green'); } public function testItReturnsTheDefaultValueWithUnionType(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'unionType')); + $class = new class () { + public DateTimeInterface|Colour|null $unionType; + }; + $cast = new CastToEnum(new ReflectionProperty($class::class, 'unionType')); $cast->setOptions('orange'); self::assertSame(Colour::Violet, $cast->toVariable('violet')); @@ -90,7 +119,10 @@ public function testItReturnsTheDefaultValueWithUnionType(): void public function testItCanConvertABackedEnum(): void { - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')); + $class = new class () { + public Colour $colour; + }; + $cast = new CastToEnum(new ReflectionProperty($class::class, 'colour')); $orange = $cast->toVariable(Colour::Orange); self::assertInstanceOf(Colour::class, $orange); @@ -101,8 +133,10 @@ public function testItCanConvertABackedEnum(): void public function testItWillThrowIfNotTheExpectedEnum(): void { $this->expectException(TypeCastingFailed::class); - - $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')); + $class = new class () { + public Colour $colour; + }; + $cast = new CastToEnum(new ReflectionProperty($class::class, 'colour')); $cast->toVariable(DayOfTheWeek::Monday); } @@ -110,8 +144,12 @@ public function testItWillThrowIfNotTheExpectedEnum(): void public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void { $this->expectException(MappingFailed::class); - - $reflectionProperty = new ReflectionProperty(EnumClass::class, $propertyName); + $class = new class () { + public ?bool $nullableBool; + public DateTimeInterface|int $invalidUnionType; + public Countable&Traversable $intersectionType; + }; + $reflectionProperty = new ReflectionProperty($class::class, $propertyName); new CastToEnum($reflectionProperty); } @@ -144,15 +182,3 @@ enum Currency case Euro; case Naira; } - -class EnumClass -{ - public DayOfTheWeek $dayOfTheWeek; - public Currency $currency; - public ?Currency $nullableCurrency; - public Colour $colour; - public ?bool $nullableBool; - public DateTimeInterface|Colour|null $unionType; - public DateTimeInterface|int $invalidUnionType; - public Countable&Traversable $intersectionType; -} diff --git a/src/Serializer/CastToFloatTest.php b/src/Serializer/CastToFloatTest.php index 26036fe5..fb3c0084 100644 --- a/src/Serializer/CastToFloatTest.php +++ b/src/Serializer/CastToFloatTest.php @@ -24,7 +24,9 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToFloat(new ReflectionProperty(FloatClass::class, 'string')); + new CastToFloat(new ReflectionProperty((new class () { + public string $string; + })::class, 'string')); } #[DataProvider('providesValidStringForInt')] @@ -42,64 +44,69 @@ public function testItCanConvertToArraygWithoutArguments( public static function providesValidStringForInt(): iterable { + $class = new class () { + public ?float $nullableFloat; + public DateTimeInterface|float|null $unionType; + }; + yield 'positive integer' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => '1', 'default' => null, 'expected' => 1.0, ]; yield 'zero' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => '0', 'default' => null, 'expected' => 0.0, ]; yield 'negative integer' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => '-10', 'default' => null, 'expected' => -10.0, ]; yield 'integer type' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => -10, 'default' => null, 'expected' => -10.0, ]; yield 'float type' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => -10.0, 'default' => null, 'expected' => -10.0, ]; yield 'null value' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => null, 'default' => null, 'expected' => null, ]; yield 'null value with default value' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), + 'prototype' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => null, 'default' => 10, 'expected' => 10.0, ]; yield 'with union type' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'unionType'), + 'prototype' => new ReflectionProperty($class::class, 'unionType'), 'input' => '23', 'default' => 42.0, 'expected' => 23.0, ]; yield 'with nullable union type' => [ - 'prototype' => new ReflectionProperty(FloatClass::class, 'unionType'), + 'prototype' => new ReflectionProperty($class::class, 'unionType'), 'input' => null, 'default' => 42.0, 'expected' => 42.0, @@ -110,16 +117,8 @@ public function testItFailsToConvertNonIntegerString(): void { $this->expectException(TypeCastingFailed::class); - (new CastToFloat(new ReflectionProperty(FloatClass::class, 'nullableFloat')))->toVariable('00foobar'); + (new CastToFloat(new ReflectionProperty((new class () { + public ?float $nullableFloat; + })::class, 'nullableFloat')))->toVariable('00foobar'); } } - -class FloatClass -{ - public float $float; - public ?float $nullableFloat; - public mixed $mixed; - public int $int; - public string $string; - public DateTimeInterface|float|null $unionType; -} diff --git a/src/Serializer/CastToIntTest.php b/src/Serializer/CastToIntTest.php index 76271714..bc695b3c 100644 --- a/src/Serializer/CastToIntTest.php +++ b/src/Serializer/CastToIntTest.php @@ -26,7 +26,9 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToInt(new ReflectionProperty(IntClass::class, 'string')); + new CastToInt(new ReflectionProperty((new class () { + public string $string; + })::class, 'string')); } #[DataProvider('providesValidStringForInt')] @@ -44,78 +46,84 @@ public function testItCanConvertToArraygWithoutArguments( public static function providesValidStringForInt(): iterable { + $class = new class () { + public ?float $nullableFloat; + public ?int $nullableInt; + public DateTimeInterface|int|null $unionType; + }; + yield 'positive integer' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => '1', 'default' => null, 'expected' => 1, ]; yield 'zero' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => '0', 'default' => null, 'expected' => 0, ]; yield 'negative integer' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => '-10', 'default' => null, 'expected' => -10, ]; yield 'null value' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => null, 'default' => null, 'expected' => null, ]; yield 'null value with default value' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => null, 'default' => 10, 'expected' => 10, ]; yield 'conversion of the null value with a nullable float' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableFloat'), + 'property' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => null, 'default' => 10, 'expected' => 10, ]; yield 'conversion with float' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableFloat'), + 'property' => new ReflectionProperty($class::class, 'nullableFloat'), 'input' => '1', 'default' => null, 'expected' => 1, ]; yield 'with union type' => [ - 'property' => new ReflectionProperty(IntClass::class, 'unionType'), + 'property' => new ReflectionProperty($class::class, 'unionType'), 'input' => '23', 'default' => 42, 'expected' => 23, ]; yield 'with nullable union type' => [ - 'property' => new ReflectionProperty(IntClass::class, 'unionType'), + 'property' => new ReflectionProperty($class::class, 'unionType'), 'input' => null, 'default' => 42, 'expected' => 42, ]; yield 'integer type' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => -10, 'default' => null, 'expected' => -10, ]; yield 'float type' => [ - 'property' => new ReflectionProperty(IntClass::class, 'nullableInt'), + 'property' => new ReflectionProperty($class::class, 'nullableInt'), 'input' => -10.0, 'default' => null, 'expected' => -10, @@ -126,7 +134,9 @@ public function testItFailsToConvertNonIntegerString(): void { $this->expectException(TypeCastingFailed::class); - (new CastToInt(new ReflectionProperty(IntClass::class, 'nullableInt')))->toVariable('00foobar'); + (new CastToInt(new ReflectionProperty((new class () { + public ?int $nullableInt; + })::class, 'nullableInt')))->toVariable('00foobar'); } #[DataProvider('invalidPropertyName')] @@ -134,7 +144,13 @@ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void { $this->expectException(MappingFailed::class); - new CastToInt(new ReflectionProperty(IntClass::class, $propertyName)); + $class = new class () { + public ?bool $nullableBool; + public DateTimeInterface|string $invalidUnionType; + public Countable&Traversable $intersectionType; + }; + + new CastToInt(new ReflectionProperty($class::class, $propertyName)); } public static function invalidPropertyName(): iterable @@ -146,17 +162,3 @@ public static function invalidPropertyName(): iterable ]; } } - -class IntClass -{ - public float $float; - public ?float $nullableFloat; - public mixed $mixed; - public int $int; - public ?int $nullableInt; - public ?bool $nullableBool; - public string $string; - public DateTimeInterface|int|null $unionType; - public DateTimeInterface|string $invalidUnionType; - public Countable&Traversable $intersectionType; -} diff --git a/src/Serializer/CastToStringTest.php b/src/Serializer/CastToStringTest.php index d7d8602e..a726dff5 100644 --- a/src/Serializer/CastToStringTest.php +++ b/src/Serializer/CastToStringTest.php @@ -26,7 +26,9 @@ public function testItFailsWithNonSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToString(new ReflectionProperty(StringClass::class, 'int')); + new CastToString(new ReflectionProperty((new class () { + public int $int; + })::class, 'int')); } #[DataProvider('providesValidInputValue')] @@ -44,43 +46,60 @@ public function testItCanConvertStringToBool( public static function providesValidInputValue(): iterable { + $class = new class () { + public float $float; + public ?float $nullableFloat; + public int $int; + public ?int $nullableInt; + public string $string; + public ?string $nullableString; + public ?bool $nullableBool; + public bool $boolean; + public mixed $mixed; + public ?iterable $nullableIterable; + public array $array; + public DateTimeInterface|string|null $unionType; + public DateTimeInterface|int $invalidUnionType; + public Countable&Traversable $intersectionType; + }; + yield 'with a string/nullable type' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'nullableString'), 'default' => null, 'input' => 'true', 'expected' => 'true', ]; yield 'with a string type' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'string'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'string'), 'default' => null, 'input' => 'yes', 'expected' => 'yes', ]; yield 'with a nullable string type and the null value' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'nullableString'), 'default' => null, 'input' => null, 'expected' => null, ]; yield 'with a nullable string type and a non null default value' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'nullableString'), 'default' => 'foo', 'input' => null, 'expected' => 'foo', ]; yield 'with union type' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'unionType'), 'default' => 'foo', 'input' => 'tata', 'expected' => 'tata', ]; yield 'with nullable union type' => [ - 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'), + 'reflectionProperty' => new ReflectionProperty($class::class, 'unionType'), 'default' => 'foo', 'input' => null, 'expected' => 'foo', @@ -92,7 +111,24 @@ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void { $this->expectException(MappingFailed::class); - new CastToString(new ReflectionProperty(StringClass::class, $propertyName)); + $class = new class () { + public float $float; + public ?float $nullableFloat; + public int $int; + public ?int $nullableInt; + public string $string; + public ?string $nullableString; + public ?bool $nullableBool; + public bool $boolean; + public mixed $mixed; + public ?iterable $nullableIterable; + public array $array; + public DateTimeInterface|string|null $unionType; + public DateTimeInterface|int $invalidUnionType; + public Countable&Traversable $intersectionType; + }; + + new CastToString(new ReflectionProperty($class::class, $propertyName)); } public static function invalidPropertyName(): iterable @@ -104,21 +140,3 @@ public static function invalidPropertyName(): iterable ]; } } - -class StringClass -{ - public float $float; - public ?float $nullableFloat; - public int $int; - public ?int $nullableInt; - public string $string; - public ?string $nullableString; - public ?bool $nullableBool; - public bool $boolean; - public mixed $mixed; - public ?iterable $nullableIterable; - public array $array; - public DateTimeInterface|string|null $unionType; - public DateTimeInterface|int $invalidUnionType; - public Countable&Traversable $intersectionType; -} diff --git a/src/Serializer/DenormalizerTest.php b/src/Serializer/DenormalizerTest.php index f9cdd37b..0dde4908 100644 --- a/src/Serializer/DenormalizerTest.php +++ b/src/Serializer/DenormalizerTest.php @@ -32,11 +32,6 @@ protected function setUp(): void Denormalizer::unregisterAll(); } - protected function tearDown(): void - { - Denormalizer::unregisterAll(); - } - public function testItConvertsAnIterableListOfRecords(): void { $records = [ @@ -668,10 +663,15 @@ public function it_will_fails_if_the_property_is_missing_from_source(): void { $data = ['foo' => 'bar']; + $class = new class () { + public string $foo; + public string $bar; + }; + $this->expectException(DenormalizationFailed::class); - $this->expectExceptionMessage('The property '.MissingProperty::class.'::bar is not initialized; its value is missing from the source data.'); + $this->expectExceptionMessage('The property '.$class::class.'::bar is not initialized; its value is missing from the source data.'); - Denormalizer::assign(MissingProperty::class, $data); + Denormalizer::assign($class::class, $data); } } @@ -722,12 +722,3 @@ private function addOne(int $add): void $this->addition += $add; } } - -class MissingProperty -{ - public function __construct( - public readonly string $foo, - public readonly string $bar, - ) { - } -}