diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md index e5317537..5afeddd0 100644 --- a/docs/9.0/reader/record-mapping.md +++ b/docs/9.0/reader/record-mapping.md @@ -167,7 +167,7 @@ The attribute can take up to three (3) arguments which are all optional: - The `cast` argument which accept the name of a class implementing the `TypeCasting` interface and responsible for type casting the record value. If not present, the mechanism will try to resolve the typecasting based on the propery or method argument type. - The `castArguments` argument enables controlling typecasting by providing extra arguments to the `TypeCasting` class constructor. The argument expects an associative array and relies on named arguments to inject its value to the `TypeCasting` implementing class constructor. -

The propertyType key can not be used with the castArguments as it is a reserved argument used by the TypeCasting class.

+

The reflectionProperty key can not be used with the castArguments as it is a reserved argument used by the TypeCasting class.

In any case, if type casting fails, an exception will be thrown. @@ -329,7 +329,7 @@ use League\Csv\Serializer; #[Serializer\Cell( offset: 'amount', - cast: App\Domain\CastToMoney::class, + cast: App\Domain\CastToNaira::class, castArguments: ['default' => 100_00] )] private ?Money $naira; @@ -340,8 +340,8 @@ To allow your object to cast the cell value to your liking it needs to implement To do so, you must define a `toVariable` method that will return the correct value once converted.

Of note The class constructor method must take the property type value as -one of its argument with the name $propertyType. This means you can not use the -propertyType as a possible key of the associative array given to castArguments

+one of its argument with the name $reflectionProperty. This means you can not use the +reflectionProperty as a possible key of the associative array given to castArguments

