diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md
index 469de19a..964fbe07 100644
--- a/docs/9.0/reader/record-mapping.md
+++ b/docs/9.0/reader/record-mapping.md
@@ -62,7 +62,7 @@ foreach ($csv->getObjects(Weather::class) as $weather) {
In the following sections we will explain the conversion and how it can be configured.
-## Pre-requisite
+## Prerequisite
The deserialization mechanism works mainly with DTO or objects
without complex logic in their constructors.
@@ -158,16 +158,17 @@ the following type:
the `nullable` aspect of the property is also automatically handled.
To complete the conversion you can use the `Cell` attribute. This attribute will override
-the automatic resolution and enable fine-tuning type casting on the property level.
+the automatic resolution and enable fine-tuning type casting. It can be used on class
+properties and methods regardless of their visibility.
-The `Cell` attribute can be used on class properties and methods regardless of their visibility.
The attribute can take up to three (3) arguments which are all optional:
- The `offset` argument tells the engine which record key to use via its numeric or name offset. If not present the property name or the name of the first argument of the `setter` method will be used. In such case, you are required to specify the property names information.
- 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.
-
The reflectionProperty
key can not be used with the castArguments
as it is a reserved argument used by the TypeCasting
class.
+The reflectionProperty
key can not be used with the
+castArguments
as it is a reserved argument used by the TypeCasting
class.
In any case, if type casting fails, an exception will be thrown.
@@ -190,21 +191,21 @@ private CarbonImmutable $observedOn;
The above rule can be translated in plain english like this:
-> convert the value of the associative array whose key is `date` into a `CarbonImmutable` object
+> Convert the value of the associative array whose key is `date` into a `CarbonImmutable` object
> using the date format `!Y-m-d` and the `Africa/Nairobi` timezone. Once created,
-> inject the date instance into the class private property `observedOn`.
+> inject the 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.
+Out of the box the `Serializer` makes no distinction between an empty 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.
+When called these methods will change the class behaviour when it comes to handling empty string.
+`Serializer::allowEmptyStringAsNull` will trigger conversion of 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
@@ -221,8 +222,9 @@ $weather->temperature; // returns null
Serializer::disallowEmptyStringAsNull();
Serializer::assign(Weather::class, $record);
-//a TypeCastingFailed exception is thrown
+//a TypeCastingFailed exception is thrown because we
//can not convert the empty string into a temperature property
+//which expects `null` or a non-empty string.
```
## Type casting
@@ -248,6 +250,8 @@ optional argument `default` which is the default value to return if the value is
Converts the array value to `true`, `false` or `null` depending on the property type information. The class takes one
optional argument `default` which is the default boolean value to return if the value is `null`.
+Since typecasting relies on `ext-filter` rules, the following strings `1`, `true`, `on` and `yes` will all be cast
+in a case-insensitive way to `true` otherwise `false` will be used.
### CastToInt and CastToFloat
@@ -349,9 +353,62 @@ public function setData(array $data): void;
If the conversion succeeds, then the property will be set with an `array` of `float` values.
The `type` option only supports scalar type (`string`, `int`, `float` and `bool`)
-### Creating your own TypeCasting class
+## Extending Type Casting capabilities
-You can also provide your own class to typecast the array value according to your own rules. To do so, first,
+We provide two mechanisms to extends typecasting. You can register a closure via the `Serializer` class
+or create a fully fledge `TypeCasting` class. Of course, the choice will depend on your use case.
+
+### Registering a closure
+
+You can register a closure using the `Serializer` class to convert a specific type. The type can be
+any built-in type or a specific class.
+
+```php
+use App\Domain\Money;
+use League\Csv\Serializer;
+
+Serializer::registerType(Money::class, fn (?string $value, bool $isNullable, ?int $default = null): Money => match (true) {
+ $isNullable && null === $value => Money::fromNaira($default ?? 20_00),
+ default => Money::fromNaira(filter_var($value, FILTER_VALIDATE_INT)),
+});
+```
+
+The Serializer will automatically call the closure for any `App\Domain\Money` conversion.
+
+```php
+use League\Csv\Serializer;
+
+Serializer::registerType('int', fn (?string $value): int => 42);
+```
+
+In the following example, the closure takes precedence over the `CastToInt` class to convert
+to the `int` type. If you still wish to use the `CastToInt` class you are require to
+explicitly declare it using the `Cell` attribute `cast` argument.
+
+The closure signature is the following:
+
+```php
+closure(?string $value, bool $isNullable, ...$arguments): mixed;
+```
+
+where:
+
+- the `$value` is the record value
+- the `$isNullable` tells whether the argument or property can be nullable
+- the `$arguments` are the extra configuration options you can pass to the `Cell` attribute via `castArguments`
+
+To complete the feature you can use:
+
+- `Serializer::unregisterType` to remove the registered closure for a specific `type`;
+
+The two (2) methods are static.
+
+the mechanism does not support IntersectionType
+
+### Implementing a TypeCasting class
+
+If you need to support `Intersection` type, or you want to be able to fine tune the typecasting
+you can provide your own class to typecast the value according to your own rules. To do so, first,
specify your casting with the attribute:
```php
@@ -361,7 +418,7 @@ use League\Csv\Serializer;
#[Serializer\Cell(
offset: 'amount',
cast: App\Domain\CastToNaira::class,
- castArguments: ['default' => 100_00]
+ castArguments: ['default' => 20_00]
)]
private ?Money $naira;
```
@@ -390,15 +447,15 @@ final class CastToNaira implements TypeCasting
public function __construct(
ReflectionProperty|ReflectionParameter $reflectionProperty, //always given by the Serializer
- ?int $default = null
+ ?int $default = null //can be filled via the Cell castArguments array destructuring
) {
if (null !== $default) {
$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 is recommended to handle the $reflectionProperty argument.
+ // The argument gives you access to property/argument information.
// 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
diff --git a/src/Serializer.php b/src/Serializer.php
index c94f2e31..e6c18511 100644
--- a/src/Serializer.php
+++ b/src/Serializer.php
@@ -13,6 +13,7 @@
namespace League\Csv;
+use Closure;
use Iterator;
use League\Csv\Serializer\CastToArray;
use League\Csv\Serializer\CastToBool;
@@ -22,6 +23,7 @@
use League\Csv\Serializer\CastToInt;
use League\Csv\Serializer\CastToString;
use League\Csv\Serializer\Cell;
+use League\Csv\Serializer\ClosureCasting;
use League\Csv\Serializer\MappingFailed;
use League\Csv\Serializer\PropertySetter;
use League\Csv\Serializer\Type;
@@ -77,6 +79,16 @@ public static function disallowEmptyStringAsNull(): void
self::$emptyStringAsNull = false;
}
+ public static function registerType(string $type, Closure $closure): void
+ {
+ ClosureCasting::register($type, $closure);
+ }
+
+ public static function unregisterType(string $type): void
+ {
+ ClosureCasting::unregister($type);
+ }
+
/**
* @param class-string $className
* @param array $record
@@ -137,13 +149,15 @@ public function deserialize(array $record): object
/**
* @param array $record
+ *
+ * @throws TypeCastingFailed
*/
private function hydrate(object $object, array $record): void
{
$record = array_values($record);
foreach ($this->propertySetters as $propertySetter) {
$value = $record[$propertySetter->offset];
- if (self::$emptyStringAsNull && '' === $value) {
+ if ('' === $value && self::$emptyStringAsNull) {
$value = null;
}
@@ -189,7 +203,7 @@ private function findPropertySetters(array $propertyNames): array
continue;
}
- $propertySetters[] = $this->autoDiscoverPropertySetter($property, $offset);
+ $propertySetters[] = new PropertySetter($property, $offset, $this->resolveTypeCasting($property));
}
$propertySetters = [...$propertySetters, ...$this->findPropertySettersByAttribute($propertyNames)];
@@ -200,16 +214,6 @@ private function findPropertySetters(array $propertyNames): array
return $propertySetters;
}
- private function autoDiscoverPropertySetter(ReflectionProperty $property, int $offset): PropertySetter
- {
- $cast = $this->resolveTypeCasting($property);
- if (null === $cast) {
- throw new MappingFailed('No built-in `'.TypeCasting::class.'` class can handle `$'.$property->getName().'` type.');
- }
-
- return new PropertySetter($property, $offset, $cast);
- }
-
/**
* @param array $propertyNames
*
@@ -280,50 +284,13 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso
return new PropertySetter($accessor, $index, $cast);
}
- /**
- * @param array|string|int|float|bool> $arguments
- *
- * @throws MappingFailed If the arguments do not match the expected TypeCasting class constructor signature
- */
- private function resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): ?TypeCasting
- {
- $reflectionType = $reflectionProperty->getType();
- if (null === $reflectionType) {
- 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(...$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) {
- if ($exception instanceof MappingFailed) {
- throw $exception;
- }
-
- throw new MappingFailed(message:'Unable to instantiate a casting mechanism. Please verify your casting arguments', previous: $exception);
- }
- }
-
/**
* @throws MappingFailed
*/
private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod $accessor): TypeCasting
{
if (array_key_exists('reflectionProperty', $cell->castArguments)) {
- throw new MappingFailed('The key `propertyType` can not be used with `castArguments`.');
+ throw new MappingFailed('The key `reflectionProperty` can not be used with `castArguments`.');
}
$reflectionProperty = match (true) {
@@ -332,21 +299,56 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod
};
$typeCaster = $cell->cast;
- if (null !== $typeCaster) {
- if (!in_array(TypeCasting::class, class_implements($typeCaster), true)) {
- throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.');
- }
+ if (null === $typeCaster) {
+ return $this->resolveTypeCasting($reflectionProperty, $cell->castArguments);
+ }
+
+ if (!in_array(TypeCasting::class, class_implements($typeCaster), true)) {
+ throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.');
+ }
- $arguments = [...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]];
+ try {
/** @var TypeCasting $cast */
- $cast = new $typeCaster(...$arguments);
+ $cast = new $typeCaster(...$cell->castArguments, ...['reflectionProperty' => $reflectionProperty]);
return $cast;
+ } catch (Throwable $exception) {
+ if ($exception instanceof MappingFailed) {
+ throw $exception;
+ }
+
+ throw new MappingFailed(message:'Unable to instantiate a casting mechanism. Please verify your casting arguments', previous: $exception);
}
+ }
- 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 resolveTypeCasting(ReflectionProperty|ReflectionParameter $reflectionProperty, array $arguments = []): TypeCasting
+ {
+ $exception = new MappingFailed(match (true) {
+ $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed with a supported type.',
+ $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed with a supported type.',
});
+
+ $reflectionType = $reflectionProperty->getType() ?? throw $exception;
+
+ try {
+ $arguments['reflectionProperty'] = $reflectionProperty;
+
+ return ClosureCasting::supports($reflectionProperty) ?
+ new ClosureCasting(...$arguments) :
+ match (Type::tryFromReflectionType($reflectionType)) {
+ Type::Mixed, Type::Null, Type::String => new CastToString(...$arguments),
+ Type::Iterable, Type::Array => new CastToArray(...$arguments),
+ Type::False, Type::True, Type::Bool => new CastToBool(...$arguments),
+ Type::Float => new CastToFloat(...$arguments),
+ Type::Int => new CastToInt(...$arguments),
+ Type::Date => new CastToDate(...$arguments),
+ Type::Enum => new CastToEnum(...$arguments),
+ default => throw $exception,
+ };
+ } catch (MappingFailed $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ throw new MappingFailed(message:'Unable to load the casting mechanism. Please verify your casting arguments', previous: $exception);
+ }
}
}
diff --git a/src/Serializer/ClosureCasting.php b/src/Serializer/ClosureCasting.php
new file mode 100644
index 00000000..fe72a150
--- /dev/null
+++ b/src/Serializer/ClosureCasting.php
@@ -0,0 +1,125 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace League\Csv\Serializer;
+
+use Closure;
+use ReflectionNamedType;
+use ReflectionParameter;
+use ReflectionProperty;
+use ReflectionType;
+use ReflectionUnionType;
+use Throwable;
+
+final class ClosureCasting implements TypeCasting
+{
+ /** @var array */
+ private static array $casters = [];
+
+ private readonly string $type;
+ private readonly bool $isNullable;
+ private readonly Closure $closure;
+ private readonly array $arguments;
+
+ public function __construct(ReflectionProperty|ReflectionParameter $reflectionProperty, mixed ...$arguments)
+ {
+ [$type, $this->isNullable] = self::resolve($reflectionProperty);
+ $this->type = $type->getName();
+ $this->closure = self::$casters[$this->type];
+ $this->arguments = $arguments;
+ }
+
+ public function toVariable(?string $value): mixed
+ {
+ try {
+ return ($this->closure)($value, $this->isNullable, ...$this->arguments);
+ } catch (Throwable $exception) {
+ if ($exception instanceof TypeCastingFailed) {
+ throw $exception;
+ }
+
+ $message = match (true) {
+ '' === $value => 'Unable to cast the empty string to `'.$this->type.'`.',
+ null === $value => 'Unable to cast the `null` value to `'.$this->type.'`.',
+ default => 'Unable to cast the given string `'.$value.'` to `'.$this->type.'`',
+ };
+
+ throw new TypeCastingFailed(message: $message, previous: $exception);
+ }
+ }
+
+ public static function register(string $type, Closure $closure): void
+ {
+ if (!class_exists($type) && !(Type::tryFrom($type)?->isBuiltIn() ?? false)) {
+ throw new MappingFailed('The `'.$type.'` could not be register.');
+ }
+
+ self::$casters[$type] = $closure;
+ }
+
+ public static function unregister(string $type): void
+ {
+ unset(self::$casters[$type]);
+ }
+
+ public static function supports(ReflectionParameter|ReflectionProperty $reflectionProperty): bool
+ {
+ foreach (self::getTypes($reflectionProperty->getType()) as $type) {
+ if (array_key_exists($type->getName(), self::$casters)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @throws MappingFailed
+ *
+ * @return array{0:ReflectionNamedType, 1:bool}
+ */
+ private static function resolve(ReflectionParameter|ReflectionProperty $reflectionProperty): array
+ {
+ $type = null;
+ $isNullable = false;
+ foreach (self::getTypes($reflectionProperty->getType()) as $foundType) {
+ if (!$isNullable && $foundType->allowsNull()) {
+ $isNullable = true;
+ }
+
+ if (null === $type && array_key_exists($foundType->getName(), self::$casters)) {
+ $type = $foundType;
+ }
+ }
+
+ return $type instanceof ReflectionNamedType ? [$type, $isNullable] : throw new MappingFailed(match (true) {
+ $reflectionProperty instanceof ReflectionParameter => 'The setter method argument `'.$reflectionProperty->getName().'` must be typed with a supported type.',
+ $reflectionProperty instanceof ReflectionProperty => 'The property `'.$reflectionProperty->getName().'` must be typed with a supported type.',
+ });
+ }
+
+ /**
+ * @return array
+ */
+ private static function getTypes(?ReflectionType $type): array
+ {
+ return match (true) {
+ $type instanceof ReflectionNamedType => [$type],
+ $type instanceof ReflectionUnionType => array_filter(
+ $type->getTypes(),
+ fn (ReflectionType $innerType) => $innerType instanceof ReflectionNamedType
+ ),
+ default => [],
+ };
+ }
+}
diff --git a/src/Serializer/Type.php b/src/Serializer/Type.php
index 8908a372..4f5cee1f 100644
--- a/src/Serializer/Type.php
+++ b/src/Serializer/Type.php
@@ -56,6 +56,14 @@ public function isOneOf(self ...$types): bool
return in_array($this, $types, true);
}
+ public function isBuiltIn(): bool
+ {
+ return match ($this) {
+ self::Date, self::Enum => false,
+ default => true,
+ };
+ }
+
public function filterFlag(): int
{
return match ($this) {
diff --git a/src/SerializerTest.php b/src/SerializerTest.php
index ed5f7bfa..26c7ed31 100644
--- a/src/SerializerTest.php
+++ b/src/SerializerTest.php
@@ -151,7 +151,7 @@ public function testItWillThrowIfTheColumnAttributesCasterIsInvalid(): void
public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('The property `temperature` must be typed.');
+ $this->expectExceptionMessage('The property `temperature` must be typed with a supported type.');
new Serializer(InvaliDWeatherWithRecordAttribute::class, ['temperature', 'foobar', 'observedOn']);
}
@@ -159,7 +159,7 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi
public function testItWillFailForLackOfTypeCasting(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$observedOn` type.');
+ $this->expectExceptionMessage('The property `observedOn` must be typed with a supported type.');
new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']);
}
@@ -167,7 +167,7 @@ public function testItWillFailForLackOfTypeCasting(): void
public function testItWillThrowIfTheClassContainsUninitializedProperties(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('No valid type casting was found for the property `annee`; it must be typed.');
+ $this->expectExceptionMessage('The property `annee` must be typed with a supported type.');
Serializer::assign(
InvalidObjectWithUninitializedProperty::class,
@@ -178,7 +178,7 @@ public function testItWillThrowIfTheClassContainsUninitializedProperties(): void
public function testItCanNotAutodiscoverWithIntersectionType(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$traversable` type.');
+ $this->expectExceptionMessage('The property `traversable` must be typed with a supported type.');
$foobar = new class () {
public Countable&Traversable $traversable;
@@ -186,6 +186,47 @@ public function testItCanNotAutodiscoverWithIntersectionType(): void
Serializer::assign($foobar::class, ['traversable' => '1']);
}
+
+ public function testItCanUseTheClosureRegisteringMechanism(): void
+ {
+ $record = ['foo' => 'toto'];
+ $foobar = new class () {
+ public string $foo;
+ };
+
+ Serializer::registerType('string', fn (?string $value) => 'yolo!');
+
+ self::assertSame('yolo!', Serializer::assign($foobar::class, $record)->foo); /* @phpstan-ignore-line */
+
+ Serializer::unregisterType('string');
+
+ self::assertSame('toto', Serializer::assign($foobar::class, $record)->foo);
+ }
+
+ public function testItFailsToRegisterUnknownType(): void
+ {
+ $type = 'UnkownType';
+ $this->expectException(MappingFailed::class);
+ $this->expectExceptionMessage('The `'.$type.'` could not be register.');
+
+ Serializer::registerType($type, fn (?string $value) => 'yolo!');
+ }
+
+ public function testEmptyStringHandling(): void
+ {
+ $record = ['foo' => ''];
+ $foobar = new class () {
+ public ?string $foo;
+ };
+
+ Serializer::disallowEmptyStringAsNull();
+
+ self::assertSame('', Serializer::assign($foobar::class, $record)->foo); /* @phpstan-ignore-line */
+
+ Serializer::allowEmptyStringAsNull();
+
+ self::assertNull(Serializer::assign($foobar::class, $record)->foo);
+ }
}
enum Place: string