Skip to content

Commit

Permalink
Improve Serializer feature
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Nov 14, 2023
1 parent a00ef2c commit 46e94ed
Show file tree
Hide file tree
Showing 18 changed files with 748 additions and 225 deletions.
61 changes: 33 additions & 28 deletions docs/9.0/reader/record-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ The attribute can take up to three (3) arguments which are all optional:
- The `cast` argument which accept the name of a class implementing the `TypeCasting` interface and responsible for type casting the record value. If not present, the mechanism will try to resolve the typecasting based on the propery or method argument type.
- The `castArguments` argument enables controlling typecasting by providing extra arguments to the `TypeCasting` class constructor. The argument expects an associative array and relies on named arguments to inject its value to the `TypeCasting` implementing class constructor.

<p class="message-warning">The <code>propertyType</code> key can not be used with the <code>castArguments</code> as it is a reserved argument used by the <code>TypeCasting</code> class.</p>
<p class="message-warning">The <code>reflectionProperty</code> key can not be used with the <code>castArguments</code> as it is a reserved argument used by the <code>TypeCasting</code> class.</p>

In any case, if type casting fails, an exception will be thrown.

Expand Down Expand Up @@ -329,7 +329,7 @@ use League\Csv\Serializer;

#[Serializer\Cell(
offset: 'amount',
cast: App\Domain\CastToMoney::class,
cast: App\Domain\CastToNaira::class,
castArguments: ['default' => 100_00]
)]
private ?Money $naira;
Expand All @@ -340,8 +340,8 @@ To allow your object to cast the cell value to your liking it needs to implement
To do so, you must define a `toVariable` method that will return the correct value once converted.

<p class="message-warning"><strong>Of note</strong> The class constructor method must take the property type value as
one of its argument with the name <code>$propertyType</code>. This means you <strong>can not</strong> use the
<code>propertyType</code> as a possible key of the associative array given to <code>castArguments</code></p>
one of its argument with the name <code>$reflectionProperty</code>. This means you <strong>can not</strong> use the
<code>reflectionProperty</code> as a possible key of the associative array given to <code>castArguments</code></p>

```php
use App\Domain\Money;
Expand All @@ -352,44 +352,49 @@ use League\Csv\Serializer\TypeCastingFailed;
/**
* @implements TypeCasting<Money|null>
*/
final class CastToMoney implements TypeCasting
final class CastToNaira implements TypeCasting
{
private readonly ?Money $default;
private readonly bool $isNullable;
private readonly Money $default;

public function __construct(
string $propertyType, //always required and given by the Serializer implementation
int $default = null,
ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Serializer
?int $default = null
) {
$this->isNullable = str_starts_with($type, '?');

//the type casting class must only work with the declared type
//Here the TypeCasting object only cares about converting
//data into a Money instance.
if (Money::class !== ltrim($propertyType, '?')) {
throw new MappingFailed('The class '. self::class . ' can only work with `' . Money::class . '` typed property.');
}

if (null !== $default) {
try {
$this->default = $this->toVariable($default);
} catch (TypeCastingFailed $exception) {
throw new MappingFailed('Unable to cast the default value `'.$value.'` to a `'.Money::class.'`.', 0, $exception);
}
$default = Money::fromNaira($default);
}
$this->default = $default;

// To be more strict during conversion you SHOULD handle the $reflectionProperty argument.
// The argument gives you access to all the information about the property.
// it allows validating that the argument does support your casting
// it allows adding support to union, intersection or unnamed type
// it tells whether the property/argument is nullable or not

$reflectionType = $reflectionProperty->getType();
if (!$reflectionType instanceof ReflectionNamedType || !in_array($reflectionType->getName(), [Money::class, 'mixed'], true)) {
throw new MappingFailed(match (true) {
$reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` is not typed with the '.Money::class.' class or with `mixed`.',
$reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` is not typed with the '.Money::class.' class or with `mixed`.',
});
}
$this->isNullable = $reflectionType->allowsNull();
}

public function toVariable(?string $value): ?Money
{
try {
// if the property is declared as nullable we exist early
if (null === $value && $this->isNullable) {
return $this->default;
}

return Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT));
return match (true) {
$this->isNullable && null === $value => $this->default,
default => Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)),
};
} catch (Throwable $exception) {
throw new TypeCastingFailed('Unable to cast the given data `'.$value.'` to a `'.Money::class.'`.', 0, $exception);
}
}
}
```

<p class="message-info">While the built-in <code>TypeCasting</code> classes do not support Intersection Type, your own
implementing class can support them via inspection of the <code>$reflectionProperty</code> argument.</p>
83 changes: 30 additions & 53 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionProperty;
use ReflectionType;
use ReflectionUnionType;
use Throwable;

use function array_key_exists;
use function array_reduce;
use function array_search;
use function array_values;
use function count;
use function in_array;
use function is_int;

final class Serializer
Expand Down Expand Up @@ -184,15 +185,9 @@ private function findPropertySetters(array $propertyNames): array

