Skip to content

Commit

Permalink
Improve the TypeCasting mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Nov 9, 2023
1 parent 572fd0b commit 767f434
Show file tree
Hide file tree
Showing 17 changed files with 461 additions and 217 deletions.
38 changes: 21 additions & 17 deletions docs/9.0/reader/record-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ text based deserialization mechanism for tabular data.

The class exposes two (2) methods to ease `array` to `object` conversion:

- `Serializer::deserialize` converts a single record into an instance of the specified class.
- `Serializer::deserializeAll` converts a collection of records and returns a collection of the specified class instances.
- `Serializer::deserialize` to convert a single record into a new instance of the specified class.
- `Serializer::deserializeAll` to do the same with a collection of records..

```php
use League\Csv\Serializer;
Expand Down Expand Up @@ -54,8 +54,8 @@ In the following sections we will explain the conversion and how it can be confi

## Pre-requisite

The deserialization mechanism works mainly with DTO or objects which can be built
without complex logic.
The deserialization mechanism works mainly with DTO or objects
without complex logic in their constructors.

<p class="message-notice">The mechanism relies on PHP's <code>Reflection</code>
feature. It does not use the class constructor to perform the conversion.
Expand Down Expand Up @@ -91,8 +91,7 @@ final readonly class Weather
public function __construct(
public float $temperature,
public Place $place,
#[Cell(castArguments: ['format' => '!Y-m-d'])]
public DateTimeImmutable $date;
public DateTimeImmutable $date,
) {
}
}
Expand Down Expand Up @@ -122,7 +121,7 @@ foreach ($serializer->deserializeAll($csv) as $weather) {

By default, the deserialization engine will convert public properties using their name. In other words,
if there is a class property, which name is the same as a column name, the column value will be assigned
to this property. The appropriate type is used if the record cell value is a `string` or `null` and
to this property. The appropriate type used for the record cell value is a `string` or `null` and
the object public properties ares typed with

- a scalar type (`string`, `int`, `float`, `bool`)
Expand Down Expand Up @@ -217,23 +216,28 @@ Converts the cell value into a PHP `DateTimeInterface` implementing object. You

### CastToArray

Converts the value into a PHP `array`. You are required to specify what type of conversion you desired (`list`, `json` or `csv`).
Converts the value into a PHP `array`. You are required to specify the array shape for the conversion to happen. The class
provides three (3) shapes:

- `list` converts the string using PHP `explode` function;
- `json` converts the string using PHP `json_decode` function;
- `csv` converts the string using PHP `str_fgetcsv` function;

The following are example for each type:

```php
$array['field1'] = "1,2,3,4"; //the string contains only a separator (type list)
$arrat['field2'] = '"1","2","3","4"'; //the string contains delimiter and enclosure (type csv)
$arrat['field3'] = '{"foo":"bar"}'; //the string is a json string (type json)
$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)
```

in case of
you can optionally configure for

- the `list` type you can configure the `delimiter`, by default it is the `,`;
- the `csv` type you can configure the `delimiter` and the `enclosure`, by default they are respectively `,` and `"`;
- the `json` type you can configure the `jsonDepth` and the `jsonFlags` options just like when using the `json_decode` arguments, the default are the same;
- the `list` shape, the `delimiter` and the array value `type` (only scalar type are supported), by default they are respectively `,` and `string`;
- the `csv` shape, the `delimiter`, the `enclosure` and the array value `type` (only scalar type are supported), by default they are respectively `,`, `"` and `string`;
- the `json` shape, the `jsonDepth` and the `jsonFlags` options just like when using the `json_decode` arguments, with the same default;

Here's a example for casting a string via the `json` type.
Here's an example for casting a string via the `json` type.

