diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md index 1aa1b4a9..d50ebd5e 100644 --- a/docs/9.0/reader/record-mapping.md +++ b/docs/9.0/reader/record-mapping.md @@ -107,10 +107,9 @@ By default, the denormalization engine will automatically fill public properties using their names. 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. -- a public class method, whose name starts with `set` and ends with the record key with the first character upper-cased, the record value will be assigned to the method first argument. +- or, a public class method, whose name starts with `set` and ends with the record key with the first character upper-cased, the record value will be assigned to the method first argument. -While the record value **MUST BE** a `string` or `null`, the autodiscovery feature works out of the box with -public properties or arguments typed with one of the following type: +The autodiscovery feature works out of the box with public properties or arguments typed with one of the following type: - a scalar type (`string`, `int`, `float`, `bool`) - `null` @@ -125,10 +124,14 @@ PHP attributes: - the `League\Csv\Serializer\MapCell` - the `League\Csv\Serializer\AfterMapping` +- the `League\Csv\Serializer\MapRecord`

The AfterMapping attribute is added in version 9.13.0

+

The MapRecord attribute is added in version 9.17.0

+

Before version 9.17.0 the celle value must be a string or null. +This limitation no longer exists.

-### Improving record mapping +### Improving field mapping Here's an example of how the `League\Csv\Serializer\MapCell` attribute works: @@ -162,7 +165,9 @@ The attribute can take up to four (4) arguments which are all optional: - The `cast` a string which represents the name of a class implementing the `TypeCasting` interface or an alias and responsible for type casting the record value. If not present, the mechanism will try to resolve the typecasting based on the property or method argument type. - The `options` an associative array to improve typecasting by providing extra options per class/alias. The argument expects an associative array and relies on named arguments to inject its value to the method. - The `ignore` a boolean which control if the property or method should be completely ignored by the mechanism. By default, its value is `false`. This property takes precedence over all the other properties of the attribute once set to `true`. +- The `convertEmptyStringToNull` a value that can be a boolean or `null`, which control if empty string should be or not converted into the `null` value. +

The convertEmptyStringToNull argument was added in version 9.17.0

The ignore argument was added in version 9.13.0

You can use the mechanism on a CSV without a header row but it requires adding a MapCell attribute on each property or method needed for the conversion. Or you @@ -171,18 +176,87 @@ header value, just like with TabularDataReader::getRecords

In any case, if type casting fails, an exception will be thrown. -### Improving object creation +## Improving object creation + +### Handling the empty string + +Out of the box the mechanism converts any empty string value into the `null` value. +You can however change this behaviour using two (2) static methods: + +- `League\Csv\Serializer\Denormalizer::allowEmptyStringAsNull` +- `League\Csv\Serializer\Denormalizer::disallowEmptyStringAsNull` + +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\Reader; +use League\Csv\Serializer\Denormalizer; + +$csv = Reader::createFromString($document); +$csv->setHeaderOffset(0); +foreach ($csv->getRecordsAsObject(ClimaticRecord::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(); + +foreach ($csv->getRecordsAsObject(ClimaticRecord::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. +} +``` + +Starting with version `9.17.0` you can get a more granular effect and override the global setting. You can either +control the conversion at the field level or at the object level. + +At the field level you need to use newly introduced `convertEmptyStringToNull` argument as shown in the example below: + +```php +#[Serializer\MapCell(convertEmptyStringToNull: true)] +private DateTimeImmutable $observedOn; +``` + +When the value is set to `true`, the conversion will happen. If set to `false`, no conversion will take place. +By default, the value is set to `null` to defer the behaviour settings at the object level. + +At the object level you can use the new `MapRecord` attribute with the same argument. + +```php +#[Serializer\MapRecord(convertEmptyStringToNull: true)] +flnail readonly class Car +{ + public function __construct( + private Wheel $wheel, + private Driver $driver + ) { + } +} +``` + +Just like with the `MapCell` attribute, if set to `true` the conversion will happen for **all fields**, but if set +to `false` no conversion will take place. By default, it is set to `null` and the conversion behaviour falls back +to the global behaviour. + +### Post Mapping

The feature is available since version 9.13.0

+

The MapRecord attribute is added in 9.17.0

