From c56aea4479e25c4bdd5d5c5082c57563b2ce073c Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Thu, 16 Nov 2023 17:26:27 +0100 Subject: [PATCH] Improve CastToDate type casting --- docs/9.0/reader/record-mapping.md | 228 +++++++++++++++--------------- src/Serializer/CastToDate.php | 16 ++- src/Serializer/ClosureCasting.php | 9 +- src/Serializer/Denormalizer.php | 4 +- 4 files changed, 136 insertions(+), 121 deletions(-) diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md index 3b233ce9..d934cab8 100644 --- a/docs/9.0/reader/record-mapping.md +++ b/docs/9.0/reader/record-mapping.md @@ -24,46 +24,6 @@ foreach ($csv->getObjects(Weather::class) as $weather) { In the following sections we will explain the mechanism use and how you can control it. -## Assign an array to an object - -To work with objects instead of arrays the `Denormalizer` class is introduced to expose a -text based denormalization mechanism for tabular data. - -The class exposes four (4) methods to ease `array` to `object` conversion: - -- `Denormalizer::denormalizeAll` and `Denormalizer::assignAll` which convert a collection of records into a collection of instances of a specified class. -- `Denormalizer::denormalize` and `Denormalizer::assign` which convert a single record into a new instance of the specified class. - -```php -use League\Csv\Serializer\Denormalizer; - -$record = [ - 'date' => '2023-10-30', - 'temperature' => '-1.5', - 'place' => 'Berkeley', -]; - -//a complete collection of records as shown below -$collection = [$record]; -//we first instantiate the denormalizer -$denormalizer = new Denormalizer(Weather::class, ['date', 'temperature', 'place']); - -$weather = $denormalizer->denormalize($record); //we convert 1 record into 1 instance -foreach ($denormalizer->denormalizeAll($collection) as $weather) { - // each $weather entry will be an instance of the Weather class; -} - -// you can use the alternate syntactic sugar methods -// if you only need the denormalizing mechanism once -$weather = Denormalizer::assign(Weather::class, $record); - -foreach (Denormalizer::assignAll(Weather::class, $collection, ['date', 'temperature', 'place']) as $weather) { - // each $weather entry will be an instance of the Weather class; -} -``` - -All classes are defined under the `League\Csv\Serializer` namespace. - ## Prerequisite The denormalization mechanism works mainly with DTO or objects @@ -83,7 +43,7 @@ As an example if we assume we have the following CSV document: ```csv date,temperature,place -2011-01-01,1,Galway +2011-01-01,,Galway 2011-01-02,-1,Galway 2011-01-03,0,Galway 2011-01-01,6,Berkeley @@ -115,7 +75,8 @@ enum Place } ``` -To get instances of your object, you now can call one of the `Denormalizer` method as show below: +To get instances of your object, you now can call `TabularData::getObjects` which returns +an `Iterator` containing only instances of your specified class. ```php use League\Csv\Reader; @@ -123,37 +84,22 @@ use League\Csv\Serializer\Denormalizer $csv = Reader::createFromString($document); $csv->setHeaderOffset(0); -$denormalizer = new Denormalizer(Weather::class, $csv->header()); - -foreach ($csv as $record) { - $weather = $denormalizer->denormalize($record); -} - -//or - -foreach ($denormalizer->denormalizeAll($csv) as $weather) { - // each $weather entry will be an instance of the Weather class; -} - -//or - -foreach (Denormalizer::assignAll(Weather::class, $csv, $csv->getHeader()) as $weather) { +foreach ($csv->getObjects(Weather::class) { // each $weather entry will be an instance of the Weather class; } ``` -

The code above is similar to using TabularDataReader::getObjects method.

- ## Defining the mapping rules By default, the denormalization engine will automatically convert public properties using their name. In other words, if there is a public class property, which name is the same as a record key, -the record value will be assigned to that property. The record value **MUST BE** a -`string` or `null` and the object public properties **MUST BE** typed with one of +the record value will be assigned to that property. While the record value **MUST BE** a +`string` or `null`, the autodiscovery feature only works with public properties typed with one of the following type: - a scalar type (`string`, `int`, `float`, `bool`) -- any `Enum` object (backed or not) +- `null` +- any `Enum` (backed or not) - `DateTimeInterface` implementing class. - an `array` @@ -200,34 +146,37 @@ In any case, if type casting fails, an exception will be thrown. ### Handling the empty string -Out of the box the `Denormalizer` makes no distinction between an empty string and the `null` value. +Out of the box the mechanism makes no distinction between an empty string and the `null` value. You can however change this behaviour using two (2) static methods: -- `Denormalizer::allowEmptyStringAsNull` -- `Denormalizer::disallowEmptyStringAsNull` +- `League\Csv\Serializer\Denormalizer::allowEmptyStringAsNull` +- `League\Csv\Serializer\Denormalizer::disallowEmptyStringAsNull` -When called these methods will change the class behaviour when it comes to handling empty string. -`Denormalizer::allowEmptyStringAsNull` will trigger conversion of all empty string into the `null` value -before typecasting whereas `Denormalizer::disallowEmptyStringAsNull` will maintain the distinction. -Using these methods will affect the `Denormalizer` usage throughout your codebase. +When called these methods will change the behaviour when it comes to handling empty string. +`Denormalizer::allowEmptyStringAsNull` will convert any empty string into the `null` value +before typecasting whereas `Denormalizer::disallowEmptyStringAsNull` will preserve the value. +Using these methods will affect the results of the process throughout your codebase. ```php use League\Csv\Serializer\Denormalizer; +use League\Csv\Reader; -$record = [ - 'date' => '2023-10-30', - 'temperature' => '', - 'place' => 'Berkeley', -]; - -$weather = Denormalizer::assign(Weather::class, $record); -$weather->temperature; // returns null +$csv = Reader::createFromString($document); +$csv->setHeaderOffset(0); +foreach ($csv->getObjects(Weather::class) { + // the first record contains an empty string for temperature + // it is converted into the null value and handle by the + // default conversion type casting; +} Denormalizer::disallowEmptyStringAsNull(); -Denormalizer::assign(Weather::class, $record); -//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. + +foreach ($csv->getObjects(Weather::class) { + // a TypeCastingFailed exception is thrown because we + // can not convert the empty string into a valid + // temperature property value + // which expects `null` or a non-empty string. +} ``` ## Type casting @@ -254,6 +203,8 @@ optional argument `default` which is the default boolean value to return if the 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. +

This class is also responsible for automatically typecasting true and false typed properties.

+ ### CastToInt and CastToFloat Converts the array value to an `int` or a `float` depending on the property type information. The class takes one @@ -289,6 +240,8 @@ public function setPlace(mixed $place): void > if the value is null resolve the string `Galway` to `Place::Galway`. Once created, > call the method `setPlace` with the created `Place` enum filling the `$place` argument. +

Using this class with a mixed type without providing the enum parameter will trigger an exception.

+ ### CastToDate Converts the cell value into a PHP `DateTimeInterface` implementing object. You can optionally specify: @@ -296,8 +249,10 @@ Converts the cell value into a PHP `DateTimeInterface` implementing object. You - the date format via the `format` argument - the date timezone if needed via the `timezone` argument - the `default` which is the default value to return if the value is `null`; should be `null` or a parsable date time `string` +- the `dateClass` the class to use if the property is typed `mixed`. -If the property is typed with `mixed` or the `DateTimeInterface` a `DateTimeImmutable` instance will be used. +If the property is typed with `mixed` or the `DateTimeInterface`, a `DateTimeImmutable` instance will be used if the `dateClass` +argument is not given. If given and invalid, an exception will be thrown. ### CastToArray @@ -311,9 +266,9 @@ provides three (3) shapes: The following are example for each shape expected string value: ```php -$array['list'] = "1,2,3,4"; //the string contains only a delimiter (type list) -$arrat['csv'] = '"1","2","3","4"'; //the string contains delimiter and enclosure (type csv) -$arrat['json'] = '{"foo":"bar"}'; //the string is a json string (type json) +$array['list'] = "1,2,3,4"; //the string contains only a delimiter (shape list) +$arrat['csv'] = '"1","2","3","4"'; //the string contains delimiter and enclosure (shape csv) +$arrat['json'] = '{"foo":"bar"}'; //the string is a json string (shape json) ``` Here's an example for casting a string via the `json` shape. @@ -356,7 +311,7 @@ The `type` option only supports scalar type (`string`, `int`, `float` and `bool` ## Extending Type Casting capabilities -We provide two mechanisms to extend typecasting. You can register a closure via the `Denormalizer` class +Two mechanisms to extend typecasting are provided. You can register a closure via the `Denormalizer` class or create a fully fledge `TypeCasting` class. Of course, the choice will depend on your use case. ### Registering a closure @@ -365,46 +320,40 @@ You can register a closure using the `Denormalizer` class to convert a specific any built-in type or a specific class. ```php -use App\Domain\Money; +use App\Domain\Money\Naira; use League\Csv\Serializer\Denormalizer; -$typeCasting = function ( - ?string $value, - bool $isNullable, - ?int $default = 20_00 - ): ?Money { +$castToNaira = function (?string $value, bool $isNullable, int $default = null): ?Naira { if (null === $value && $isNullable) { if (null !== $default) { - return Money::fromNaira($default); + return Naira::fromKobos($default); } return null; } - return Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)); -} + return Naira::fromKobos(filter_var($value, FILTER_VALIDATE_INT)); +}; -Denormalizer::registerType(Money::class, $typeCasting); +Denormalizer::registerType(Naira::class, $castToNaira); ``` -The `Denormalizer` will automatically call the closure for any `App\Domain\Money` conversion. You can +The `Denormalizer` will automatically call the closure for any `App\Domain\Money\Naira` conversion. You can also use the `Cell` attribute to further control the conversion -To do so, first, specify your casting with the attribute: +To do so specify your casting with the attribute: ```php use App\Domain\Money use League\Csv\Serializer; -#[Serializer\Cell(offset: 'amount', castArguments: ['default' => 20_00])] -private ?Money $naira; +#[Serializer\Cell(offset: 'amount', castArguments: ['default' => 1000_00])] +private ?Naira $amount; ```

No need to specify the cast argument as the closure is registered.

-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 via the `Cell` attribute `cast` argument. +In the following example, we redefine how to typecast to integer. ```php use League\Csv\Serializer\Denormalizer; @@ -412,6 +361,11 @@ use League\Csv\Serializer\Denormalizer; Denormalizer::registerType('int', fn (?string $value): int => 42); ``` +The closure will take precedence over the `CastToInt` class to convert +to the `int` type during autodiscovery. You can still use the `CastToInt` +class, but you are now require to explicitly declare it via the `Cell` +attribute using the `cast` argument. + The closure signature is the following: ```php @@ -424,9 +378,13 @@ where: - 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: +To complete the feature you can use `Denormalizer::unregisterType` to remove a registered closure for a specific `type`. + +```php +use League\Csv\Serializer\Denormalizer; -- `Denormalizer::unregisterType` to remove the registered closure for a specific `type`; +Denormalizer::unregisterType(Naira::class); +``` The two (2) methods are static. @@ -439,12 +397,12 @@ you can provide your own class to typecast the value according to your own rules is not registered by default you must configure its usage via the `Cell` attribute `cast` argument. ```php -use App\Domain\Money +use App\Domain\Money\Naira; use League\Csv\Serializer; #[Serializer\Cell( offset: 'amount', - cast: App\Domain\CastToNaira::class, + cast: App\Domain\Money\CastToNaira::class, castArguments: ['default' => 20_00] )] private ?Money $naira; @@ -459,25 +417,30 @@ one of its argument with the name $reflectionProperty. This means y reflectionProperty as a possible key of the associative array given to castArguments

```php -use App\Domain\Money; + + * @implements TypeCasting */ final class CastToNaira implements TypeCasting { private readonly bool $isNullable; - private readonly Money $default; + private readonly Naira $default; public function __construct( - ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Serializer + ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Denormalizer ?int $default = null //can be filled via the Cell castArguments array destructuring ) { if (null !== $default) { - $default = Money::fromNaira($default); + $default = Naira::fromKobos($default); } $this->default = $default; @@ -504,7 +467,7 @@ final class CastToNaira implements TypeCasting return $this->default; } - return Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)); + return Naira::fromKobos(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); } @@ -514,3 +477,42 @@ final class CastToNaira implements TypeCasting

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

+ +## Using the feature outside the TabularDataReader + +The feature can be used outside the package usage via the `Denormalizer` class. + +The class exposes four (4) methods to ease `array` to `object` conversion: + +- `Denormalizer::denormalizeAll` and `Denormalizer::assignAll` which convert a collection of records into a collection of instances of a specified class. +- `Denormalizer::denormalize` and `Denormalizer::assign` which convert a single record into a new instance of the specified class. + +```php +use League\Csv\Serializer\Denormalizer; + +$record = [ + 'date' => '2023-10-30', + 'temperature' => '-1.5', + 'place' => 'Berkeley', +]; + +//a complete collection of records as shown below +$collection = [$record]; +//we first instantiate the denormalizer +$denormalizer = new Denormalizer(Weather::class, ['date', 'temperature', 'place']); +$weather = $denormalizer->denormalize($record); //we convert 1 record into 1 instance + +foreach ($denormalizer->denormalizeAll($collection) as $weather) { + // each $weather entry will be an instance of the Weather class; +} + +// you can use the alternate syntactic sugar methods +// if you only need to use the mechanism once +$weather = Denormalizer::assign(Weather::class, $record); + +foreach (Denormalizer::assignAll(Weather::class, $collection, ['date', 'temperature', 'place']) as $weather) { + // each $weather entry will be an instance of the Weather class; +} +``` + +Every rule and setting explain before applies to the `Denormalizer` usage. diff --git a/src/Serializer/CastToDate.php b/src/Serializer/CastToDate.php index fcd44999..5f47e1f5 100644 --- a/src/Serializer/CastToDate.php +++ b/src/Serializer/CastToDate.php @@ -17,11 +17,13 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use ReflectionClass; use ReflectionNamedType; use ReflectionParameter; use ReflectionProperty; use Throwable; +use function class_exists; use function is_string; /** @@ -30,11 +32,13 @@ final class CastToDate implements TypeCasting { private readonly ?DateTimeZone $timezone; + /** @var class-string */ private readonly string $class; private readonly bool $isNullable; private readonly DateTimeImmutable|DateTime|null $default; /** + * @param ?class-string $dateClass * @throws MappingFailed */ public function __construct( @@ -42,14 +46,18 @@ public function __construct( ?string $default = null, private readonly ?string $format = null, DateTimeZone|string|null $timezone = null, + ?string $dateClass = null ) { [$type, $reflection, $this->isNullable] = $this->init($reflectionProperty); + /** @var class-string $class */ $class = $reflection->getName(); - if (Type::Mixed->equals($type) || DateTimeInterface::class === $class) { - $class = DateTimeImmutable::class; - } + $this->class = match (true) { + DateTimeInterface::class !== $class && !Type::Mixed->equals($type) => $class, + null === $dateClass => DateTimeImmutable::class, + class_exists($dateClass) && (new ReflectionClass($dateClass))->implementsInterface(DateTimeInterface::class) => $dateClass, + default => throw new MappingFailed('`'.$reflectionProperty->getName().'` type is `mixed`; the specify `DateTimeInterface` class via the `$dateClass` argument is invalid or could not be found.'), + }; - $this->class = $class; try { $this->timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone; $this->default = (null !== $default) ? $this->cast($default) : $default; diff --git a/src/Serializer/ClosureCasting.php b/src/Serializer/ClosureCasting.php index 5ba0cd58..9cc7ab78 100644 --- a/src/Serializer/ClosureCasting.php +++ b/src/Serializer/ClosureCasting.php @@ -26,7 +26,6 @@ final class ClosureCasting implements TypeCasting { - /** @var array */ private static array $casters = []; private readonly string $type; @@ -70,9 +69,15 @@ class_exists($type), }; } - public static function unregister(string $type): void + public static function unregister(string $type): bool { + if (!array_key_exists($type, self::$casters)) { + return false; + } + unset(self::$casters[$type]); + + return true; } public static function supports(ReflectionParameter|ReflectionProperty $reflectionProperty): bool diff --git a/src/Serializer/Denormalizer.php b/src/Serializer/Denormalizer.php index fb658455..6a16ab32 100644 --- a/src/Serializer/Denormalizer.php +++ b/src/Serializer/Denormalizer.php @@ -71,9 +71,9 @@ public static function registerType(string $type, Closure $closure): void ClosureCasting::register($type, $closure); } - public static function unregisterType(string $type): void + public static function unregisterType(string $type): bool { - ClosureCasting::unregister($type); + return ClosureCasting::unregister($type); } /**