Skip to content

Commit

Permalink
Improve Serializer feature, nullable can be configured per field
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Sep 23, 2024
1 parent fb8af3e commit 0384285
Show file tree
Hide file tree
Showing 14 changed files with 272 additions and 90 deletions.
140 changes: 104 additions & 36 deletions docs/9.0/reader/record-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -125,10 +124,14 @@ PHP attributes:

- the `League\Csv\Serializer\MapCell`
- the `League\Csv\Serializer\AfterMapping`
- the `League\Csv\Serializer\MapRecord`

<p class="message-info">The <code>AfterMapping</code> attribute is added in version <code>9.13.0</code></p>
<p class="message-info">The <code>MapRecord</code> attribute is added in version <code>9.17.0</code></p>
<p class="message-notice">Before version <code>9.17.0</code> the celle value must be a <code>string</code> or <code>null</code>.
This limitation no longer exists.</p>

### Improving record mapping
### Improving field mapping

Here's an example of how the `League\Csv\Serializer\MapCell` attribute works:

Expand Down Expand Up @@ -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.

<p class="message-info">The <code>convertEmptyStringToNull</code> argument was added in version <code>9.17.0</code></p>
<p class="message-info">The <code>ignore</code> argument was added in version <code>9.13.0</code></p>
<p class="message-info">You can use the mechanism on a CSV without a header row but it requires
adding a <code>MapCell</code> attribute on each property or method needed for the conversion. Or you
Expand All @@ -171,18 +176,87 @@ header value, just like with <code>TabularDataReader::getRecords</code></p>

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

<p class="message-info">The feature is available since version <code>9.13.0</code></p>
<p class="message-info">The <code>MapRecord</code> attribute is added in <code>9.17.0</code></p>

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(
Expand All @@ -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.

<p class="message-notice">If the method does not exist or requires explicit arguments an exception will be thrown.</p>

### 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
}
}
```

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/Serializer/AfterMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,4 +29,43 @@ public function __construct(string ...$methods)
{
$this->methods = $methods;
}

/**
*
* @return array<ReflectionMethod>
*/
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();
}
}
6 changes: 4 additions & 2 deletions src/Serializer/CallbackCasting.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/Serializer/CastToBool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Serializer/CastToDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/Serializer/CastToEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
6 changes: 4 additions & 2 deletions src/Serializer/CastToFloat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/Serializer/CastToInt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/Serializer/CastToString.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit 0384285

Please sign in to comment.