Because we are not using the object constructor method, we need a way to work around that limitation and tagging one or more methods that should be called after all mapping is done to return a valid object. -Tagging is made using the `League\Csv\Serializer\AfterMapping` attribute. +Tagging is made using the `League\Csv\Serializer\MapRecord` attribute. ```php use League\Csv\Serializer; -#[Serializer\AfterMapping('validate')] +#[Serializer\MapRecord(afterMapping:['validate'])] final class ClimateRecord { public function __construct( @@ -204,43 +278,38 @@ final class ClimateRecord In the above example, the `validate` method will be call once all the properties have been set but before the object is returned. You can specify as many methods belonging to the class as you want -regardless of their visibility by separating them with a comma. The methods will be called +regardless of their visibility by adding them to the array. The methods will be called in the order they have been declared.

If the method does not exist or requires explicit arguments an exception will be thrown.

-### Handling the empty string - -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: +#### Deprecated attribute -- `League\Csv\Serializer\Denormalizer::allowEmptyStringAsNull` -- `League\Csv\Serializer\Denormalizer::disallowEmptyStringAsNull` +The `League\Csv\Serializer\AfterMapping` attribute is deprecated in favor of the `League\Csv\Serializer\MapRecord` attribute. +If both attributes are used simultaneously, the content of the `AfterMapping` attribute will be ignored. -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. +The example is left for reference. ```php -use League\Csv\Reader; -use League\Csv\Serializer\Denormalizer; - -$csv = Reader::createFromString($document); -$csv->setHeaderOffset(0); -foreach ($csv->getRecordsAsObject(ClimaticRecord::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; -} +use League\Csv\Serializer; -Denormalizer::disallowEmptyStringAsNull(); +#[Serializer\AfterMapping('validate')] +final class ClimateRecord +{ + public function __construct( + public readonly Place $place, + public readonly ?float $temperature, + public readonly ?DateTimeImmutable $date, + ) { + $this->validate(); + } -foreach ($csv->getRecordsAsObject(ClimaticRecord::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. + protected function validate(): void + { + //further validation on your object + //or any other post construction methods + //that is needed to be called + } } ``` @@ -508,7 +577,6 @@ To complete the feature you can use: ```php use League\Csv\Serializer; - Serializer\Denormalizer::unregisterType(Naira::class); Serializer\Denormalizer::unregisterAllTypes(); Serializer\Denormalizer::types(); @@ -621,7 +689,7 @@ final class CastToNaira implements TypeCasting // in case of error you should throw a MappingFailed exception } - public function toVariable(?string $value): ?Naira + public function toVariable(mixed $value): ?Naira { //convert the Cell value into the expected type // in case of error you should throw a TypeCastingFailed exception diff --git a/src/Serializer/AfterMapping.php b/src/Serializer/AfterMapping.php index 65d244e0..de698bc5 100644 --- a/src/Serializer/AfterMapping.php +++ b/src/Serializer/AfterMapping.php @@ -14,6 +14,10 @@ namespace League\Csv\Serializer; use Attribute; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; #[Attribute(Attribute::TARGET_CLASS)] final class AfterMapping @@ -25,4 +29,43 @@ public function __construct(string ...$methods) { $this->methods = $methods; } + + /** + * + * @return array + */ + public function afterMappingMethods(ReflectionClass $class): array + { + $methods = []; + foreach ($this->methods as $method) { + try { + $accessor = $class->getMethod($method); + } catch (ReflectionException $exception) { + throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$class->getName().'` class.', 0, $exception); + } + + if (0 !== $accessor->getNumberOfRequiredParameters()) { + throw new MappingFailed('The method `'.$class->getName().'::'.$accessor->getName().'` has too many required parameters.'); + } + + $methods[] = $accessor; + } + + return $methods; + } + + public static function from(ReflectionClass $class): ?self + { + $attributes = $class->getAttributes(AfterMapping::class, ReflectionAttribute::IS_INSTANCEOF); + $nbAttributes = count($attributes); + if (0 === $nbAttributes) { + return null; + } + + if (1 < $nbAttributes) { + throw new MappingFailed('Using more than one `'.AfterMapping::class.'` attribute on a class property or method is not supported.'); + } + + return $attributes[0]->newInstance(); + } } diff --git a/src/Serializer/CallbackCasting.php b/src/Serializer/CallbackCasting.php index 94705c8f..af385462 100644 --- a/src/Serializer/CallbackCasting.php +++ b/src/Serializer/CallbackCasting.php @@ -60,8 +60,10 @@ public function __construct( /** * @throws MappingFailed */ - public function setOptions(?string $type = null, mixed ...$options): void - { + public function setOptions( + ?string $type = null, + mixed ...$options + ): void { if (null === $this->alias) { if (Type::Mixed->value === $this->type && null !== $type) { $this->type = $type; diff --git a/src/Serializer/CastToBool.php b/src/Serializer/CastToBool.php index 5f2a0c8b..fc25dc93 100644 --- a/src/Serializer/CastToBool.php +++ b/src/Serializer/CastToBool.php @@ -32,8 +32,10 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr [$this->type, $this->isNullable] = $this->init($reflectionProperty); } - public function setOptions(?bool $default = null): void - { + public function setOptions( + ?bool $default = null, + bool $emptyStringAsNull = false, + ): void { $this->default = $default; } diff --git a/src/Serializer/CastToDate.php b/src/Serializer/CastToDate.php index e45a913e..a4becdf6 100644 --- a/src/Serializer/CastToDate.php +++ b/src/Serializer/CastToDate.php @@ -58,7 +58,7 @@ public function setOptions( ?string $default = null, ?string $format = null, DateTimeZone|string|null $timezone = null, - ?string $className = null + ?string $className = null, ): void { $this->class = match (true) { !interface_exists($this->class) && !Type::Mixed->equals($this->type) => $this->class, diff --git a/src/Serializer/CastToEnum.php b/src/Serializer/CastToEnum.php index 21e07522..d8ce983b 100644 --- a/src/Serializer/CastToEnum.php +++ b/src/Serializer/CastToEnum.php @@ -46,8 +46,11 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr * * @throws MappingFailed */ - public function setOptions(?string $default = null, ?string $className = null): void - { + public function setOptions( + ?string $default = null, + ?string $className = null, + bool $emptyStringAsNull = false, + ): void { if (Type::Mixed->equals($this->type) || in_array($this->class, [BackedEnum::class , UnitEnum::class], true)) { if (null === $className || !enum_exists($className)) { throw new MappingFailed('`'.$this->propertyName.'` type is `'.($this->class ?? 'mixed').'` but the specified class via the `$className` argument is invalid or could not be found.'); diff --git a/src/Serializer/CastToFloat.php b/src/Serializer/CastToFloat.php index f8a8f778..333db5cd 100644 --- a/src/Serializer/CastToFloat.php +++ b/src/Serializer/CastToFloat.php @@ -31,8 +31,10 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr $this->isNullable = $this->init($reflectionProperty); } - public function setOptions(int|float|null $default = null): void - { + public function setOptions( + int|float|null $default = null, + bool $emptyStringAsNull = false, + ): void { $this->default = $default; } diff --git a/src/Serializer/CastToInt.php b/src/Serializer/CastToInt.php index 7d673968..baea712f 100644 --- a/src/Serializer/CastToInt.php +++ b/src/Serializer/CastToInt.php @@ -31,8 +31,10 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr $this->isNullable = $this->init($reflectionProperty); } - public function setOptions(?int $default = null): void - { + public function setOptions( + ?int $default = null, + bool $emptyStringAsNull = false, + ): void { $this->default = $default; } diff --git a/src/Serializer/CastToString.php b/src/Serializer/CastToString.php index 1a24c8da..fabac260 100644 --- a/src/Serializer/CastToString.php +++ b/src/Serializer/CastToString.php @@ -30,8 +30,10 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr [$this->type, $this->isNullable] = $this->init($reflectionProperty); } - public function setOptions(?string $default = null): void - { + public function setOptions( + ?string $default = null, + bool $emptyStringAsNull = false, + ): void { $this->default = $default; } diff --git a/src/Serializer/Denormalizer.php b/src/Serializer/Denormalizer.php index d5845fc4..19f7e2fc 100644 --- a/src/Serializer/Denormalizer.php +++ b/src/Serializer/Denormalizer.php @@ -40,6 +40,7 @@ final class Denormalizer private readonly array $propertySetters; /** @var array */ private readonly array $postMapCalls; + private readonly ?MapRecord $mapRecord; /** * @param class-string $className @@ -50,6 +51,7 @@ final class Denormalizer public function __construct(string $className, array $propertyNames = []) { $this->class = $this->setClass($className); + $this->mapRecord = $this->getMapRecord(); $this->properties = $this->class->getProperties(); $this->propertySetters = $this->setPropertySetters($propertyNames); $this->postMapCalls = $this->setPostMapCalls(); @@ -204,12 +206,7 @@ private function hydrate(object $object, array $record): void { $record = array_values($record); foreach ($this->propertySetters as $propertySetter) { - $value = $record[$propertySetter->offset]; - if (is_string($value) && '' === trim($value) && self::$emptyStringAsNull) { - $value = null; - } - - $propertySetter($object, $value); + $propertySetter($object, $record[$propertySetter->offset]); } } @@ -255,6 +252,7 @@ private function setPropertySetters(array $propertyNames): array { $propertySetters = []; $methodNames = array_map(fn (string $propertyName) => 'set'.ucfirst($propertyName), $propertyNames); + foreach ([...$this->properties, ...$this->class->getMethods()] as $accessor) { $attributes = $accessor->getAttributes(MapCell::class, ReflectionAttribute::IS_INSTANCEOF); $propertySetter = match (count($attributes)) { @@ -273,36 +271,28 @@ private function setPropertySetters(array $propertyNames): array }; } - private function setPostMapCalls(): array + private function getMapRecord(): ?MapRecord { - $methods = []; - $attributes = $this->class->getAttributes(AfterMapping::class, ReflectionAttribute::IS_INSTANCEOF); + $attributes = $this->class->getAttributes(MapRecord::class, ReflectionAttribute::IS_INSTANCEOF); $nbAttributes = count($attributes); - if (0 === $nbAttributes) { - return $methods; - } - - if (1 < $nbAttributes) { - throw new MappingFailed('Using more than one `'.AfterMapping::class.'` attribute on a class property or method is not supported.'); - } - - /** @var AfterMapping $postMap */ - $postMap = $attributes[0]->newInstance(); - foreach ($postMap->methods as $method) { - try { - $accessor = $this->class->getMethod($method); - } catch (ReflectionException $exception) { - throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$this->class->getName().'` class.', 0, $exception); - } - if (0 !== $accessor->getNumberOfRequiredParameters()) { - throw new MappingFailed('The method `'.$this->class->getName().'::'.$accessor->getName().'` has too many required parameters.'); - } + return match ($nbAttributes) { + 0 => null, + 1 => $attributes[0]->newInstance(), + default => throw new MappingFailed('Using more than one `'.MapRecord::class.'` attribute on a class property or method is not supported.'), + }; + } - $methods[] = $accessor; + /** + * @return array + */ + private function setPostMapCalls(): array + { + if ($this->mapRecord instanceof MapRecord) { + return $this->mapRecord->afterMappingMethods($this->class); } - return $methods; + return AfterMapping::from($this->class)?->afterMappingMethods($this->class) ?? []; } /** @@ -344,7 +334,8 @@ private function autoDiscoverPropertySetter(ReflectionMethod|ReflectionProperty default => new PropertySetter( $accessor, $offset, - $this->resolveTypeCasting($reflectionProperty) + $this->resolveTypeCasting($reflectionProperty), + $this->mapRecord?->convertEmptyStringToNull ?? self::$emptyStringAsNull, ), }; } @@ -354,15 +345,15 @@ private function autoDiscoverPropertySetter(ReflectionMethod|ReflectionProperty * * @throws MappingFailed */ - private function findPropertySetter(MapCell $cell, ReflectionMethod|ReflectionProperty $accessor, array $propertyNames): ?PropertySetter + private function findPropertySetter(MapCell $mapCell, ReflectionMethod|ReflectionProperty $accessor, array $propertyNames): ?PropertySetter { - if ($cell->ignore) { + if ($mapCell->ignore) { return null; } - $typeCaster = $this->resolveTypeCaster($cell, $accessor); + $typeCaster = $this->resolveTypeCaster($mapCell, $accessor); - $offset = $cell->column ?? match (true) { + $offset = $mapCell->column ?? match (true) { $accessor instanceof ReflectionMethod => $this->getMethodFirstArgument($accessor)->getName(), $accessor instanceof ReflectionProperty => $accessor->getName(), }; @@ -386,11 +377,13 @@ private function findPropertySetter(MapCell $cell, ReflectionMethod|ReflectionPr $accessor instanceof ReflectionProperty => $accessor, }; + $emptyStringToNull = $mapCell->convertEmptyStringToNull ?? $this->mapRecord?->convertEmptyStringToNull ?? self::$emptyStringAsNull; + return match (true) { 0 > $offset => throw new MappingFailed('offset integer position can only be positive or equals to 0; received `'.$offset.'`'), [] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('offset integer position can not exceed property names count.'), - null === $typeCaster => new PropertySetter($accessor, $offset, $this->resolveTypeCasting($reflectionProperty, $cell->options)), - default => new PropertySetter($accessor, $offset, $this->getTypeCasting($reflectionProperty, $typeCaster, $cell->options)), + null === $typeCaster => new PropertySetter($accessor, $offset, $this->resolveTypeCasting($reflectionProperty, $mapCell->options), $emptyStringToNull), + default => new PropertySetter($accessor, $offset, $this->getTypeCasting($reflectionProperty, $typeCaster, $mapCell->options), $emptyStringToNull), }; } diff --git a/src/Serializer/DenormalizerTest.php b/src/Serializer/DenormalizerTest.php index f954a83f..33f4b516 100644 --- a/src/Serializer/DenormalizerTest.php +++ b/src/Serializer/DenormalizerTest.php @@ -672,7 +672,7 @@ enum Place: string case Abidjan = 'Abidjan'; } -#[AfterMapping('addOne')] +#[MapRecord(afterMapping: ['addOne'])] class UsingAfterMapping { public function __construct(public int $addition) @@ -686,7 +686,7 @@ private function addOne(): void } } -#[AfterMapping('addOne', 'addTow')] +#[MapRecord(afterMapping: ['addOne', 'addTow'])] class MissingMethodAfterMapping { public function __construct(public int $addition) @@ -700,7 +700,7 @@ private function addOne(): void } } -#[AfterMapping('addOne')] +#[MapRecord(afterMapping: ['addOne'])] class RequiresArgumentAfterMapping { public function __construct(public int $addition) diff --git a/src/Serializer/MapCell.php b/src/Serializer/MapCell.php index 4621b2c2..2840b680 100644 --- a/src/Serializer/MapCell.php +++ b/src/Serializer/MapCell.php @@ -26,6 +26,7 @@ public function __construct( public readonly ?string $cast = null, public readonly array $options = [], public readonly bool $ignore = false, + public readonly ?bool $convertEmptyStringToNull = null, ) { } } diff --git a/src/Serializer/MapRecord.php b/src/Serializer/MapRecord.php new file mode 100644 index 00000000..44272e58 --- /dev/null +++ b/src/Serializer/MapRecord.php @@ -0,0 +1,59 @@ + + * + * 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 Attribute; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; +use ValueError; + +#[Attribute(Attribute::TARGET_CLASS)] +final class MapRecord +{ + public function __construct( + /** @var array $afterMapping */ + public readonly array $afterMapping = [], + public readonly ?bool $convertEmptyStringToNull = null, + ) { + foreach ($this->afterMapping as $method) { + if (!is_string($method)) { + throw new ValueError('The method names must be strings.'); + } + } + } + + /** + * @return array + */ + public function afterMappingMethods(ReflectionClass $class): array + { + $methods = []; + foreach ($this->afterMapping as $method) { + try { + $accessor = $class->getMethod($method); + } catch (ReflectionException $exception) { + throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$class->getName().'` class.', 0, $exception); + } + + if (0 !== $accessor->getNumberOfRequiredParameters()) { + throw new MappingFailed('The method `'.$class->getName().'::'.$accessor->getName().'` has too many required parameters.'); + } + + $methods[] = $accessor; + } + + return $methods; + } +} diff --git a/src/Serializer/PropertySetter.php b/src/Serializer/PropertySetter.php index 18f23b40..6985c895 100644 --- a/src/Serializer/PropertySetter.php +++ b/src/Serializer/PropertySetter.php @@ -25,7 +25,8 @@ final class PropertySetter public function __construct( private readonly ReflectionMethod|ReflectionProperty $accessor, public readonly int $offset, - private readonly TypeCasting $cast, + public readonly TypeCasting $cast, + public readonly bool $convertEmptyStringToNull = false, ) { } @@ -34,6 +35,10 @@ public function __construct( */ public function __invoke(object $object, mixed $value): void { + if ('' === $value && $this->convertEmptyStringToNull) { + $value = null; + } + $typeCastedValue = $this->cast->toVariable($value); match (true) {