Skip to content

Commit

Permalink
Improve empty string handling during serialization
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Nov 15, 2023
1 parent 46e94ed commit 876e71f
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 20 deletions.
33 changes: 32 additions & 1 deletion docs/9.0/reader/record-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ use League\Csv\Serializer\Cell;
final readonly class Weather
{
public function __construct(
public float $temperature,
public ?float $temperature,
public Place $place,
public DateTimeImmutable $date,
) {
Expand Down Expand Up @@ -194,6 +194,37 @@ The above rule can be translated in plain english like this:
> using the date format `!Y-m-d` and the `Africa/Nairobi` timezone. Once created,
> inject the date 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.
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.
Using these methods will affect the `Serializer` usage throughout your codebase.

```php
use League\Csv\Serializer;

$record = [
'date' => '2023-10-30',
'temperature' => '',
'place' => 'Berkeley',
];

$weather = Serializer::assign(Weather::class, $record);
$weather->temperature; // returns null

Serializer::disallowEmptyStringAsNull();
Serializer::assign(Weather::class, $record);
//a TypeCastingFailed exception is thrown
//can not convert the empty string into a temperature property
```

## Type casting

The library comes bundled with seven (7) type casting classes which relies on the property type information.
Expand Down
53 changes: 34 additions & 19 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@

final class Serializer
{
private static bool $emptyStringAsNull = true;

private readonly ReflectionClass $class;
/** @var array<ReflectionProperty> */
private readonly array $properties;
/** @var non-empty-array<PropertySetter> */
/** @var non-empty-array<PropertySetter> */
private readonly array $propertySetters;

/**
Expand All @@ -65,6 +67,16 @@ public function __construct(string $className, array $propertyNames = [])
$this->propertySetters = $this->findPropertySetters($propertyNames);
}

public static function allowEmptyStringAsNull(): void
{
self::$emptyStringAsNull = true;
}

public static function disallowEmptyStringAsNull(): void
{
self::$emptyStringAsNull = false;
}

/**
* @param class-string $className
* @param array<?string> $record
Expand Down Expand Up @@ -130,7 +142,12 @@ private function hydrate(object $object, array $record): void
{
$record = array_values($record);
foreach ($this->propertySetters as $propertySetter) {
$propertySetter($object, $record[$propertySetter->offset]);
$value = $record[$propertySetter->offset];
if (self::$emptyStringAsNull && '' === $value) {
$value = null;
}

$propertySetter($object, $value);
}
}

Expand Down Expand Up @@ -175,7 +192,7 @@ private function findPropertySetters(array $propertyNames): array
$propertySetters[] = $this->autoDiscoverPropertySetter($property, $offset);
}

$propertySetters = [...$propertySetters, ...$this->findPropertySettersByCellAttribute($propertyNames)];
$propertySetters = [...$propertySetters, ...$this->findPropertySettersByAttribute($propertyNames)];
if ([] === $propertySetters) {
throw new MappingFailed('No properties or method setters were found eligible on the class `'.$this->class->getName().'` to be used for type casting.');
}
Expand All @@ -198,7 +215,7 @@ private function autoDiscoverPropertySetter(ReflectionProperty $property, int $o
*
* @return array<PropertySetter>
*/
private function findPropertySettersByCellAttribute(array $propertyNames): array
private function findPropertySettersByAttribute(array $propertyNames): array
{
$addPropertySetter = function (array $carry, ReflectionProperty|ReflectionMethod $accessor) use ($propertyNames) {
$propertySetter = $this->findPropertySetter($accessor, $propertyNames);
Expand Down Expand Up @@ -272,18 +289,23 @@ private function resolveTypeCasting(ReflectionProperty|ReflectionParameter $refl
{
$reflectionType = $reflectionProperty->getType();
if (null === $reflectionType) {
throw new MappingFailed('The property `'.$reflectionProperty->getName().'` must be typed.');
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($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 */
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) {
Expand Down Expand Up @@ -322,13 +344,6 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod
return $cast;
}

if (null === $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 $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.',
Expand Down

0 comments on commit 876e71f

Please sign in to comment.