```php use App\Domain\Money; @@ -352,44 +352,49 @@ use League\Csv\Serializer\TypeCastingFailed; /** * @implements TypeCasting */ -final class CastToMoney implements TypeCasting +final class CastToNaira implements TypeCasting { - private readonly ?Money $default; + private readonly bool $isNullable; + private readonly Money $default; public function __construct( - string $propertyType, //always required and given by the Serializer implementation - int $default = null, + ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Serializer + ?int $default = null ) { - $this->isNullable = str_starts_with($type, '?'); - - //the type casting class must only work with the declared type - //Here the TypeCasting object only cares about converting - //data into a Money instance. - if (Money::class !== ltrim($propertyType, '?')) { - throw new MappingFailed('The class '. self::class . ' can only work with `' . Money::class . '` typed property.'); - } - if (null !== $default) { - try { - $this->default = $this->toVariable($default); - } catch (TypeCastingFailed $exception) { - throw new MappingFailed('Unable to cast the default value `'.$value.'` to a `'.Money::class.'`.', 0, $exception); - } + $default = Money::fromNaira($default); } + $this->default = $default; + + // To be more strict during conversion you SHOULD handle the $reflectionProperty argument. + // The argument gives you access to all the information about the property. + // it allows validating that the argument does support your casting + // it allows adding support to union, intersection or unnamed type + // it tells whether the property/argument is nullable or not + + $reflectionType = $reflectionProperty->getType(); + if (!$reflectionType instanceof ReflectionNamedType || !in_array($reflectionType->getName(), [Money::class, 'mixed'], true)) { + throw new MappingFailed(match (true) { + $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` is not typed with the '.Money::class.' class or with `mixed`.', + $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` is not typed with the '.Money::class.' class or with `mixed`.', + }); + } + $this->isNullable = $reflectionType->allowsNull(); } public function toVariable(?string $value): ?Money { try { - // if the property is declared as nullable we exist early - if (null === $value && $this->isNullable) { - return $this->default; - } - - return Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)); + return match (true) { + $this->isNullable && null === $value => $this->default, + default => Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)), + }; } catch (Throwable $exception) { throw new TypeCastingFailed('Unable to cast the given data `'.$value.'` to a `'.Money::class.'`.', 0, $exception); } } } ``` + +

While the built-in TypeCasting classes do not support Intersection Type, your own +implementing class can support them via inspection of the $reflectionProperty argument.

diff --git a/src/Serializer.php b/src/Serializer.php index 13d5cc34..a6ab0737 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -31,15 +31,16 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; -use ReflectionNamedType; +use ReflectionParameter; use ReflectionProperty; -use ReflectionType; -use ReflectionUnionType; use Throwable; +use function array_key_exists; use function array_reduce; use function array_search; use function array_values; +use function count; +use function in_array; use function is_int; final class Serializer @@ -184,15 +185,9 @@ private function findPropertySetters(array $propertyNames): array private function autoDiscoverPropertySetter(ReflectionProperty $property, int $offset): PropertySetter { - $propertyName = $property->getName(); - $type = $property->getType(); - if (null === $type) { - throw new MappingFailed('The property `'.$propertyName.'` must be typed.'); - } - - $cast = $this->resolveTypeCasting($type); + $cast = $this->resolveTypeCasting($property); if (null === $cast) { - throw new MappingFailed('No valid type casting for `'.$type.' $'.$propertyName.'`.'); + throw new MappingFailed('No built-in `'.TypeCasting::class.'` class can handle `$'.$property->getName().'` type.'); } return new PropertySetter($property, $offset, $cast); @@ -273,19 +268,22 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso * * @throws MappingFailed If the arguments do not match the expected TypeCasting class constructor signature */ - private function resolveTypeCasting(ReflectionType $reflectionType, array $arguments = []): ?TypeCasting + private function resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): ?TypeCasting { - $type = (string) $this->getAccessorType($reflectionType); + $reflectionType = $reflectionProperty->getType(); + if (null === $reflectionType) { + throw new MappingFailed('The property `'.$reflectionProperty->getName().'` must be typed.'); + } try { - return match (Type::tryFromPropertyType($type)) { - 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 */ + return match (Type::tryFromReflectionType($reflectionType)) { + Type::Mixed, Type::Null, Type::String => new CastToString($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::Iterable, Type::Array => new CastToArray($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::False, Type::True, Type::Bool => new CastToBool($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::Float => new CastToFloat($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::Int => new CastToInt($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::Date => new CastToDate($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ + Type::Enum => new CastToEnum($reflectionProperty, ...$arguments), /* @phpstan-ignore-line */ null => null, }; } catch (Throwable $exception) { @@ -302,13 +300,13 @@ private function resolveTypeCasting(ReflectionType $reflectionType, array $argum */ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod $accessor): TypeCasting { - if (array_key_exists('propertyType', $cell->castArguments)) { + if (array_key_exists('reflectionProperty', $cell->castArguments)) { throw new MappingFailed('The key `propertyType` can not be used with `castArguments`.'); } - $type = match (true) { - $accessor instanceof ReflectionMethod => $accessor->getParameters()[0]->getType(), - $accessor instanceof ReflectionProperty => $accessor->getType(), + $reflectionProperty = match (true) { + $accessor instanceof ReflectionMethod => $accessor->getParameters()[0], + $accessor instanceof ReflectionProperty => $accessor, }; $typeCaster = $cell->cast; @@ -317,44 +315,23 @@ 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) $this->getAccessorType($type)]]; + $arguments = [...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]]; /** @var TypeCasting $cast */ $cast = new $typeCaster(...$arguments); return $cast; } - if (null === $type) { + if (null === $reflectionProperty->getType()) { throw new MappingFailed(match (true) { - $accessor instanceof ReflectionMethod => 'The setter method argument `'.$accessor->getParameters()[0]->getName().'` must be typed.', - $accessor instanceof ReflectionProperty => 'The property `'.$accessor->getName().'` must be typed.', + $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed.', + $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed.', }); } - return $this->resolveTypeCasting($type, $cell->castArguments) ?? throw new MappingFailed(match (true) { - $accessor instanceof ReflectionMethod => 'No valid type casting was found for the setter method argument `'.$accessor->getParameters()[0]->getName().'` must be typed.', - $accessor instanceof ReflectionProperty => 'No valid type casting was found for the property `'.$accessor->getName().'` must be typed.', + return $this->resolveTypeCasting($reflectionProperty, $cell->castArguments) ?? throw new MappingFailed(match (true) { + $reflectionProperty instanceof ReflectionParameter => 'No valid type casting was found for the setter method argument `'.$reflectionProperty->getName().'`; it must be typed.', + $reflectionProperty instanceof ReflectionProperty => 'No valid type casting was found for the property `'.$reflectionProperty->getName().'`; it 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 e3debb2d..fa8d9a2d 100644 --- a/src/Serializer/CastToArray.php +++ b/src/Serializer/CastToArray.php @@ -15,12 +15,13 @@ use JsonException; +use ReflectionParameter; +use ReflectionProperty; + use function explode; use function is_array; use function json_decode; -use function ltrim; use function str_getcsv; -use function str_starts_with; use function strlen; use const FILTER_REQUIRE_ARRAY; @@ -31,7 +32,7 @@ */ final class CastToArray implements TypeCasting { - private readonly string $class; + private readonly Type $type; private readonly bool $isNullable; private readonly int $filterFlag; private readonly ArrayShape $shape; @@ -43,7 +44,7 @@ final class CastToArray implements TypeCasting * @throws MappingFailed */ public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, private readonly ?array $default = null, ArrayShape|string $shape = ArrayShape::List, private readonly string $delimiter = ',', @@ -52,14 +53,7 @@ public function __construct( private readonly int $jsonFlags = 0, Type|string $type = Type::String, ) { - $baseType = Type::tryFromPropertyType($propertyType); - if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Array, Type::Iterable)) { - throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an `array` or an `iterable` structure is required.'); - } - - $this->class = ltrim($propertyType, '?'); - $this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?'); - + [$this->type, $this->isNullable] = $this->init($reflectionProperty); if (!$shape instanceof ArrayShape) { $shape = ArrayShape::tryFrom($shape) ?? throw new MappingFailed('Unable to resolve the array shape; Verify your cast arguments.'); } @@ -79,7 +73,7 @@ public function toVariable(?string $value): ?array if (null === $value) { return match (true) { $this->isNullable, - Type::tryFrom($this->class)?->equals(Type::Mixed) => $this->default, + Type::Mixed->equals($this->type) => $this->default, default => throw new TypeCastingFailed('The `null` value can not be cast to an `array`; the property type is not nullable.'), }; } @@ -125,4 +119,28 @@ private function resolveFilterFlag(Type|string $type): int default => $type->filterFlag(), }; } + + /** + * @return array{0:Type, 1:bool} + */ + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Array, Type::Iterable)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed` or `bool` type is required.'); + } + + return [$type[0], $isNullable]; + } } diff --git a/src/Serializer/CastToArrayTest.php b/src/Serializer/CastToArrayTest.php index 5e61b12a..c0d4771c 100644 --- a/src/Serializer/CastToArrayTest.php +++ b/src/Serializer/CastToArrayTest.php @@ -13,8 +13,12 @@ namespace League\Csv\Serializer; +use Countable; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; +use Traversable; final class CastToArrayTest extends TestCase { @@ -25,7 +29,7 @@ final class CastToArrayTest extends TestCase #[DataProvider('providesValidStringForArray')] public function testItCanConvertToArraygWithoutArguments(string $shape, string $type, string $input, array $expected): void { - self::assertSame($expected, (new CastToArray(propertyType: '?iterable', shape:$shape, type:$type))->toVariable($input)); + self::assertSame($expected, (new CastToArray(reflectionProperty: new ReflectionProperty(ArrayClass::class, 'nullableIterable'), shape:$shape, type:$type))->toVariable($input)); } public static function providesValidStringForArray(): iterable @@ -91,14 +95,14 @@ public function testItFailsToCastAnUnsupportedType(): void { $this->expectException(MappingFailed::class); - new CastToArray('?int'); + new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableInt')); } public function testItFailsToCastInvalidJson(): void { $this->expectException(TypeCastingFailed::class); - (new CastToArray('?iterable', null, 'json'))->toVariable('{"json":toto}'); + (new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableIterable'), null, 'json'))->toVariable('{"json":toto}'); } public function testItCastNullableJsonUsingTheDefaultValue(): void @@ -107,7 +111,36 @@ public function testItCastNullableJsonUsingTheDefaultValue(): void self::assertSame( $defaultValue, - (new CastToArray('?iterable', $defaultValue, 'json'))->toVariable(null) + (new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableIterable'), $defaultValue, 'json'))->toVariable(null) ); } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(ArrayClass::class, $propertyName); + + new CastToArray($reflectionProperty); + } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableInt'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; + } +} + +class ArrayClass +{ + public ?iterable $nullableIterable; + public ?int $nullableInt; + public array $array; + public DateTimeInterface|array|null $unionType; + public DateTimeInterface|string $invalidUnionType; + public Countable&Traversable $intersectionType; } diff --git a/src/Serializer/CastToBool.php b/src/Serializer/CastToBool.php index 423a6990..74ceb335 100644 --- a/src/Serializer/CastToBool.php +++ b/src/Serializer/CastToBool.php @@ -13,8 +13,10 @@ namespace League\Csv\Serializer; +use ReflectionParameter; +use ReflectionProperty; + use function filter_var; -use function str_starts_with; final class CastToBool implements TypeCasting { @@ -22,16 +24,10 @@ final class CastToBool implements TypeCasting private readonly Type $type; public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, private readonly ?bool $default = null ) { - $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->type = $type; - $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); + [$this->type, $this->isNullable] = $this->init($reflectionProperty); } /** @@ -51,4 +47,28 @@ public function toVariable(?string $value): ?bool default => $returnValue, }; } + + /** + * @return array{0:Type, 1:bool} + */ + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Bool, Type::True, Type::False)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed` or `bool` type is required.'); + } + + return [$type[0], $isNullable]; + } } diff --git a/src/Serializer/CastToBoolTest.php b/src/Serializer/CastToBoolTest.php index ee0dd529..255e1f44 100644 --- a/src/Serializer/CastToBoolTest.php +++ b/src/Serializer/CastToBoolTest.php @@ -13,8 +13,12 @@ namespace League\Csv\Serializer; +use Countable; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; +use Traversable; final class CastToBoolTest extends TestCase { @@ -22,12 +26,12 @@ public function testItFailsWithNonSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToBool('int'); + new CastToBool(new ReflectionProperty(BoolClass::class, 'string')); } #[DataProvider('providesValidInputValue')] public function testItCanConvertStringToBool( - string $propertyType, + ReflectionProperty $propertyType, ?bool $default, ?string $input, ?bool $expected @@ -38,66 +42,111 @@ public function testItCanConvertStringToBool( public static function providesValidInputValue(): iterable { yield 'with a true type - true' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => 'true', 'expected' => true, ]; yield 'with a true type - yes' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => 'yes', 'expected' => true, ]; yield 'with a true type - 1' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => '1', 'expected' => true, ]; yield 'with a false type - false' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => 'f', 'expected' => false, ]; yield 'with a false type - no' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => 'no', 'expected' => false, ]; yield 'with a false type - 0' => [ - 'propertyType' => 'bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'), 'default' => null, 'input' => '0', 'expected' => false, ]; yield 'with a null type' => [ - 'propertyType' => '?bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'nullableBool'), 'default' => null, 'input' => null, 'expected' => null, ]; yield 'with another default type' => [ - 'propertyType' => '?bool', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'nullableBool'), 'default' => false, 'input' => null, 'expected' => false, ]; yield 'with the mixed type' => [ - 'propertyType' => 'mixed', + 'propertyType' => new ReflectionProperty(BoolClass::class, 'mixed'), 'default' => null, 'input' => 'YES', 'expected' => true, ]; + + yield 'with union type' => [ + 'reflectionProperty' => new ReflectionProperty(BoolClass::class, 'unionType'), + 'default' => false, + 'input' => 'yes', + 'expected' => true, + ]; + + yield 'with nullable union type' => [ + 'reflectionProperty' => new ReflectionProperty(BoolClass::class, 'unionType'), + 'default' => false, + 'input' => null, + 'expected' => false, + ]; + } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(BoolClass::class, $propertyName); + + new CastToBool($reflectionProperty); } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableInt'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; + } +} + +class BoolClass +{ + public ?bool $nullableBool; + public bool $boolean; + public mixed $mixed; + public string $string; + public ?int $nullableInt; + public DateTimeInterface|bool|null $unionType; + public DateTimeInterface|string $invalidUnionType; + public Countable&Traversable $intersectionType; } diff --git a/src/Serializer/CastToDate.php b/src/Serializer/CastToDate.php index fae5a8da..fcd44999 100644 --- a/src/Serializer/CastToDate.php +++ b/src/Serializer/CastToDate.php @@ -17,11 +17,12 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; use Throwable; use function is_string; -use function ltrim; -use function str_starts_with; /** * @implements TypeCasting @@ -37,23 +38,18 @@ final class CastToDate implements TypeCasting * @throws MappingFailed */ public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, ?string $default = null, private readonly ?string $format = null, DateTimeZone|string|null $timezone = null, ) { - $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, '?'); + [$type, $reflection, $this->isNullable] = $this->init($reflectionProperty); + $class = $reflection->getName(); if (Type::Mixed->equals($type) || DateTimeInterface::class === $class) { $class = DateTimeImmutable::class; } $this->class = $class; - $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; @@ -96,4 +92,30 @@ private function cast(string $value): DateTimeImmutable|DateTime return $date; } + + /** + * @throws MappingFailed + * + * @return array{0:Type, 1:ReflectionNamedType, 2:bool} + */ + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Date)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; a class implementing the `'.DateTimeInterface::class.'` interface or `mixed` is required.'); + } + + return [...$type, $isNullable]; + } } diff --git a/src/Serializer/CastToDateTest.php b/src/Serializer/CastToDateTest.php index e0a54acd..c4228be2 100644 --- a/src/Serializer/CastToDateTest.php +++ b/src/Serializer/CastToDateTest.php @@ -13,17 +13,20 @@ namespace League\Csv\Serializer; +use Countable; use DateTime; use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; final class CastToDateTest extends TestCase { public function testItCanConvertADateWithoutArguments(): void { - $cast = new CastToDate(DateTime::class); + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'dateTime')); $date = $cast->toVariable('2023-10-30'); self::assertInstanceOf(DateTime::class, $date); @@ -32,7 +35,7 @@ public function testItCanConvertADateWithoutArguments(): void public function testItCanConvertADateWithASpecificFormat(): void { - $cast = new CastToDate(DateTimeInterface::class, null, '!Y-m-d', 'Africa/Kinshasa'); + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'dateTimeInterface'), null, '!Y-m-d', 'Africa/Kinshasa'); $date = $cast->toVariable('2023-10-30'); self::assertInstanceOf(DateTimeImmutable::class, $date); @@ -42,7 +45,7 @@ public function testItCanConvertADateWithASpecificFormat(): void public function testItCanConvertAnObjectImplementingTheDateTimeInterface(): void { - $cast = new CastToDate(MyDate::class); + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'myDate')); $date = $cast->toVariable('2023-10-30'); self::assertInstanceOf(MyDate::class, $date); @@ -53,27 +56,85 @@ public function testItCShouldThrowIfNoConversionIsPossible(): void { $this->expectException(TypeCastingFailed::class); - (new CastToDate(DateTimeInterface::class))->toVariable('foobar'); + (new CastToDate(new ReflectionProperty(DateClass::class, 'dateTimeInterface')))->toVariable('DateClass'); } + + + public function testItCShouldThrowIfTheOptionsAreInvalid(): void + { + $this->expectException(MappingFailed::class); + + new CastToDate( + new ReflectionProperty(DateClass::class, 'dateTimeInterface'), + '2023-11-11', + 'Y-m-d', + 'Europe\Blan' + ); + } + + public function testItReturnsNullWhenTheVariableIsNullable(): void { - $cast = new CastToDate('?'.DateTime::class); + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'nullableDateTime')); self::assertNull($cast->toVariable(null)); } public function testItCanConvertADateWithADefaultValue(): void { - $cast = new CastToDate('?'.DateTimeInterface::class, '2023-01-01', '!Y-m-d', 'Africa/Kinshasa'); + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'nullableDateTimeInterface'), '2023-01-01', '!Y-m-d', 'Africa/Kinshasa'); $date = $cast->toVariable(null); self::assertInstanceOf(DateTimeImmutable::class, $date); self::assertSame('01-01-2023 00:00:00', $date->format('d-m-Y H:i:s')); self::assertEquals(new DateTimeZone('Africa/Kinshasa'), $date->getTimezone()); } + + public function testItReturnsTheValueWithUnionType(): void + { + $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'unionType'), '2023-01-01'); + + self::assertEquals(new DateTimeImmutable('2023-01-01'), $cast->toVariable(null)); + } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(DateClass::class, $propertyName); + + new CastToDate($reflectionProperty); + } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableBool'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; + } } class MyDate extends DateTimeImmutable { } + +class DateClass +{ + public DateTimeImmutable $dateTimeImmutable; + public DateTime $dateTime; + public DateTimeInterface $dateTimeInterface; + public MyDate $myDate; + public ?DateTimeImmutable $nullableDateTimeImmutable; + public ?DateTime $nullableDateTime; + public ?DateTimeInterface $nullableDateTimeInterface; + public ?MyDate $nullableMyDate; + public mixed $mixed; + public ?bool $nullableBool; + public DateTimeInterface|string|null $unionType; + public float|int $invalidUnionType; + public Countable&DateTimeInterface $intersectionType; +} diff --git a/src/Serializer/CastToEnum.php b/src/Serializer/CastToEnum.php index e9adc618..12b92835 100644 --- a/src/Serializer/CastToEnum.php +++ b/src/Serializer/CastToEnum.php @@ -15,17 +15,18 @@ use BackedEnum; use ReflectionEnum; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; use Throwable; use UnitEnum; -use function ltrim; -use function str_starts_with; - /** * @implements TypeCasting */ class CastToEnum implements TypeCasting { + /** @var class-string */ private readonly string $class; private readonly bool $isNullable; private readonly BackedEnum|UnitEnum|null $default; @@ -36,27 +37,21 @@ class CastToEnum implements TypeCasting * @throws MappingFailed */ public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, ?string $default = null, ?string $enum = null, ) { - $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 ($type->equals(Type::Mixed)) { + [$type, $reflection, $this->isNullable] = $this->init($reflectionProperty); + /** @var class-string $class */ + $class = $reflection->getName(); + if (Type::Mixed->equals($type)) { if (null === $enum || !enum_exists($enum)) { - throw new MappingFailed('You need to specify the enum class with a `mixed` typed property.'); + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is `mixed`; you must specify the Enum class via the `$enum` argument.'); } $class = $enum; - $isNullable = true; } $this->class = $class; - $this->isNullable = $isNullable; try { $this->default = (null !== $default) ? $this->cast($default) : $default; @@ -95,4 +90,28 @@ private function cast(string $value): BackedEnum|UnitEnum throw new TypeCastingFailed(message: 'Unable to cast to `'.$this->class.'` the value `'.$value.'`.', previous: $exception); } } + + /** + * @return array{0:Type, 1:ReflectionNamedType, 2:bool} + */ + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Enum)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; an Enum or `mixed` is required.'); + } + + return [...$type, $isNullable]; + } } diff --git a/src/Serializer/CastToEnumTest.php b/src/Serializer/CastToEnumTest.php index c32c0948..55436669 100644 --- a/src/Serializer/CastToEnumTest.php +++ b/src/Serializer/CastToEnumTest.php @@ -13,13 +13,18 @@ namespace League\Csv\Serializer; +use Countable; +use DateTimeInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; +use Traversable; final class CastToEnumTest extends TestCase { public function testItCanConvertAStringBackedEnum(): void { - $cast = new CastToEnum(Colour::class); + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')); $orange = $cast->toVariable('orange'); self::assertInstanceOf(Colour::class, $orange); @@ -29,7 +34,7 @@ public function testItCanConvertAStringBackedEnum(): void public function testItCanConvertAIntegerBackedEnum(): void { - $cast = new CastToEnum(DayOfTheWeek::class); + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'dayOfTheWeek')); $monday = $cast->toVariable('1'); self::assertInstanceOf(DayOfTheWeek::class, $monday); @@ -39,7 +44,7 @@ public function testItCanConvertAIntegerBackedEnum(): void public function testItCanConvertAUnitEnum(): void { - $cast = new CastToEnum(Currency::class); + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency')); $naira = $cast->toVariable('Naira'); self::assertInstanceOf(Currency::class, $naira); @@ -48,14 +53,14 @@ public function testItCanConvertAUnitEnum(): void public function testItReturnsNullWhenTheVariableIsNullable(): void { - $cast = new CastToEnum('?'.Currency::class); + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency')); self::assertNull($cast->toVariable(null)); } public function testItReturnsTheDefaultValueWhenTheVariableIsNullable(): void { - $cast = new CastToEnum('?'.Currency::class, 'Naira'); + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency'), 'Naira'); self::assertSame(Currency::Naira, $cast->toVariable(null)); } @@ -64,14 +69,40 @@ public function testThrowsOnNullIfTheVariableIsNotNullable(): void { $this->expectException(TypeCastingFailed::class); - (new CastToEnum(Currency::class))->toVariable(null); + (new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency')))->toVariable(null); } public function testThrowsIfTheValueIsNotRecognizedByTheEnum(): void { $this->expectException(TypeCastingFailed::class); - (new CastToEnum(Colour::class))->toVariable('green'); + (new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')))->toVariable('green'); + } + + public function testItReturnsTheDefaultValueWithUnionType(): void + { + $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'unionType'), 'orange'); + + self::assertSame(Colour::Violet, $cast->toVariable('violet')); + } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(EnumClass::class, $propertyName); + + new CastToEnum($reflectionProperty); + } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableBool'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; } } @@ -93,3 +124,15 @@ 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/CastToFloat.php b/src/Serializer/CastToFloat.php index d3282177..998f7ddf 100644 --- a/src/Serializer/CastToFloat.php +++ b/src/Serializer/CastToFloat.php @@ -13,8 +13,10 @@ namespace League\Csv\Serializer; +use ReflectionParameter; +use ReflectionProperty; + use function filter_var; -use function str_starts_with; /** * @implements TypeCasting @@ -24,15 +26,10 @@ final class CastToFloat implements TypeCasting private readonly bool $isNullable; public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, private readonly ?float $default = null, ) { - $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 = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); + $this->isNullable = $this->init($reflectionProperty); } /** @@ -54,4 +51,25 @@ public function toVariable(?string $value): ?float default => $float, }; } + + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): bool + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Float)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `float` or `null` type is required.'); + } + + return $isNullable; + } } diff --git a/src/Serializer/CastToFloatTest.php b/src/Serializer/CastToFloatTest.php index 917b655a..799fac79 100644 --- a/src/Serializer/CastToFloatTest.php +++ b/src/Serializer/CastToFloatTest.php @@ -13,8 +13,10 @@ namespace League\Csv\Serializer; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; final class CastToFloatTest extends TestCase { @@ -22,57 +24,81 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToFloat('string'); + new CastToFloat(new ReflectionProperty(FloatClass::class, 'string')); } #[DataProvider('providesValidStringForInt')] - public function testItCanConvertToArraygWithoutArguments(string $prototype, ?string $input, ?float $default, ?float $expected): void + public function testItCanConvertToArraygWithoutArguments(ReflectionProperty $prototype, ?string $input, ?float $default, ?float $expected): void { - self::assertSame($expected, (new CastToFloat(propertyType: $prototype, default:$default))->toVariable($input)); + self::assertSame($expected, (new CastToFloat(reflectionProperty: $prototype, default:$default))->toVariable($input)); } public static function providesValidStringForInt(): iterable { yield 'positive integer' => [ - 'prototype' => '?float', + 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), 'input' => '1', 'default' => null, 'expected' => 1.0, ]; yield 'zero' => [ - 'prototype' => '?float', + 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), 'input' => '0', 'default' => null, 'expected' => 0.0, ]; yield 'negative integer' => [ - 'prototype' => '?float', + 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), 'input' => '-10', 'default' => null, 'expected' => -10.0, ]; yield 'null value' => [ - 'prototype' => '?float', + 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), 'input' => null, 'default' => null, 'expected' => null, ]; yield 'null value with default value' => [ - 'prototype' => '?float', + 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'), 'input' => null, 'default' => 10, 'expected' => 10.0, ]; + + yield 'with union type' => [ + 'reflectionProperty' => new ReflectionProperty(FloatClass::class, 'unionType'), + 'input' => '23', + 'default' => 42.0, + 'expected' => 23.0, + ]; + + yield 'with nullable union type' => [ + 'reflectionProperty' => new ReflectionProperty(FloatClass::class, 'unionType'), + 'input' => null, + 'default' => 42.0, + 'expected' => 42.0, + ]; } public function testItFailsToConvertNonIntegerString(): void { $this->expectException(TypeCastingFailed::class); - (new CastToFloat(propertyType: '?float'))->toVariable('00foobar'); + (new CastToFloat(new ReflectionProperty(FloatClass::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/CastToInTest.php b/src/Serializer/CastToInTest.php index 6d1087ec..797d64d1 100644 --- a/src/Serializer/CastToInTest.php +++ b/src/Serializer/CastToInTest.php @@ -13,8 +13,12 @@ namespace League\Csv\Serializer; +use Countable; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; +use Traversable; final class CastToInTest extends TestCase { @@ -22,71 +26,118 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToInt('string'); + new CastToInt(new ReflectionProperty(IntClass::class, 'string')); } #[DataProvider('providesValidStringForInt')] - public function testItCanConvertToArraygWithoutArguments(string $prototype, ?string $input, ?int $default, ?int $expected): void + public function testItCanConvertToArraygWithoutArguments(ReflectionProperty $reflectionProperty, ?string $input, ?int $default, ?int $expected): void { - self::assertSame($expected, (new CastToInt(propertyType: $prototype, default:$default))->toVariable($input)); + self::assertSame($expected, (new CastToInt(reflectionProperty: $reflectionProperty, default:$default))->toVariable($input)); } public static function providesValidStringForInt(): iterable { yield 'positive integer' => [ - 'prototype' => '?int', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'), 'input' => '1', 'default' => null, 'expected' => 1, ]; yield 'zero' => [ - 'prototype' => '?int', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'), 'input' => '0', 'default' => null, 'expected' => 0, ]; yield 'negative integer' => [ - 'prototype' => '?int', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'), 'input' => '-10', 'default' => null, 'expected' => -10, ]; yield 'null value' => [ - 'prototype' => '?int', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'), 'input' => null, 'default' => null, 'expected' => null, ]; yield 'null value with default value' => [ - 'prototype' => '?int', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'), 'input' => null, 'default' => 10, 'expected' => 10, ]; yield 'conversion of the null value with a nullable float' => [ - 'prototype' => '?float', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableFloat'), 'input' => null, 'default' => 10, 'expected' => 10, ]; yield 'conversion with float' => [ - 'prototype' => '?float', + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableFloat'), 'input' => '1', 'default' => null, 'expected' => 1, ]; + + yield 'with union type' => [ + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'unionType'), + 'input' => '23', + 'default' => 42, + 'expected' => 23, + ]; + + yield 'with nullable union type' => [ + 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'unionType'), + 'input' => null, + 'default' => 42, + 'expected' => 42, + ]; } public function testItFailsToConvertNonIntegerString(): void { $this->expectException(TypeCastingFailed::class); - (new CastToInt(propertyType: '?int'))->toVariable('00foobar'); + (new CastToInt(reflectionProperty: new ReflectionProperty(IntClass::class, 'nullableInt')))->toVariable('00foobar'); + } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(IntClass::class, $propertyName); + + new CastToInt($reflectionProperty); } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableBool'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; + } +} + +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/CastToInt.php b/src/Serializer/CastToInt.php index 5fc15725..27cca588 100644 --- a/src/Serializer/CastToInt.php +++ b/src/Serializer/CastToInt.php @@ -13,8 +13,10 @@ namespace League\Csv\Serializer; +use ReflectionParameter; +use ReflectionProperty; + use function filter_var; -use function str_starts_with; /** * @implements TypeCasting @@ -24,15 +26,10 @@ final class CastToInt implements TypeCasting private readonly bool $isNullable; public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, private readonly ?int $default = null, ) { - $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 = Type::Mixed->equals($type) || str_starts_with($propertyType, '?'); + $this->isNullable = $this->init($reflectionProperty); } /** @@ -54,4 +51,25 @@ public function toVariable(?string $value): ?int default => $int, }; } + + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): bool + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Int, Type::Float)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `int`, `float` or `null` type is required.'); + } + + return $isNullable; + } } diff --git a/src/Serializer/CastToString.php b/src/Serializer/CastToString.php index 4bf7e091..b9b28c40 100644 --- a/src/Serializer/CastToString.php +++ b/src/Serializer/CastToString.php @@ -13,7 +13,8 @@ namespace League\Csv\Serializer; -use function str_starts_with; +use ReflectionParameter; +use ReflectionProperty; /** * @implements TypeCasting @@ -24,16 +25,10 @@ final class CastToString implements TypeCasting private readonly Type $type; public function __construct( - string $propertyType, + ReflectionProperty|ReflectionParameter $reflectionProperty, private readonly ?string $default = null ) { - $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->type = $type; - $this->isNullable = $type->isOneOf(Type::Mixed, Type::Null) || str_starts_with($propertyType, '?'); + [$this->type, $this->isNullable] = $this->init($reflectionProperty); } /** @@ -52,4 +47,28 @@ public function toVariable(?string $value): ?string default => $returnedValue, }; } + + /** + * @return array{0:Type, 1:bool} + */ + private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (Type::list($reflectionProperty) as $found) { + if (!$isNullable && $found[1]->allowsNull()) { + $isNullable = true; + } + + if (null === $type && $found[0]->isOneOf(Type::String, Type::Mixed, Type::Null)) { + $type = $found; + } + } + + if (null === $type) { + throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed`, `string` or `null` type is required.'); + } + + return [$type[0], $isNullable]; + } } diff --git a/src/Serializer/CastToStringTest.php b/src/Serializer/CastToStringTest.php index c391b509..b9e1b938 100644 --- a/src/Serializer/CastToStringTest.php +++ b/src/Serializer/CastToStringTest.php @@ -13,8 +13,12 @@ namespace League\Csv\Serializer; +use Countable; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; +use Traversable; final class CastToStringTest extends TestCase { @@ -22,47 +26,98 @@ public function testItFailsWithNonSupportedType(): void { $this->expectException(MappingFailed::class); - new CastToString('int'); + new CastToString(new ReflectionProperty(StringClass::class, 'int')); } #[DataProvider('providesValidInputValue')] public function testItCanConvertStringToBool( - string $propertyType, + ReflectionProperty $reflectionProperty, ?string $default, ?string $input, ?string $expected ): void { - self::assertSame($expected, (new CastToString($propertyType, $default))->toVariable($input)); + self::assertSame($expected, (new CastToString($reflectionProperty, $default))->toVariable($input)); } public static function providesValidInputValue(): iterable { yield 'with a string/nullable type' => [ - 'propertyType' => '?string', + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), 'default' => null, 'input' => 'true', 'expected' => 'true', ]; yield 'with a string type' => [ - 'propertyType' => 'string', + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'string'), 'default' => null, 'input' => 'yes', 'expected' => 'yes', ]; yield 'with a nullable string type and the null value' => [ - 'propertyType' => '?string', + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), 'default' => null, 'input' => null, 'expected' => null, ]; yield 'with a nullable string type and a non null default value' => [ - 'propertyType' => '?string', + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'), + 'default' => 'foo', + 'input' => null, + 'expected' => 'foo', + ]; + + yield 'with union type' => [ + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'), + 'default' => 'foo', + 'input' => 'tata', + 'expected' => 'tata', + ]; + + yield 'with nullable union type' => [ + 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'), 'default' => 'foo', 'input' => null, 'expected' => 'foo', ]; } + + #[DataProvider('invalidPropertyName')] + public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void + { + $this->expectException(MappingFailed::class); + + $reflectionProperty = new ReflectionProperty(StringClass::class, $propertyName); + + new CastToString($reflectionProperty); + } + + public static function invalidPropertyName(): iterable + { + return [ + 'named type not supported' => ['propertyName' => 'nullableBool'], + 'union type not supported' => ['propertyName' => 'invalidUnionType'], + 'intersection type not supported' => ['propertyName' => 'intersectionType'], + ]; + } +} + +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/Type.php b/src/Serializer/Type.php index 3dd3577f..8908a372 100644 --- a/src/Serializer/Type.php +++ b/src/Serializer/Type.php @@ -13,12 +13,17 @@ use DateTimeInterface; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; + use function class_exists; use function class_implements; use function enum_exists; use function in_array; use function interface_exists; -use function ltrim; use const FILTER_UNSAFE_RAW; use const FILTER_VALIDATE_BOOL; @@ -51,20 +56,6 @@ public function isOneOf(self ...$types): bool return in_array($this, $types, true); } - public static function tryFromPropertyType(string $propertyType): ?self - { - $type = ltrim($propertyType, '?'); - $enumType = self::tryFrom($type); - - return match (true) { - $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, - default => null, - }; - } - public function filterFlag(): int { return match ($this) { @@ -89,4 +80,88 @@ public function isScalar(): bool default => false, }; } + + /** + * @return list + */ + public static function list(ReflectionParameter|ReflectionProperty $reflectionProperty): array + { + $reflectionType = $reflectionProperty->getType() ?? throw new MappingFailed(match (true) { + $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed.', + $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed.', + }); + + return self::typeList($reflectionType); + } + + /** + * @return list + */ + private static function typeList(ReflectionType $reflectionType): array + { + $foundTypes = static function (array $res, ReflectionType $reflectionType) { + if (!$reflectionType instanceof ReflectionNamedType) { + return $res; + } + + $type = self::tryFromName($reflectionType->getName()); + if (null !== $type) { + $res[] = [$type, $reflectionType]; + } + + return $res; + }; + + if ($reflectionType instanceof ReflectionNamedType) { + $type = self::tryFromName($reflectionType->getName()); + if (null !== $type) { + return [[$type, $reflectionType]]; + } + + return []; + } + + if ($reflectionType instanceof ReflectionUnionType) { + return array_reduce($reflectionType->getTypes(), $foundTypes, []); + } + + return []; + } + + public static function tryFromReflectionType(ReflectionType $type): ?self + { + if ($type instanceof ReflectionNamedType) { + return self::tryFromName($type->getName()); + } + + if (!$type instanceof ReflectionUnionType) { + return null; + } + + foreach ($type->getTypes() as $innerType) { + if (!$innerType instanceof ReflectionNamedType) { + continue; + } + + $result = self::tryFromName($innerType->getName()); + if ($result instanceof self) { + return $result; + } + } + + return null; + } + + private static function tryFromName(string $propertyType): ?self + { + $type = self::tryFrom($propertyType); + + return match (true) { + $type instanceof self => $type, + enum_exists($propertyType) => self::Enum, + interface_exists($propertyType) && DateTimeInterface::class === $propertyType, + class_exists($propertyType) && in_array(DateTimeInterface::class, class_implements($propertyType), true) => self::Date, + default => null, + }; + } } diff --git a/src/SerializerTest.php b/src/SerializerTest.php index f9921591..ed5f7bfa 100644 --- a/src/SerializerTest.php +++ b/src/SerializerTest.php @@ -13,6 +13,7 @@ namespace League\Csv; +use Countable; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -23,6 +24,7 @@ use PHPUnit\Framework\TestCase; use SplFileObject; use stdClass; +use Traversable; final class SerializerTest extends TestCase { @@ -157,7 +159,7 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi public function testItWillFailForLackOfTypeCasting(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No valid type casting for `SplFileObject $observedOn`.'); + $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$observedOn` type.'); new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']); } @@ -165,13 +167,25 @@ public function testItWillFailForLackOfTypeCasting(): void public function testItWillThrowIfTheClassContainsUninitializedProperties(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No valid type casting was found for the property `annee` must be typed.'); + $this->expectExceptionMessage('No valid type casting was found for the property `annee`; it must be typed.'); Serializer::assign( InvalidObjectWithUninitializedProperty::class, ['prenoms' => 'John', 'nombre' => '42', 'sexe' => 'M', 'annee' => '2018'] ); } + + public function testItCanNotAutodiscoverWithIntersectionType(): void + { + $this->expectException(MappingFailed::class); + $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$traversable` type.'); + + $foobar = new class () { + public Countable&Traversable $traversable; + }; + + Serializer::assign($foobar::class, ['traversable' => '1']); + } } enum Place: string