From 339c89e7dc36d0fa8555af2c46106201f82513ca Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Sun, 12 Nov 2023 21:16:27 +0100 Subject: [PATCH] Add support for true, false and null parameter type --- src/Serializer.php | 57 ++++++++++++++++++++++----------- src/Serializer/CastToArray.php | 2 +- src/Serializer/CastToBool.php | 16 ++++++--- src/Serializer/CastToDate.php | 8 ++--- src/Serializer/CastToEnum.php | 6 ++-- src/Serializer/CastToFloat.php | 6 ++-- src/Serializer/CastToInt.php | 6 ++-- src/Serializer/CastToString.php | 17 +++++++--- src/Serializer/Type.php | 15 ++++++--- src/SerializerTest.php | 18 +++++------ 10 files changed, 96 insertions(+), 55 deletions(-) diff --git a/src/Serializer.php b/src/Serializer.php index c24551af..b774d4b1 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -34,6 +34,7 @@ use ReflectionNamedType; use ReflectionProperty; use ReflectionType; +use ReflectionUnionType; use Throwable; use function array_reduce; @@ -156,10 +157,11 @@ private function findPropertySetters(array $propertyNames): array $propertySetters = []; foreach ($this->class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { if ($property->isStatic()) { + //the property can not be cast yet + //as casting may be set using the Cell attribute continue; } - $propertyName = $property->getName(); $attribute = $property->getAttributes(Cell::class, ReflectionAttribute::IS_INSTANCEOF); if ([] !== $attribute) { //the property can not be cast yet @@ -167,6 +169,7 @@ private function findPropertySetters(array $propertyNames): array continue; } + $propertyName = $property->getName(); /** @var int|false $offset */ $offset = array_search($propertyName, $propertyNames, true); if (false === $offset) { @@ -183,13 +186,13 @@ private function findPropertySetters(array $propertyNames): array $cast = $this->resolveTypeCasting($type); if (null === $cast) { - throw new MappingFailed('No valid type casting was found for property `'.$propertyName.'`.'); + throw new MappingFailed('No valid type casting for `'.$type.'` was found for property `'.$propertyName.'`.'); } $propertySetters[] = new PropertySetter($property, $offset, $cast); } - $propertySetters = [...$propertySetters, ...$this->findPropertySettersByCellAttributes($propertyNames)]; + $propertySetters = [...$propertySetters, ...$this->findPropertySettersByCellAttribute($propertyNames)]; //if converters is empty it means the Serializer //was unable to detect properties to assign @@ -205,7 +208,7 @@ private function findPropertySetters(array $propertyNames): array * * @return array */ - private function findPropertySettersByCellAttributes(array $propertyNames): array + private function findPropertySettersByCellAttribute(array $propertyNames): array { $addPropertySetter = function (array $carry, ReflectionProperty|ReflectionMethod $accessor) use ($propertyNames) { $propertySetter = $this->findPropertySetter($accessor, $propertyNames); @@ -252,7 +255,7 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso if (is_int($offset)) { return match (true) { 0 > $offset => throw new MappingFailed('column integer position can only be positive or equals to 0; received `'.$offset.'`'), - [] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('column integer position can not exceed header cell count.'), + [] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('column integer position can not exceed property names count.'), default => new PropertySetter($accessor, $offset, $cast), }; } @@ -277,22 +280,17 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso */ private function resolveTypeCasting(ReflectionType $reflectionType, array $arguments = []): ?TypeCasting { - $type = ''; - if ($reflectionType instanceof ReflectionNamedType) { - $type = $reflectionType->getName(); - } + $type = (string) $this->getAccessorType($reflectionType); try { return match (Type::tryFromPropertyType($type)) { - Type::Mixed, - Type::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Iterable, - Type::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */ - Type::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Mixed, Type::Null, Type::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Iterable, Type::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */ + Type::False, Type::True, Type::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */ + Type::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */ null => null, }; } catch (Throwable $exception) { @@ -324,7 +322,7 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.'); } - $arguments = [...$cell->castArguments, ...['propertyType' => (string) $type]]; + $arguments = [...$cell->castArguments, ...['propertyType' => (string) $this->getAccessorType($type)]]; /** @var TypeCasting $cast */ $cast = new $typeCaster(...$arguments); @@ -343,4 +341,25 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod $accessor instanceof ReflectionProperty => 'No valid type casting was found for the property `'.$accessor->getName().'` must be typed.', }); } + + private function getAccessorType(?ReflectionType $type): ?string + { + return match (true) { + null === $type => null, + $type instanceof ReflectionNamedType => $type->getName(), + $type instanceof ReflectionUnionType => implode('|', array_reduce( + $type->getTypes(), + function (array $carry, ReflectionType $type): array { + $result = $this->getAccessorType($type); + + return match ('') { + $result => $carry, + default => [...$carry, $result], + }; + }, + [] + )), + default => '', + }; + } } diff --git a/src/Serializer/CastToArray.php b/src/Serializer/CastToArray.php index ad6f402e..e3debb2d 100644 --- a/src/Serializer/CastToArray.php +++ b/src/Serializer/CastToArray.php @@ -45,7 +45,7 @@ final class CastToArray implements TypeCasting public function __construct( string $propertyType, private readonly ?array $default = null, - ArrayShape|string $shape = 'list', + ArrayShape|string $shape = ArrayShape::List, private readonly string $delimiter = ',', private readonly string $enclosure = '"', private readonly int $jsonDepth = 512, diff --git a/src/Serializer/CastToBool.php b/src/Serializer/CastToBool.php index c77fa8e4..423a6990 100644 --- a/src/Serializer/CastToBool.php +++ b/src/Serializer/CastToBool.php @@ -19,17 +19,19 @@ final class CastToBool implements TypeCasting { private readonly bool $isNullable; + private readonly Type $type; public function __construct( string $propertyType, private readonly ?bool $default = null ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Bool)) { + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::Bool, Type::True, Type::False)) { throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `bool` type is required.'); } - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); + $this->type = $type; + $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); } /** @@ -37,10 +39,16 @@ public function __construct( */ public function toVariable(?string $value): ?bool { - return match(true) { + $returnValue = match(true) { null !== $value => filter_var($value, Type::Bool->filterFlag()), $this->isNullable => $this->default, default => throw new TypeCastingFailed('The `null` value can not be cast to a boolean value.'), }; + + return match (true) { + Type::True->equals($this->type) && true !== $returnValue && !$this->isNullable, + Type::False->equals($this->type) && false !== $returnValue && !$this->isNullable => throw new TypeCastingFailed('The value `'.$value.'` could not be cast to `'.$this->type->value.'`.'), + default => $returnValue, + }; } } diff --git a/src/Serializer/CastToDate.php b/src/Serializer/CastToDate.php index 279228f5..fae5a8da 100644 --- a/src/Serializer/CastToDate.php +++ b/src/Serializer/CastToDate.php @@ -42,18 +42,18 @@ public function __construct( private readonly ?string $format = null, DateTimeZone|string|null $timezone = null, ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Date)) { + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::Date)) { throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an class implementing the `'.DateTimeInterface::class.'` interface is required.'); } $class = ltrim($propertyType, '?'); - if (Type::Mixed->equals($baseType) || DateTimeInterface::class === $class) { + if (Type::Mixed->equals($type) || DateTimeInterface::class === $class) { $class = DateTimeImmutable::class; } $this->class = $class; - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); + $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); try { $this->timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone; $this->default = (null !== $default) ? $this->cast($default) : $default; diff --git a/src/Serializer/CastToEnum.php b/src/Serializer/CastToEnum.php index e8d2f09d..e9adc618 100644 --- a/src/Serializer/CastToEnum.php +++ b/src/Serializer/CastToEnum.php @@ -40,14 +40,14 @@ public function __construct( ?string $default = null, ?string $enum = null, ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Enum)) { + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::Enum)) { throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an `Enum` is required.'); } $class = ltrim($propertyType, '?'); $isNullable = str_starts_with($propertyType, '?'); - if ($baseType->equals(Type::Mixed)) { + if ($type->equals(Type::Mixed)) { if (null === $enum || !enum_exists($enum)) { throw new MappingFailed('You need to specify the enum class with a `mixed` typed property.'); } diff --git a/src/Serializer/CastToFloat.php b/src/Serializer/CastToFloat.php index d5e36861..d3282177 100644 --- a/src/Serializer/CastToFloat.php +++ b/src/Serializer/CastToFloat.php @@ -27,12 +27,12 @@ public function __construct( string $propertyType, private readonly ?float $default = null, ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Float)) { + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::Float)) { throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `float` type is required.'); } - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); + $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); } /** diff --git a/src/Serializer/CastToInt.php b/src/Serializer/CastToInt.php index 1bf971be..5fc15725 100644 --- a/src/Serializer/CastToInt.php +++ b/src/Serializer/CastToInt.php @@ -27,12 +27,12 @@ public function __construct( string $propertyType, private readonly ?int $default = null, ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Int, Type::Float)) { + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::Int, Type::Float)) { throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `int` type is required.'); } - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); + $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); } /** diff --git a/src/Serializer/CastToString.php b/src/Serializer/CastToString.php index 7be26f8e..4bf7e091 100644 --- a/src/Serializer/CastToString.php +++ b/src/Serializer/CastToString.php @@ -21,17 +21,19 @@ final class CastToString implements TypeCasting { private readonly bool $isNullable; + private readonly Type $type; public function __construct( string $propertyType, private readonly ?string $default = null ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::String)) { - throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `string` type is required.'); + $type = Type::tryFromPropertyType($propertyType); + if (null === $type || !$type->isOneOf(Type::Mixed, Type::String, Type::Null)) { + throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `string` or `null` type is required.'); } - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); + $this->type = $type; + $this->isNullable = $type->isOneOf(Type::Mixed, Type::Null) || str_starts_with($propertyType, '?'); } /** @@ -39,10 +41,15 @@ public function __construct( */ public function toVariable(?string $value): ?string { - return match(true) { + $returnedValue = match(true) { null !== $value => $value, $this->isNullable => $this->default, default => throw new TypeCastingFailed('The `null` value can not be cast to a string.'), }; + + return match (true) { + Type::Null->equals($this->type) && null !== $returnedValue => throw new TypeCastingFailed('The value `'.$value.'` could not be cast to `'.$this->type->value.'`.'), + default => $returnedValue, + }; } } diff --git a/src/Serializer/Type.php b/src/Serializer/Type.php index d9446b69..3dd3577f 100644 --- a/src/Serializer/Type.php +++ b/src/Serializer/Type.php @@ -28,6 +28,9 @@ enum Type: string { case Bool = 'bool'; + case True = 'true'; + case False = 'false'; + case Null = 'null'; case Int = 'int'; case Float = 'float'; case String = 'string'; @@ -50,11 +53,11 @@ public function isOneOf(self ...$types): bool public static function tryFromPropertyType(string $propertyType): ?self { - $type = ltrim($propertyType, '?'); - $basicType = self::tryFrom($type); + $type = ltrim($propertyType, '?'); + $enumType = self::tryFrom($type); return match (true) { - $basicType instanceof self => $basicType, + $enumType instanceof self => $enumType, enum_exists($type) => self::Enum, interface_exists($type) && DateTimeInterface::class === $type, class_exists($type) && in_array(DateTimeInterface::class, class_implements($type), true) => self::Date, @@ -65,7 +68,9 @@ class_exists($type) && in_array(DateTimeInterface::class, class_implements($type public function filterFlag(): int { return match ($this) { - self::Bool => FILTER_VALIDATE_BOOL, + self::Bool, + self::True, + self::False => FILTER_VALIDATE_BOOL, self::Int => FILTER_VALIDATE_INT, self::Float => FILTER_VALIDATE_FLOAT, default => FILTER_UNSAFE_RAW, @@ -76,6 +81,8 @@ public function isScalar(): bool { return match ($this) { self::Bool, + self::True, + self::False, self::Int, self::Float, self::String => true, diff --git a/src/SerializerTest.php b/src/SerializerTest.php index 13cdd990..67f1ba2c 100644 --- a/src/SerializerTest.php +++ b/src/SerializerTest.php @@ -41,8 +41,7 @@ public function testItConvertsAnIterableListOfRecords(): void ], ]; - $serializer = new Serializer(WeatherWithRecordAttribute::class, ['date', 'temperature', 'place']); - $results = [...$serializer->deserializeAll($records)]; + $results = [...Serializer::assignAll(WeatherWithRecordAttribute::class, $records, ['date', 'temperature', 'place'])]; self::assertCount(2, $results); foreach ($results as $result) { self::assertInstanceOf(WeatherWithRecordAttribute::class, $result); @@ -97,7 +96,7 @@ public function testItConvertsARecordsToAnObjectUsingMethods(): void self::assertSame(-1.5, $weather->getTemperature()); } - public function testMapingFailBecauseTheRecordAttributeIsMissing(): void + public function testMappingFailBecauseTheRecordAttributeIsMissing(): void { $this->expectException(MappingFailed::class); $this->expectExceptionMessage('No properties or method setters were found eligible on the class `stdClass` to be used for type casting.'); @@ -158,18 +157,20 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi public function testItWillFailForLackOfTypeCasting(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No valid type casting was found for property `observedOn`.'); + $this->expectExceptionMessage('No valid type casting for `SplFileObject` was found for property `observedOn`'); new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']); } - public function testItWillThrowIfTheClassContainsUnitiliaziedProperties(): void + public function testItWillThrowIfTheClassContainsUninitializedProperties(): void { $this->expectException(MappingFailed::class); $this->expectExceptionMessage('No valid type casting was found for the property `annee` must be typed.'); - $serializer = new Serializer(InvalidObjectWithUninitializedProperty::class, ['prenoms', 'nombre', 'sexe', 'annee']); - $serializer->deserialize(['prenoms' => 'John', 'nombre' => 42, 'sexe' => 'M', 'annee' => '2018']); + Serializer::assign( + InvalidObjectWithUninitializedProperty::class, + ['prenoms' => 'John', 'nombre' => '42', 'sexe' => 'M', 'annee' => '2018'] + ); } } @@ -267,8 +268,7 @@ public function getObservedOn(): DateTime final class InvalidWeatherAttributeUsage { public function __construct( - /* @phpstan-ignore-next-line */ - #[Cell(offset:'temperature'), Cell(offset:'date')] + #[Cell(offset:'temperature'), Cell(offset:'date')] /* @phpstan-ignore-line */ public readonly float $temperature, #[Cell(offset:2, cast: CastToEnum::class)] public readonly Place $place,