private function autoDiscoverPropertySetter(ReflectionProperty $property, int $offset): PropertySetter
{
$propertyName = $property->getName();
$type = $property->getType();
if (null === $type) {
throw new MappingFailed('The property `'.$propertyName.'` must be typed.');
}

$cast = $this->resolveTypeCasting($type);
$cast = $this->resolveTypeCasting($property);
if (null === $cast) {
throw new MappingFailed('No valid type casting for `'.$type.' $'.$propertyName.'`.');
throw new MappingFailed('No built-in `'.TypeCasting::class.'` class can handle `$'.$property->getName().'` type.');
}

return new PropertySetter($property, $offset, $cast);
Expand Down Expand Up @@ -273,19 +268,22 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso
*
* @throws MappingFailed If the arguments do not match the expected TypeCasting class constructor signature
*/
private function resolveTypeCasting(ReflectionType $reflectionType, array $arguments = []): ?TypeCasting
private function resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): ?TypeCasting
{
$type = (string) $this->getAccessorType($reflectionType);
$reflectionType = $reflectionProperty->getType();
if (null === $reflectionType) {
throw new MappingFailed('The property `'.$reflectionProperty->getName().'` must be typed.');
}

try {
return match (Type::tryFromPropertyType($type)) {
Type::Mixed, Type::Null, Type::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */
Type::Iterable, Type::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */
Type::False, Type::True, Type::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */
Type::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */
Type::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */
Type::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */
Type::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */
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 */
null => null,
};
} catch (Throwable $exception) {
Expand All @@ -302,13 +300,13 @@ private function resolveTypeCasting(ReflectionType $reflectionType, array $argum
*/
private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod $accessor): TypeCasting
{
if (array_key_exists('propertyType', $cell->castArguments)) {
if (array_key_exists('reflectionProperty', $cell->castArguments)) {
throw new MappingFailed('The key `propertyType` can not be used with `castArguments`.');
}

$type = match (true) {
$accessor instanceof ReflectionMethod => $accessor->getParameters()[0]->getType(),
$accessor instanceof ReflectionProperty => $accessor->getType(),
$reflectionProperty = match (true) {
$accessor instanceof ReflectionMethod => $accessor->getParameters()[0],
$accessor instanceof ReflectionProperty => $accessor,
};

$typeCaster = $cell->cast;
Expand All @@ -317,44 +315,23 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod
throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.');
}

$arguments = [...$cell->castArguments, ...['propertyType' => (string) $this->getAccessorType($type)]];
$arguments = [...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]];
/** @var TypeCasting $cast */
$cast = new $typeCaster(...$arguments);

return $cast;
}

if (null === $type) {
if (null === $reflectionProperty->getType()) {
throw new MappingFailed(match (true) {
$accessor instanceof ReflectionMethod => 'The setter method argument `'.$accessor->getParameters()[0]->getName().'` must be typed.',
$accessor instanceof ReflectionProperty => 'The property `'.$accessor->getName().'` must be typed.',
$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($type, $cell->castArguments) ?? throw new MappingFailed(match (true) {
$accessor instanceof ReflectionMethod => 'No valid type casting was found for the setter method argument `'.$accessor->getParameters()[0]->getName().'` must be typed.',
$accessor instanceof ReflectionProperty => 'No valid type casting was found for the property `'.$accessor->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.',
});
}

private function getAccessorType(?ReflectionType $type): ?string
{
return match (true) {
null === $type => null,
$type instanceof ReflectionNamedType => $type->getName(),
$type instanceof ReflectionUnionType => implode('|', array_reduce(
$type->getTypes(),
function (array $carry, ReflectionType $type): array {
$result = $this->getAccessorType($type);

return match ('') {
$result => $carry,
default => [...$carry, $result],
};
},
[]
)),
default => '',
};
}
}
44 changes: 31 additions & 13 deletions src/Serializer/CastToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@

use JsonException;

use ReflectionParameter;
use ReflectionProperty;

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;
Expand All @@ -31,7 +32,7 @@
*/
final class CastToArray implements TypeCasting
{
private readonly string $class;
private readonly Type $type;
private readonly bool $isNullable;
private readonly int $filterFlag;
private readonly ArrayShape $shape;
Expand All @@ -43,7 +44,7 @@ final class CastToArray implements TypeCasting
* @throws MappingFailed
*/
public function __construct(
string $propertyType,
ReflectionProperty|ReflectionParameter $reflectionProperty,
private readonly ?array $default = null,
ArrayShape|string $shape = ArrayShape::List,
private readonly string $delimiter = ',',
Expand All @@ -52,14 +53,7 @@ public function __construct(
private readonly int $jsonFlags = 0,
Type|string $type = Type::String,
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Array, Type::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 = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');

[$this->type, $this->isNullable] = $this->init($reflectionProperty);
if (!$shape instanceof ArrayShape) {
$shape = ArrayShape::tryFrom($shape) ?? throw new MappingFailed('Unable to resolve the array shape; Verify your cast arguments.');
}
Expand All @@ -79,7 +73,7 @@ public function toVariable(?string $value): ?array
if (null === $value) {
return match (true) {
$this->isNullable,
Type::tryFrom($this->class)?->equals(Type::Mixed) => $this->default,
Type::Mixed->equals($this->type) => $this->default,
default => throw new TypeCastingFailed('The `null` value can not be cast to an `array`; the property type is not nullable.'),
};
}
Expand Down Expand Up @@ -125,4 +119,28 @@ private function resolveFilterFlag(Type|string $type): int
default => $type->filterFlag(),
};
}

/**
* @return array{0:Type, 1:bool}
*/
private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): array
{
$type = null;
$isNullable = false;
foreach (Type::list($reflectionProperty) as $found) {
if (!$isNullable && $found[1]->allowsNull()) {
$isNullable = true;
}

if (null === $type && $found[0]->isOneOf(Type::Mixed, Type::Array, Type::Iterable)) {
$type = $found;
}
}

if (null === $type) {
throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed` or `bool` type is required.');
}

return [$type[0], $isNullable];
}
}
Loading

0 comments on commit 46e94ed

Please sign in to comment.