```php
use League\Csv\Serializer;
Expand Down Expand Up @@ -261,7 +265,7 @@ use App\Domain\Money
use League\Csv\Serializer;

#[Serializer\Cell(
offset: 'amout',
offset: 'amount',
cast: CastToMoney::class,
castArguments: ['min' => -10000_00, 'max' => 10000_00, 'default' => 100_00]
)]
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ parameters:
checkMissingIterableValueType: false
ignoreErrors:
reportUnmatchedIgnoredErrors: true
treatPhpDocTypesAsCertain: false
27 changes: 18 additions & 9 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ArrayIterator;
use Iterator;
use League\Csv\Serializer\BasicType;
use League\Csv\Serializer\CastToArray;
use League\Csv\Serializer\CastToBool;
use League\Csv\Serializer\CastToDate;
Expand All @@ -37,6 +38,12 @@
use Throwable;
use TypeError;

use function array_reduce;
use function array_search;
use function array_values;
use function is_array;
use function is_int;

final class Serializer
{
private readonly ReflectionClass $class;
Expand Down Expand Up @@ -260,7 +267,7 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso
}

/**
* @param array<string, mixed> $arguments
* @param array<string, array<string|int|float|bool>|string|int|float|bool> $arguments
*
* @throws MappingFailed If the arguments do not match the expected TypeCasting class constructor signature
*/
Expand All @@ -272,14 +279,16 @@ private function resolveTypeCasting(ReflectionType $reflectionType, array $argum
}

try {
return match (true) {
CastToString::supports($type) => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */
CastToInt::supports($type) => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */
CastToFloat::supports($type) => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */
CastToBool::supports($type) => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */
CastToDate::supports($type) => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */
CastToArray::supports($type) => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */
CastToEnum::supports($type) => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */
return match (BasicType::tryFromPropertyType($type)) {
BasicType::Mixed,
BasicType::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Iterable,
BasicType::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */
BasicType::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */
default => null,
};
} catch (Throwable $exception) {
Expand Down
30 changes: 30 additions & 0 deletions src/Serializer/ArrayShape.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\Csv\Serializer;

enum ArrayShape: string
{
case List = 'list';
case Csv = 'csv';
case Json = 'json';

public function equals(mixed $value): bool
{
return $value instanceof self
&& $value === $this;
}

public function isOneOf(self ...$types): bool
{
return in_array($this, $types, true);
}
}
48 changes: 47 additions & 1 deletion src/Serializer/BasicType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@

namespace League\Csv\Serializer;

use DateTimeInterface;

use function class_exists;
use function class_implements;
use function enum_exists;
use function in_array;
use function interface_exists;
use function ltrim;

use const FILTER_UNSAFE_RAW;
use const FILTER_VALIDATE_BOOL;
use const FILTER_VALIDATE_FLOAT;
use const FILTER_VALIDATE_INT;

enum BasicType: string
{
case Bool = 'bool';
Expand All @@ -20,6 +34,8 @@ enum BasicType: string
case Mixed = 'mixed';
case Array = 'array';
case Iterable = 'iterable';
case Enum = 'enum';
case Date = 'date';

public function equals(mixed $value): bool
{
Expand All @@ -34,6 +50,36 @@ public function isOneOf(self ...$types): bool

public static function tryFromPropertyType(string $propertyType): ?self
{
return self::tryFrom(ltrim($propertyType, '?'));
$type = ltrim($propertyType, '?');
$basicType = self::tryFrom($type);

return match (true) {
$basicType instanceof self => $basicType,
enum_exists($type) => self::Enum,
interface_exists($type) && DateTimeInterface::class === $type,
class_exists($type) && in_array(DateTimeInterface::class, class_implements($type), true) => self::Date,
default => null,
};
}

public function filterFlag(): int
{
return match ($this) {
self::Bool => FILTER_VALIDATE_BOOL,
self::Int => FILTER_VALIDATE_INT,
self::Float => FILTER_VALIDATE_FLOAT,
default => FILTER_UNSAFE_RAW,
};
}

public function isScalar(): bool
{
return match ($this) {
self::Bool,
self::Int,
self::Float,
self::String => true,
default => false,
};
}
}
84 changes: 57 additions & 27 deletions src/Serializer/CastToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,32 @@

use JsonException;

use function explode;
use function is_array;
use function json_decode;
use function ltrim;
use function str_getcsv;
use function str_starts_with;
use function strlen;

use const FILTER_REQUIRE_ARRAY;
use const JSON_THROW_ON_ERROR;

/**
* @implements TypeCasting<array|null>
*/
final class CastToArray implements TypeCasting
{
public const TYPE_JSON = 'json';
public const TYPE_LIST = 'list';
public const TYPE_CSV = 'csv';
public const SHAPE_JSON = 'json';
public const SHAPE_LIST = 'list';
public const SHAPE_CSV = 'csv';

private readonly string $class;
private readonly bool $isNullable;

public static function supports(string $propertyType): bool
{
return BasicType::tryFromPropertyType($propertyType)
?->isOneOf(BasicType::Mixed, BasicType::Array, BasicType::Iterable)
?? false;
}
private readonly int $filterFlag;
private readonly ArrayShape $shape;

/**
* @param 'json'|'csv'|'list' $type
* @param non-empty-string $delimiter
* @param int<1, max> $jsonDepth
*
Expand All @@ -46,25 +49,32 @@ public static function supports(string $propertyType): bool
public function __construct(
string $propertyType,
private readonly ?array $default = null,
private readonly string $type = self::TYPE_LIST,
ArrayShape|string $shape = 'list',
private readonly string $delimiter = ',',
private readonly string $enclosure = '"',
private readonly int $jsonDepth = 512,
private readonly int $jsonFlags = 0
private readonly int $jsonFlags = 0,
BasicType|string $type = BasicType::String,
) {
if (!self::supports($propertyType)) {
throw new MappingFailed('The property type is not an array or an iterable structure.');
$baseType = BasicType::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(BasicType::Mixed, BasicType::Array, BasicType::Iterable)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an `array` or an `iterable` structure is required.');
}

$this->class = ltrim($propertyType, '?');
$this->isNullable = str_starts_with($propertyType, '?');

match (true) {
!in_array($type, [self::TYPE_JSON, self::TYPE_LIST, self::TYPE_CSV], true) => throw new MappingFailed('Unable to resolve the array.'),
1 > $this->jsonDepth => throw new MappingFailed('the json depth can not be less than 1.'), /* @phpstan-ignore-line */
1 > strlen($this->delimiter) && self::TYPE_LIST === $this->type => throw new MappingFailed('expects delimiter to be a non-empty string for list conversion; emtpy string given.'), /* @phpstan-ignore-line */
1 !== strlen($this->delimiter) && self::TYPE_CSV === $this->type => throw new MappingFailed('expects delimiter to be a single character for CSV conversion; `'.$this->delimiter.'` given.'),
1 !== strlen($this->enclosure) => throw new MappingFailed('expects enclosire to be a single character; `'.$this->enclosure.'` given.'),
default => null,
if (!$shape instanceof ArrayShape) {
$shape = ArrayShape::tryFrom($shape) ?? throw new MappingFailed('Unable to resolve the array shape; Verify your cast arguments.');
}

$this->shape = $shape;
$this->filterFlag = match (true) {
1 > $this->jsonDepth && $this->shape->equals(ArrayShape::Json) => throw new MappingFailed('the json depth can not be less than 1.'),
1 > strlen($this->delimiter) && $this->shape->equals(ArrayShape::List) => throw new MappingFailed('expects delimiter to be a non-empty string for list conversion; emtpy string given.'),
1 !== strlen($this->delimiter) && $this->shape->equals(ArrayShape::Csv) => throw new MappingFailed('expects delimiter to be a single character for CSV conversion; `'.$this->delimiter.'` given.'),
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),
};
}

Expand All @@ -74,7 +84,7 @@ public function toVariable(?string $value): ?array
return match (true) {
$this->isNullable,
BasicType::tryFrom($this->class)?->equals(BasicType::Mixed) => $this->default,
default => throw new TypeCastingFailed('Unable to convert the `null` value.'),
default => throw new TypeCastingFailed('The `null` value can not be cast to an `array`; the property type is not nullable.'),
};
}

Expand All @@ -83,10 +93,10 @@ public function toVariable(?string $value): ?array
}

try {
$result = match ($this->type) {
self::TYPE_JSON => json_decode($value, true, $this->jsonDepth, $this->jsonFlags | JSON_THROW_ON_ERROR),
self::TYPE_LIST => explode($this->delimiter, $value),
default => str_getcsv($value, $this->delimiter, $this->enclosure, ''),
$result = match ($this->shape) {
ArrayShape::Json => json_decode($value, true, $this->jsonDepth, $this->jsonFlags | JSON_THROW_ON_ERROR),
ArrayShape::List => filter_var(explode($this->delimiter, $value), $this->filterFlag, FILTER_REQUIRE_ARRAY),
ArrayShape::Csv => filter_var(str_getcsv($value, $this->delimiter, $this->enclosure, ''), $this->filterFlag, FILTER_REQUIRE_ARRAY),
};

if (!is_array($result)) {
Expand All @@ -99,4 +109,24 @@ public function toVariable(?string $value): ?array
throw new TypeCastingFailed('Unable to cast the given data `'.$value.'` to a PHP array.', 0, $exception);
}
}

/**
* @throws MappingFailed if the type is not supported
*/
private function resolveFilterFlag(BasicType|string $type): int
{
if ($this->shape->equals(ArrayShape::Json)) {
return BasicType::String->filterFlag();
}

if (!$type instanceof BasicType) {
$type = BasicType::tryFrom($type);
}

return match (true) {
!$type instanceof BasicType,
!$type->isScalar() => throw new MappingFailed('Only scalar type are supported for `array` value casting.'),
default => $type->filterFlag(),
};
}
}
Loading

0 comments on commit 767f434

Please sign in to comment.