Skip to content

Commit

Permalink
Improving denormalization
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Sep 21, 2024
1 parent bbfefd8 commit da8f41d
Show file tree
Hide file tree
Showing 12 changed files with 182 additions and 86 deletions.
132 changes: 97 additions & 35 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 @@ -171,18 +174,82 @@ 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

<p class="message-info">The feature is available since version <code>9.13.0</code></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:

- `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. To do so, you first need to disable the
auto-conversion using `Denormalizer::disallowEmptyStringAsNull`. Once this is done you can either control
the conversion at the field level using the option field `emptyStringAsNull` as shown in the example below:

```php
#[Serializer\MapCell(options: ['emptyStringAsNull' => true])]
private DateTimeImmutable $observedOn;
```

When the value is set to boolean `true` the auto-conversion from the empty string to `null` will happen. By default,
the value is `false` and out of the box no conversion is done.

If you want to control the conversion at the object level you can use the attribute called `MapRecord`:

```php
#[Serializer\MapRecord(emptyStringAsNull: 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 otherwise and by default, no conversion is done.

### Post Mapping

<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 +271,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`
<p class="message-info">The feature is available since version <code>9.13.0</code></p>

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 `League\Csv\Serializer\AfterMapping` attribute is deprecated in favor of the `MapRecord` attribute. The
example is left of 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
15 changes: 10 additions & 5 deletions src/Serializer/CallbackCasting.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
*/
final class CallbackCasting implements TypeCasting
{
use EmptyStringAsNull;

/** @var array<string, Closure(mixed, bool, mixed...): mixed> */
private static array $types = [];

Expand Down Expand Up @@ -60,8 +62,11 @@ public function __construct(
/**
* @throws MappingFailed
*/
public function setOptions(?string $type = null, mixed ...$options): void
{
public function setOptions(
?string $type = null,
bool $emptyStringAsNull = false,
mixed ...$options
): void {
if (null === $this->alias) {
if (Type::Mixed->value === $this->type && null !== $type) {
$this->type = $type;
Expand All @@ -85,16 +90,16 @@ public function setOptions(?string $type = null, mixed ...$options): void
$callback = self::$aliases[$this->type][$this->alias];
$this->callback = $callback;
$this->options = $options;

$this->setEmptyStringAsNull($emptyStringAsNull);
}

/**
* @return TValue
*/
public function toVariable(mixed $value): mixed
{
if ('' === $value && isset($this->options['allowEmptyStringAsNull']) && $this->options['allowEmptyStringAsNull']) {
$value = null;
}
$value = $this->treatEmptyStringAsNull($value);

try {
return ($this->callback)($value, $this->isNullable, ...$this->options);
Expand Down
11 changes: 5 additions & 6 deletions src/Serializer/CastToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
*/
final class CastToArray implements TypeCasting
{
use EmptyStringAsNull;

private readonly Type $type;
private readonly bool $isNullable;
private ArrayShape $shape;
Expand All @@ -44,7 +46,6 @@ final class CastToArray implements TypeCasting
private int $depth = 512;
private int $flags = 0;
private ?array $default = null;
private bool $allowEmptyStringAsNull = false;

/**
* @throws MappingFailed
Expand Down Expand Up @@ -72,7 +73,7 @@ public function setOptions(
int $depth = 512,
int $flags = 0,
Type|string $type = Type::String,
?bool $allowEmptyStringAsNull = null,
bool $emptyStringAsNull = false,
): void {
if (!$shape instanceof ArrayShape) {
$shape = ArrayShape::tryFrom($shape) ?? throw new MappingFailed('Unable to resolve the array shape; Verify your options arguments.');
Expand All @@ -96,14 +97,12 @@ public function setOptions(
1 !== strlen($this->enclosure) && $this->shape->equals(ArrayShape::Csv) => throw new MappingFailed('expects enclosure to be a single character; `'.$this->enclosure.'` given.'),
default => $this->resolveFilterFlag($type),
};
$this->allowEmptyStringAsNull = $allowEmptyStringAsNull ?? false;
$this->setEmptyStringAsNull($emptyStringAsNull);
}

public function toVariable(mixed $value): ?array
{
if ('' === $value && $this->allowEmptyStringAsNull) {
$value = null;
}
$value = $this->treatEmptyStringAsNull($value);

if (null === $value) {
return match (true) {
Expand Down
12 changes: 6 additions & 6 deletions src/Serializer/CastToBool.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
*/
final class CastToBool implements TypeCasting
{
use EmptyStringAsNull;

private readonly bool $isNullable;
private readonly Type $type;
private ?bool $default = null;
private bool $allowEmptyStringAsNull = false;
private bool $emptyStringAsNull = false;

public function __construct(ReflectionProperty|ReflectionParameter $reflectionProperty)
{
Expand All @@ -35,20 +37,18 @@ public function __construct(ReflectionProperty|ReflectionParameter $reflectionPr

public function setOptions(
?bool $default = null,
?bool $allowEmptyStringAsNull = null,
bool $emptyStringAsNull = false,
): void {
$this->default = $default;
$this->allowEmptyStringAsNull = $allowEmptyStringAsNull ?? false;
$this->setEmptyStringAsNull($emptyStringAsNull);
}

/**
* @throws TypeCastingFailed
*/
public function toVariable(mixed $value): ?bool
{
if ('' === $value && $this->allowEmptyStringAsNull) {
$value = null;
}
$value = $this->treatEmptyStringAsNull($value);

$returnValue = match (true) {
is_bool($value) => $value,
Expand Down
13 changes: 6 additions & 7 deletions src/Serializer/CastToDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
*/
final class CastToDate implements TypeCasting
{
use EmptyStringAsNull;

/** @var class-string */
private string $class;
private readonly bool $isNullable;
Expand All @@ -38,7 +40,6 @@ final class CastToDate implements TypeCasting
private readonly string $propertyName;
private ?DateTimeZone $timezone = null;
private ?string $format = null;
private bool $allowEmptyStringAsNull = false;

/**
* @throws MappingFailed
Expand All @@ -60,7 +61,7 @@ public function setOptions(
?string $format = null,
DateTimeZone|string|null $timezone = null,
?string $className = null,
?bool $allowEmptyStringAsNull = null,
bool $emptyStringAsNull = false,
): void {
$this->class = match (true) {
!interface_exists($this->class) && !Type::Mixed->equals($this->type) => $this->class,
Expand All @@ -76,14 +77,16 @@ interface_exists($this->class) && null !== $className && class_exists($className
} catch (Throwable $exception) {
throw new MappingFailed('The `timezone` and/or `format` options used for `'.self::class.'` are invalud.', 0, $exception);
}
$this->allowEmptyStringAsNull = $allowEmptyStringAsNull ?? false;
$this->setEmptyStringAsNull($emptyStringAsNull);
}

/**
* @throws TypeCastingFailed
*/
public function toVariable(mixed $value): DateTimeImmutable|DateTime|null
{
$value = $this->treatEmptyStringAsNull($value);

return match (true) {
null !== $value && '' !== $value => $this->cast($value),
$this->isNullable => $this->default,
Expand All @@ -96,10 +99,6 @@ public function toVariable(mixed $value): DateTimeImmutable|DateTime|null
*/
private function cast(mixed $value): DateTimeImmutable|DateTime
{
if ('' === $value && $this->allowEmptyStringAsNull) {
$value = null;
}

if ($value instanceof DateTimeInterface) {
if ($value instanceof $this->class) {
return $value;
Expand Down
Loading

0 comments on commit da8f41d

Please sign in to comment.