diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md index 469de19a..964fbe07 100644 --- a/docs/9.0/reader/record-mapping.md +++ b/docs/9.0/reader/record-mapping.md @@ -62,7 +62,7 @@ foreach ($csv->getObjects(Weather::class) as $weather) { In the following sections we will explain the conversion and how it can be configured. -## Pre-requisite +## Prerequisite The deserialization mechanism works mainly with DTO or objects without complex logic in their constructors. @@ -158,16 +158,17 @@ the following type: the `nullable` aspect of the property is also automatically handled. To complete the conversion you can use the `Cell` attribute. This attribute will override -the automatic resolution and enable fine-tuning type casting on the property level. +the automatic resolution and enable fine-tuning type casting. It can be used on class +properties and methods regardless of their visibility. -The `Cell` attribute can be used on class properties and methods regardless of their visibility. The attribute can take up to three (3) arguments which are all optional: - The `offset` argument tells the engine which record key to use via its numeric or name offset. If not present the property name or the name of the first argument of the `setter` method will be used. In such case, you are required to specify the property names information. - 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 reflectionProperty 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. @@ -190,21 +191,21 @@ private CarbonImmutable $observedOn; The above rule can be translated in plain english like this: -> convert the value of the associative array whose key is `date` into a `CarbonImmutable` object +> Convert the value of the associative array whose key is `date` into a `CarbonImmutable` object > using the date format `!Y-m-d` and the `Africa/Nairobi` timezone. Once created, -> inject the date instance into the class private property `observedOn`. +> inject the instance into the class private property `observedOn`. ### Handling the empty string -Out of the box the Serializer makes no difference between an emoty string and the `null` value. +Out of the box the `Serializer` makes no distinction between an empty string and the `null` value. You can however change this behaviour using two (2) static methods: - `Serializer::allowEmptyStringAsNull` - `Serializer::disallowEmptyStringAsNull` -When called these methods will change the class behaviour when it comes to empty string. -`Serializer::allowEmptyStringAsNull` will convert all empty string into the `null` value before -typecasting whereas `Serializer::disallowEmptyStringAsNull` will maintain the distinction. +When called these methods will change the class behaviour when it comes to handling empty string. +`Serializer::allowEmptyStringAsNull` will trigger conversion of all empty string into the `null` value +before typecasting whereas `Serializer::disallowEmptyStringAsNull` will maintain the distinction. Using these methods will affect the `Serializer` usage throughout your codebase. ```php @@ -221,8 +222,9 @@ $weather->temperature; // returns null Serializer::disallowEmptyStringAsNull(); Serializer::assign(Weather::class, $record); -//a TypeCastingFailed exception is thrown +//a TypeCastingFailed exception is thrown because we //can not convert the empty string into a temperature property +//which expects `null` or a non-empty string. ``` ## Type casting @@ -248,6 +250,8 @@ optional argument `default` which is the default value to return if the value is Converts the array value to `true`, `false` or `null` depending on the property type information. The class takes one optional argument `default` which is the default boolean value to return if the value is `null`. +Since typecasting relies on `ext-filter` rules, the following strings `1`, `true`, `on` and `yes` will all be cast +in a case-insensitive way to `true` otherwise `false` will be used. ### CastToInt and CastToFloat @@ -349,9 +353,62 @@ public function setData(array $data): void; If the conversion succeeds, then the property will be set with an `array` of `float` values. The `type` option only supports scalar type (`string`, `int`, `float` and `bool`) -### Creating your own TypeCasting class +## Extending Type Casting capabilities -You can also provide your own class to typecast the array value according to your own rules. To do so, first, +We provide two mechanisms to extends typecasting. You can register a closure via the `Serializer` class +or create a fully fledge `TypeCasting` class. Of course, the choice will depend on your use case. + +### Registering a closure + +You can register a closure using the `Serializer` class to convert a specific type. The type can be +any built-in type or a specific class. + +```php +use App\Domain\Money; +use League\Csv\Serializer; + +Serializer::registerType(Money::class, fn (?string $value, bool $isNullable, ?int $default = null): Money => match (true) { + $isNullable && null === $value => Money::fromNaira($default ?? 20_00), + default => Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)), +}); +``` + +The Serializer will automatically call the closure for any `App\Domain\Money` conversion. + +```php +use League\Csv\Serializer; + +Serializer::registerType('int', fn (?string $value): int => 42); +``` + +In the following example, the closure takes precedence over the `CastToInt` class to convert +to the `int` type. If you still wish to use the `CastToInt` class you are require to +explicitly declare it using the `Cell` attribute `cast` argument. + +The closure signature is the following: + +```php +closure(?string $value, bool $isNullable, ...$arguments): mixed; +``` + +where: + +- the `$value` is the record value +- the `$isNullable` tells whether the argument or property can be nullable +- the `$arguments` are the extra configuration options you can pass to the `Cell` attribute via `castArguments` + +To complete the feature you can use: + +- `Serializer::unregisterType` to remove the registered closure for a specific `type`; + +The two (2) methods are static. + +

the mechanism does not support IntersectionType

+ +### Implementing a TypeCasting class + +If you need to support `Intersection` type, or you want to be able to fine tune the typecasting +you can provide your own class to typecast the value according to your own rules. To do so, first, specify your casting with the attribute: ```php @@ -361,7 +418,7 @@ use League\Csv\Serializer; #[Serializer\Cell( offset: 'amount', cast: App\Domain\CastToNaira::class, - castArguments: ['default' => 100_00] + castArguments: ['default' => 20_00] )] private ?Money $naira; ``` @@ -390,15 +447,15 @@ final class CastToNaira implements TypeCasting public function __construct( ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Serializer - ?int $default = null + ?int $default = null //can be filled via the Cell castArguments array destructuring ) { if (null !== $default) { $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 is recommended to handle the $reflectionProperty argument. + // The argument gives you access to property/argument information. // 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 diff --git a/src/Serializer.php b/src/Serializer.php index c94f2e31..e6c18511 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -13,6 +13,7 @@ namespace League\Csv; +use Closure; use Iterator; use League\Csv\Serializer\CastToArray; use League\Csv\Serializer\CastToBool; @@ -22,6 +23,7 @@ use League\Csv\Serializer\CastToInt; use League\Csv\Serializer\CastToString; use League\Csv\Serializer\Cell; +use League\Csv\Serializer\ClosureCasting; use League\Csv\Serializer\MappingFailed; use League\Csv\Serializer\PropertySetter; use League\Csv\Serializer\Type; @@ -77,6 +79,16 @@ public static function disallowEmptyStringAsNull(): void self::$emptyStringAsNull = false; } + public static function registerType(string $type, Closure $closure): void + { + ClosureCasting::register($type, $closure); + } + + public static function unregisterType(string $type): void + { + ClosureCasting::unregister($type); + } + /** * @param class-string $className * @param array $record @@ -137,13 +149,15 @@ public function deserialize(array $record): object /** * @param array $record + * + * @throws TypeCastingFailed */ private function hydrate(object $object, array $record): void { $record = array_values($record); foreach ($this->propertySetters as $propertySetter) { $value = $record[$propertySetter->offset]; - if (self::$emptyStringAsNull && '' === $value) { + if ('' === $value && self::$emptyStringAsNull) { $value = null; } @@ -189,7 +203,7 @@ private function findPropertySetters(array $propertyNames): array continue; } - $propertySetters[] = $this->autoDiscoverPropertySetter($property, $offset); + $propertySetters[] = new PropertySetter($property, $offset, $this->resolveTypeCasting($property)); } $propertySetters = [...$propertySetters, ...$this->findPropertySettersByAttribute($propertyNames)]; @@ -200,16 +214,6 @@ private function findPropertySetters(array $propertyNames): array return $propertySetters; } - private function autoDiscoverPropertySetter(ReflectionProperty $property, int $offset): PropertySetter - { - $cast = $this->resolveTypeCasting($property); - if (null === $cast) { - throw new MappingFailed('No built-in `'.TypeCasting::class.'` class can handle `$'.$property->getName().'` type.'); - } - - return new PropertySetter($property, $offset, $cast); - } - /** * @param array $propertyNames * @@ -280,50 +284,13 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso return new PropertySetter($accessor, $index, $cast); } - /** - * @param array|string|int|float|bool> $arguments - * - * @throws MappingFailed If the arguments do not match the expected TypeCasting class constructor signature - */ - private function resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): ?TypeCasting - { - $reflectionType = $reflectionProperty->getType(); - if (null === $reflectionType) { - 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.', - }); - } - - try { - $arguments['reflectionProperty'] = $reflectionProperty; - - return match (Type::tryFromReflectionType($reflectionType)) { - Type::Mixed, Type::Null, Type::String => new CastToString(...$arguments), /* @phpstan-ignore-line */ - Type::Iterable, Type::Array => new CastToArray(...$arguments), /* @phpstan-ignore-line */ - Type::False, Type::True, Type::Bool => new CastToBool(...$arguments), /* @phpstan-ignore-line */ - Type::Float => new CastToFloat(...$arguments), /* @phpstan-ignore-line */ - Type::Int => new CastToInt(...$arguments), /* @phpstan-ignore-line */ - Type::Date => new CastToDate(...$arguments), /* @phpstan-ignore-line */ - Type::Enum => new CastToEnum(...$arguments), /* @phpstan-ignore-line */ - null => null, - }; - } catch (Throwable $exception) { - if ($exception instanceof MappingFailed) { - throw $exception; - } - - throw new MappingFailed(message:'Unable to instantiate a casting mechanism. Please verify your casting arguments', previous: $exception); - } - } - /** * @throws MappingFailed */ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod $accessor): TypeCasting { if (array_key_exists('reflectionProperty', $cell->castArguments)) { - throw new MappingFailed('The key `propertyType` can not be used with `castArguments`.'); + throw new MappingFailed('The key `reflectionProperty` can not be used with `castArguments`.'); } $reflectionProperty = match (true) { @@ -332,21 +299,56 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod }; $typeCaster = $cell->cast; - if (null !== $typeCaster) { - if (!in_array(TypeCasting::class, class_implements($typeCaster), true)) { - throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.'); - } + if (null === $typeCaster) { + return $this->resolveTypeCasting($reflectionProperty, $cell->castArguments); + } + + if (!in_array(TypeCasting::class, class_implements($typeCaster), true)) { + throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.'); + } - $arguments = [...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]]; + try { /** @var TypeCasting $cast */ - $cast = new $typeCaster(...$arguments); + $cast = new $typeCaster(...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]); return $cast; + } catch (Throwable $exception) { + if ($exception instanceof MappingFailed) { + throw $exception; + } + + throw new MappingFailed(message:'Unable to instantiate a casting mechanism. Please verify your casting arguments', previous: $exception); } + } - 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 resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): TypeCasting + { + $exception = new MappingFailed(match (true) { + $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed with a supported type.', + $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed with a supported type.', }); + + $reflectionType = $reflectionProperty->getType() ?? throw $exception; + + try { + $arguments['reflectionProperty'] = $reflectionProperty; + + return ClosureCasting::supports($reflectionProperty) ? + new ClosureCasting(...$arguments) : + match (Type::tryFromReflectionType($reflectionType)) { + Type::Mixed, Type::Null, Type::String => new CastToString(...$arguments), + Type::Iterable, Type::Array => new CastToArray(...$arguments), + Type::False, Type::True, Type::Bool => new CastToBool(...$arguments), + Type::Float => new CastToFloat(...$arguments), + Type::Int => new CastToInt(...$arguments), + Type::Date => new CastToDate(...$arguments), + Type::Enum => new CastToEnum(...$arguments), + default => throw $exception, + }; + } catch (MappingFailed $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new MappingFailed(message:'Unable to load the casting mechanism. Please verify your casting arguments', previous: $exception); + } } } diff --git a/src/Serializer/ClosureCasting.php b/src/Serializer/ClosureCasting.php new file mode 100644 index 00000000..fe72a150 --- /dev/null +++ b/src/Serializer/ClosureCasting.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Csv\Serializer; + +use Closure; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; +use Throwable; + +final class ClosureCasting implements TypeCasting +{ + /** @var array */ + private static array $casters = []; + + private readonly string $type; + private readonly bool $isNullable; + private readonly Closure $closure; + private readonly array $arguments; + + public function __construct(ReflectionProperty|ReflectionParameter $reflectionProperty, mixed ...$arguments) + { + [$type, $this->isNullable] = self::resolve($reflectionProperty); + $this->type = $type->getName(); + $this->closure = self::$casters[$this->type]; + $this->arguments = $arguments; + } + + public function toVariable(?string $value): mixed + { + try { + return ($this->closure)($value, $this->isNullable, ...$this->arguments); + } catch (Throwable $exception) { + if ($exception instanceof TypeCastingFailed) { + throw $exception; + } + + $message = match (true) { + '' === $value => 'Unable to cast the empty string to `'.$this->type.'`.', + null === $value => 'Unable to cast the `null` value to `'.$this->type.'`.', + default => 'Unable to cast the given string `'.$value.'` to `'.$this->type.'`', + }; + + throw new TypeCastingFailed(message: $message, previous: $exception); + } + } + + public static function register(string $type, Closure $closure): void + { + if (!class_exists($type) && !(Type::tryFrom($type)?->isBuiltIn() ?? false)) { + throw new MappingFailed('The `'.$type.'` could not be register.'); + } + + self::$casters[$type] = $closure; + } + + public static function unregister(string $type): void + { + unset(self::$casters[$type]); + } + + public static function supports(ReflectionParameter|ReflectionProperty $reflectionProperty): bool + { + foreach (self::getTypes($reflectionProperty->getType()) as $type) { + if (array_key_exists($type->getName(), self::$casters)) { + return true; + } + } + + return false; + } + + /** + * @throws MappingFailed + * + * @return array{0:ReflectionNamedType, 1:bool} + */ + private static function resolve(ReflectionParameter|ReflectionProperty $reflectionProperty): array + { + $type = null; + $isNullable = false; + foreach (self::getTypes($reflectionProperty->getType()) as $foundType) { + if (!$isNullable && $foundType->allowsNull()) { + $isNullable = true; + } + + if (null === $type && array_key_exists($foundType->getName(), self::$casters)) { + $type = $foundType; + } + } + + return $type instanceof ReflectionNamedType ? [$type, $isNullable] : throw new MappingFailed(match (true) { + $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed with a supported type.', + $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed with a supported type.', + }); + } + + /** + * @return array + */ + private static function getTypes(?ReflectionType $type): array + { + return match (true) { + $type instanceof ReflectionNamedType => [$type], + $type instanceof ReflectionUnionType => array_filter( + $type->getTypes(), + fn (ReflectionType $innerType) => $innerType instanceof ReflectionNamedType + ), + default => [], + }; + } +} diff --git a/src/Serializer/Type.php b/src/Serializer/Type.php index 8908a372..4f5cee1f 100644 --- a/src/Serializer/Type.php +++ b/src/Serializer/Type.php @@ -56,6 +56,14 @@ public function isOneOf(self ...$types): bool return in_array($this, $types, true); } + public function isBuiltIn(): bool + { + return match ($this) { + self::Date, self::Enum => false, + default => true, + }; + } + public function filterFlag(): int { return match ($this) { diff --git a/src/SerializerTest.php b/src/SerializerTest.php index ed5f7bfa..26c7ed31 100644 --- a/src/SerializerTest.php +++ b/src/SerializerTest.php @@ -151,7 +151,7 @@ public function testItWillThrowIfTheColumnAttributesCasterIsInvalid(): void public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('The property `temperature` must be typed.'); + $this->expectExceptionMessage('The property `temperature` must be typed with a supported type.'); new Serializer(InvaliDWeatherWithRecordAttribute::class, ['temperature', 'foobar', 'observedOn']); } @@ -159,7 +159,7 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi public function testItWillFailForLackOfTypeCasting(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$observedOn` type.'); + $this->expectExceptionMessage('The property `observedOn` must be typed with a supported type.'); new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']); } @@ -167,7 +167,7 @@ public function testItWillFailForLackOfTypeCasting(): void public function testItWillThrowIfTheClassContainsUninitializedProperties(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No valid type casting was found for the property `annee`; it must be typed.'); + $this->expectExceptionMessage('The property `annee` must be typed with a supported type.'); Serializer::assign( InvalidObjectWithUninitializedProperty::class, @@ -178,7 +178,7 @@ public function testItWillThrowIfTheClassContainsUninitializedProperties(): void public function testItCanNotAutodiscoverWithIntersectionType(): void { $this->expectException(MappingFailed::class); - $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$traversable` type.'); + $this->expectExceptionMessage('The property `traversable` must be typed with a supported type.'); $foobar = new class () { public Countable&Traversable $traversable; @@ -186,6 +186,47 @@ public function testItCanNotAutodiscoverWithIntersectionType(): void Serializer::assign($foobar::class, ['traversable' => '1']); } + + public function testItCanUseTheClosureRegisteringMechanism(): void + { + $record = ['foo' => 'toto']; + $foobar = new class () { + public string $foo; + }; + + Serializer::registerType('string', fn (?string $value) => 'yolo!'); + + self::assertSame('yolo!', Serializer::assign($foobar::class, $record)->foo); /* @phpstan-ignore-line */ + + Serializer::unregisterType('string'); + + self::assertSame('toto', Serializer::assign($foobar::class, $record)->foo); + } + + public function testItFailsToRegisterUnknownType(): void + { + $type = 'UnkownType'; + $this->expectException(MappingFailed::class); + $this->expectExceptionMessage('The `'.$type.'` could not be register.'); + + Serializer::registerType($type, fn (?string $value) => 'yolo!'); + } + + public function testEmptyStringHandling(): void + { + $record = ['foo' => '']; + $foobar = new class () { + public ?string $foo; + }; + + Serializer::disallowEmptyStringAsNull(); + + self::assertSame('', Serializer::assign($foobar::class, $record)->foo); /* @phpstan-ignore-line */ + + Serializer::allowEmptyStringAsNull(); + + self::assertNull(Serializer::assign($foobar::class, $record)->foo); + } } enum Place: string