diff --git a/docs/9.0/reader/record-mapping.md b/docs/9.0/reader/record-mapping.md
index e5317537..5afeddd0 100644
--- a/docs/9.0/reader/record-mapping.md
+++ b/docs/9.0/reader/record-mapping.md
@@ -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.
-
The propertyType
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.
@@ -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;
@@ -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.
Of note The class constructor method must take the property type value as
-one of its argument with the name $propertyType
. This means you can not use the
-propertyType
as a possible key of the associative array given to castArguments
+one of its argument with the name $reflectionProperty
. This means you can not use the
+reflectionProperty
as a possible key of the associative array given to castArguments
```php
use App\Domain\Money;
@@ -352,44 +352,49 @@ use League\Csv\Serializer\TypeCastingFailed;
/**
* @implements TypeCasting
*/
-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);
}
}
}
```
+
+While the built-in TypeCasting
classes do not support Intersection Type, your own
+implementing class can support them via inspection of the $reflectionProperty
argument.
diff --git a/src/Serializer.php b/src/Serializer.php
index 13d5cc34..a6ab0737 100644
--- a/src/Serializer.php
+++ b/src/Serializer.php
@@ -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
@@ -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);
@@ -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) {
@@ -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;
@@ -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 => '',
- };
- }
}
diff --git a/src/Serializer/CastToArray.php b/src/Serializer/CastToArray.php
index e3debb2d..fa8d9a2d 100644
--- a/src/Serializer/CastToArray.php
+++ b/src/Serializer/CastToArray.php
@@ -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;
@@ -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;
@@ -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 = ',',
@@ -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.');
}
@@ -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.'),
};
}
@@ -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];
+ }
}
diff --git a/src/Serializer/CastToArrayTest.php b/src/Serializer/CastToArrayTest.php
index 5e61b12a..c0d4771c 100644
--- a/src/Serializer/CastToArrayTest.php
+++ b/src/Serializer/CastToArrayTest.php
@@ -13,8 +13,12 @@
namespace League\Csv\Serializer;
+use Countable;
+use DateTimeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
+use Traversable;
final class CastToArrayTest extends TestCase
{
@@ -25,7 +29,7 @@ final class CastToArrayTest extends TestCase
#[DataProvider('providesValidStringForArray')]
public function testItCanConvertToArraygWithoutArguments(string $shape, string $type, string $input, array $expected): void
{
- self::assertSame($expected, (new CastToArray(propertyType: '?iterable', shape:$shape, type:$type))->toVariable($input));
+ self::assertSame($expected, (new CastToArray(reflectionProperty: new ReflectionProperty(ArrayClass::class, 'nullableIterable'), shape:$shape, type:$type))->toVariable($input));
}
public static function providesValidStringForArray(): iterable
@@ -91,14 +95,14 @@ public function testItFailsToCastAnUnsupportedType(): void
{
$this->expectException(MappingFailed::class);
- new CastToArray('?int');
+ new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableInt'));
}
public function testItFailsToCastInvalidJson(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToArray('?iterable', null, 'json'))->toVariable('{"json":toto}');
+ (new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableIterable'), null, 'json'))->toVariable('{"json":toto}');
}
public function testItCastNullableJsonUsingTheDefaultValue(): void
@@ -107,7 +111,36 @@ public function testItCastNullableJsonUsingTheDefaultValue(): void
self::assertSame(
$defaultValue,
- (new CastToArray('?iterable', $defaultValue, 'json'))->toVariable(null)
+ (new CastToArray(new ReflectionProperty(ArrayClass::class, 'nullableIterable'), $defaultValue, 'json'))->toVariable(null)
);
}
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(ArrayClass::class, $propertyName);
+
+ new CastToArray($reflectionProperty);
+ }
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableInt'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
+ }
+}
+
+class ArrayClass
+{
+ public ?iterable $nullableIterable;
+ public ?int $nullableInt;
+ public array $array;
+ public DateTimeInterface|array|null $unionType;
+ public DateTimeInterface|string $invalidUnionType;
+ public Countable&Traversable $intersectionType;
}
diff --git a/src/Serializer/CastToBool.php b/src/Serializer/CastToBool.php
index 423a6990..74ceb335 100644
--- a/src/Serializer/CastToBool.php
+++ b/src/Serializer/CastToBool.php
@@ -13,8 +13,10 @@
namespace League\Csv\Serializer;
+use ReflectionParameter;
+use ReflectionProperty;
+
use function filter_var;
-use function str_starts_with;
final class CastToBool implements TypeCasting
{
@@ -22,16 +24,10 @@ final class CastToBool implements TypeCasting
private readonly Type $type;
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
private readonly ?bool $default = null
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::Bool, Type::True, Type::False)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `bool` type is required.');
- }
-
- $this->type = $type;
- $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
+ [$this->type, $this->isNullable] = $this->init($reflectionProperty);
}
/**
@@ -51,4 +47,28 @@ public function toVariable(?string $value): ?bool
default => $returnValue,
};
}
+
+ /**
+ * @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::Bool, Type::True, Type::False)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed` or `bool` type is required.');
+ }
+
+ return [$type[0], $isNullable];
+ }
}
diff --git a/src/Serializer/CastToBoolTest.php b/src/Serializer/CastToBoolTest.php
index ee0dd529..255e1f44 100644
--- a/src/Serializer/CastToBoolTest.php
+++ b/src/Serializer/CastToBoolTest.php
@@ -13,8 +13,12 @@
namespace League\Csv\Serializer;
+use Countable;
+use DateTimeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
+use Traversable;
final class CastToBoolTest extends TestCase
{
@@ -22,12 +26,12 @@ public function testItFailsWithNonSupportedType(): void
{
$this->expectException(MappingFailed::class);
- new CastToBool('int');
+ new CastToBool(new ReflectionProperty(BoolClass::class, 'string'));
}
#[DataProvider('providesValidInputValue')]
public function testItCanConvertStringToBool(
- string $propertyType,
+ ReflectionProperty $propertyType,
?bool $default,
?string $input,
?bool $expected
@@ -38,66 +42,111 @@ public function testItCanConvertStringToBool(
public static function providesValidInputValue(): iterable
{
yield 'with a true type - true' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => 'true',
'expected' => true,
];
yield 'with a true type - yes' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => 'yes',
'expected' => true,
];
yield 'with a true type - 1' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => '1',
'expected' => true,
];
yield 'with a false type - false' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => 'f',
'expected' => false,
];
yield 'with a false type - no' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => 'no',
'expected' => false,
];
yield 'with a false type - 0' => [
- 'propertyType' => 'bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'boolean'),
'default' => null,
'input' => '0',
'expected' => false,
];
yield 'with a null type' => [
- 'propertyType' => '?bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'nullableBool'),
'default' => null,
'input' => null,
'expected' => null,
];
yield 'with another default type' => [
- 'propertyType' => '?bool',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'nullableBool'),
'default' => false,
'input' => null,
'expected' => false,
];
yield 'with the mixed type' => [
- 'propertyType' => 'mixed',
+ 'propertyType' => new ReflectionProperty(BoolClass::class, 'mixed'),
'default' => null,
'input' => 'YES',
'expected' => true,
];
+
+ yield 'with union type' => [
+ 'reflectionProperty' => new ReflectionProperty(BoolClass::class, 'unionType'),
+ 'default' => false,
+ 'input' => 'yes',
+ 'expected' => true,
+ ];
+
+ yield 'with nullable union type' => [
+ 'reflectionProperty' => new ReflectionProperty(BoolClass::class, 'unionType'),
+ 'default' => false,
+ 'input' => null,
+ 'expected' => false,
+ ];
+ }
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(BoolClass::class, $propertyName);
+
+ new CastToBool($reflectionProperty);
}
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableInt'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
+ }
+}
+
+class BoolClass
+{
+ public ?bool $nullableBool;
+ public bool $boolean;
+ public mixed $mixed;
+ public string $string;
+ public ?int $nullableInt;
+ public DateTimeInterface|bool|null $unionType;
+ public DateTimeInterface|string $invalidUnionType;
+ public Countable&Traversable $intersectionType;
}
diff --git a/src/Serializer/CastToDate.php b/src/Serializer/CastToDate.php
index fae5a8da..fcd44999 100644
--- a/src/Serializer/CastToDate.php
+++ b/src/Serializer/CastToDate.php
@@ -17,11 +17,12 @@
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
+use ReflectionNamedType;
+use ReflectionParameter;
+use ReflectionProperty;
use Throwable;
use function is_string;
-use function ltrim;
-use function str_starts_with;
/**
* @implements TypeCasting
@@ -37,23 +38,18 @@ final class CastToDate implements TypeCasting
* @throws MappingFailed
*/
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
?string $default = null,
private readonly ?string $format = null,
DateTimeZone|string|null $timezone = null,
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::Date)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an class implementing the `'.DateTimeInterface::class.'` interface is required.');
- }
-
- $class = ltrim($propertyType, '?');
+ [$type, $reflection, $this->isNullable] = $this->init($reflectionProperty);
+ $class = $reflection->getName();
if (Type::Mixed->equals($type) || DateTimeInterface::class === $class) {
$class = DateTimeImmutable::class;
}
$this->class = $class;
- $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
try {
$this->timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone;
$this->default = (null !== $default) ? $this->cast($default) : $default;
@@ -96,4 +92,30 @@ private function cast(string $value): DateTimeImmutable|DateTime
return $date;
}
+
+ /**
+ * @throws MappingFailed
+ *
+ * @return array{0:Type, 1:ReflectionNamedType, 2: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::Date)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; a class implementing the `'.DateTimeInterface::class.'` interface or `mixed` is required.');
+ }
+
+ return [...$type, $isNullable];
+ }
}
diff --git a/src/Serializer/CastToDateTest.php b/src/Serializer/CastToDateTest.php
index e0a54acd..c4228be2 100644
--- a/src/Serializer/CastToDateTest.php
+++ b/src/Serializer/CastToDateTest.php
@@ -13,17 +13,20 @@
namespace League\Csv\Serializer;
+use Countable;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
final class CastToDateTest extends TestCase
{
public function testItCanConvertADateWithoutArguments(): void
{
- $cast = new CastToDate(DateTime::class);
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'dateTime'));
$date = $cast->toVariable('2023-10-30');
self::assertInstanceOf(DateTime::class, $date);
@@ -32,7 +35,7 @@ public function testItCanConvertADateWithoutArguments(): void
public function testItCanConvertADateWithASpecificFormat(): void
{
- $cast = new CastToDate(DateTimeInterface::class, null, '!Y-m-d', 'Africa/Kinshasa');
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'dateTimeInterface'), null, '!Y-m-d', 'Africa/Kinshasa');
$date = $cast->toVariable('2023-10-30');
self::assertInstanceOf(DateTimeImmutable::class, $date);
@@ -42,7 +45,7 @@ public function testItCanConvertADateWithASpecificFormat(): void
public function testItCanConvertAnObjectImplementingTheDateTimeInterface(): void
{
- $cast = new CastToDate(MyDate::class);
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'myDate'));
$date = $cast->toVariable('2023-10-30');
self::assertInstanceOf(MyDate::class, $date);
@@ -53,27 +56,85 @@ public function testItCShouldThrowIfNoConversionIsPossible(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToDate(DateTimeInterface::class))->toVariable('foobar');
+ (new CastToDate(new ReflectionProperty(DateClass::class, 'dateTimeInterface')))->toVariable('DateClass');
}
+
+
+ public function testItCShouldThrowIfTheOptionsAreInvalid(): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ new CastToDate(
+ new ReflectionProperty(DateClass::class, 'dateTimeInterface'),
+ '2023-11-11',
+ 'Y-m-d',
+ 'Europe\Blan'
+ );
+ }
+
+
public function testItReturnsNullWhenTheVariableIsNullable(): void
{
- $cast = new CastToDate('?'.DateTime::class);
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'nullableDateTime'));
self::assertNull($cast->toVariable(null));
}
public function testItCanConvertADateWithADefaultValue(): void
{
- $cast = new CastToDate('?'.DateTimeInterface::class, '2023-01-01', '!Y-m-d', 'Africa/Kinshasa');
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'nullableDateTimeInterface'), '2023-01-01', '!Y-m-d', 'Africa/Kinshasa');
$date = $cast->toVariable(null);
self::assertInstanceOf(DateTimeImmutable::class, $date);
self::assertSame('01-01-2023 00:00:00', $date->format('d-m-Y H:i:s'));
self::assertEquals(new DateTimeZone('Africa/Kinshasa'), $date->getTimezone());
}
+
+ public function testItReturnsTheValueWithUnionType(): void
+ {
+ $cast = new CastToDate(new ReflectionProperty(DateClass::class, 'unionType'), '2023-01-01');
+
+ self::assertEquals(new DateTimeImmutable('2023-01-01'), $cast->toVariable(null));
+ }
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(DateClass::class, $propertyName);
+
+ new CastToDate($reflectionProperty);
+ }
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableBool'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
+ }
}
class MyDate extends DateTimeImmutable
{
}
+
+class DateClass
+{
+ public DateTimeImmutable $dateTimeImmutable;
+ public DateTime $dateTime;
+ public DateTimeInterface $dateTimeInterface;
+ public MyDate $myDate;
+ public ?DateTimeImmutable $nullableDateTimeImmutable;
+ public ?DateTime $nullableDateTime;
+ public ?DateTimeInterface $nullableDateTimeInterface;
+ public ?MyDate $nullableMyDate;
+ public mixed $mixed;
+ public ?bool $nullableBool;
+ public DateTimeInterface|string|null $unionType;
+ public float|int $invalidUnionType;
+ public Countable&DateTimeInterface $intersectionType;
+}
diff --git a/src/Serializer/CastToEnum.php b/src/Serializer/CastToEnum.php
index e9adc618..12b92835 100644
--- a/src/Serializer/CastToEnum.php
+++ b/src/Serializer/CastToEnum.php
@@ -15,17 +15,18 @@
use BackedEnum;
use ReflectionEnum;
+use ReflectionNamedType;
+use ReflectionParameter;
+use ReflectionProperty;
use Throwable;
use UnitEnum;
-use function ltrim;
-use function str_starts_with;
-
/**
* @implements TypeCasting
*/
class CastToEnum implements TypeCasting
{
+ /** @var class-string */
private readonly string $class;
private readonly bool $isNullable;
private readonly BackedEnum|UnitEnum|null $default;
@@ -36,27 +37,21 @@ class CastToEnum implements TypeCasting
* @throws MappingFailed
*/
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
?string $default = null,
?string $enum = null,
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::Enum)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an `Enum` is required.');
- }
-
- $class = ltrim($propertyType, '?');
- $isNullable = str_starts_with($propertyType, '?');
- if ($type->equals(Type::Mixed)) {
+ [$type, $reflection, $this->isNullable] = $this->init($reflectionProperty);
+ /** @var class-string $class */
+ $class = $reflection->getName();
+ if (Type::Mixed->equals($type)) {
if (null === $enum || !enum_exists($enum)) {
- throw new MappingFailed('You need to specify the enum class with a `mixed` typed property.');
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is `mixed`; you must specify the Enum class via the `$enum` argument.');
}
$class = $enum;
- $isNullable = true;
}
$this->class = $class;
- $this->isNullable = $isNullable;
try {
$this->default = (null !== $default) ? $this->cast($default) : $default;
@@ -95,4 +90,28 @@ private function cast(string $value): BackedEnum|UnitEnum
throw new TypeCastingFailed(message: 'Unable to cast to `'.$this->class.'` the value `'.$value.'`.', previous: $exception);
}
}
+
+ /**
+ * @return array{0:Type, 1:ReflectionNamedType, 2: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::Enum)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; an Enum or `mixed` is required.');
+ }
+
+ return [...$type, $isNullable];
+ }
}
diff --git a/src/Serializer/CastToEnumTest.php b/src/Serializer/CastToEnumTest.php
index c32c0948..55436669 100644
--- a/src/Serializer/CastToEnumTest.php
+++ b/src/Serializer/CastToEnumTest.php
@@ -13,13 +13,18 @@
namespace League\Csv\Serializer;
+use Countable;
+use DateTimeInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
+use Traversable;
final class CastToEnumTest extends TestCase
{
public function testItCanConvertAStringBackedEnum(): void
{
- $cast = new CastToEnum(Colour::class);
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour'));
$orange = $cast->toVariable('orange');
self::assertInstanceOf(Colour::class, $orange);
@@ -29,7 +34,7 @@ public function testItCanConvertAStringBackedEnum(): void
public function testItCanConvertAIntegerBackedEnum(): void
{
- $cast = new CastToEnum(DayOfTheWeek::class);
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'dayOfTheWeek'));
$monday = $cast->toVariable('1');
self::assertInstanceOf(DayOfTheWeek::class, $monday);
@@ -39,7 +44,7 @@ public function testItCanConvertAIntegerBackedEnum(): void
public function testItCanConvertAUnitEnum(): void
{
- $cast = new CastToEnum(Currency::class);
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency'));
$naira = $cast->toVariable('Naira');
self::assertInstanceOf(Currency::class, $naira);
@@ -48,14 +53,14 @@ public function testItCanConvertAUnitEnum(): void
public function testItReturnsNullWhenTheVariableIsNullable(): void
{
- $cast = new CastToEnum('?'.Currency::class);
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency'));
self::assertNull($cast->toVariable(null));
}
public function testItReturnsTheDefaultValueWhenTheVariableIsNullable(): void
{
- $cast = new CastToEnum('?'.Currency::class, 'Naira');
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'nullableCurrency'), 'Naira');
self::assertSame(Currency::Naira, $cast->toVariable(null));
}
@@ -64,14 +69,40 @@ public function testThrowsOnNullIfTheVariableIsNotNullable(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToEnum(Currency::class))->toVariable(null);
+ (new CastToEnum(new ReflectionProperty(EnumClass::class, 'currency')))->toVariable(null);
}
public function testThrowsIfTheValueIsNotRecognizedByTheEnum(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToEnum(Colour::class))->toVariable('green');
+ (new CastToEnum(new ReflectionProperty(EnumClass::class, 'colour')))->toVariable('green');
+ }
+
+ public function testItReturnsTheDefaultValueWithUnionType(): void
+ {
+ $cast = new CastToEnum(new ReflectionProperty(EnumClass::class, 'unionType'), 'orange');
+
+ self::assertSame(Colour::Violet, $cast->toVariable('violet'));
+ }
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(EnumClass::class, $propertyName);
+
+ new CastToEnum($reflectionProperty);
+ }
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableBool'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
}
}
@@ -93,3 +124,15 @@ enum Currency
case Euro;
case Naira;
}
+
+class EnumClass
+{
+ public DayOfTheWeek $dayOfTheWeek;
+ public Currency $currency;
+ public ?Currency $nullableCurrency;
+ public Colour $colour;
+ public ?bool $nullableBool;
+ public DateTimeInterface|Colour|null $unionType;
+ public DateTimeInterface|int $invalidUnionType;
+ public Countable&Traversable $intersectionType;
+}
diff --git a/src/Serializer/CastToFloat.php b/src/Serializer/CastToFloat.php
index d3282177..998f7ddf 100644
--- a/src/Serializer/CastToFloat.php
+++ b/src/Serializer/CastToFloat.php
@@ -13,8 +13,10 @@
namespace League\Csv\Serializer;
+use ReflectionParameter;
+use ReflectionProperty;
+
use function filter_var;
-use function str_starts_with;
/**
* @implements TypeCasting
@@ -24,15 +26,10 @@ final class CastToFloat implements TypeCasting
private readonly bool $isNullable;
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
private readonly ?float $default = null,
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::Float)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `float` type is required.');
- }
-
- $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
+ $this->isNullable = $this->init($reflectionProperty);
}
/**
@@ -54,4 +51,25 @@ public function toVariable(?string $value): ?float
default => $float,
};
}
+
+ private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): bool
+ {
+ $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::Float)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `float` or `null` type is required.');
+ }
+
+ return $isNullable;
+ }
}
diff --git a/src/Serializer/CastToFloatTest.php b/src/Serializer/CastToFloatTest.php
index 917b655a..799fac79 100644
--- a/src/Serializer/CastToFloatTest.php
+++ b/src/Serializer/CastToFloatTest.php
@@ -13,8 +13,10 @@
namespace League\Csv\Serializer;
+use DateTimeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
final class CastToFloatTest extends TestCase
{
@@ -22,57 +24,81 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void
{
$this->expectException(MappingFailed::class);
- new CastToFloat('string');
+ new CastToFloat(new ReflectionProperty(FloatClass::class, 'string'));
}
#[DataProvider('providesValidStringForInt')]
- public function testItCanConvertToArraygWithoutArguments(string $prototype, ?string $input, ?float $default, ?float $expected): void
+ public function testItCanConvertToArraygWithoutArguments(ReflectionProperty $prototype, ?string $input, ?float $default, ?float $expected): void
{
- self::assertSame($expected, (new CastToFloat(propertyType: $prototype, default:$default))->toVariable($input));
+ self::assertSame($expected, (new CastToFloat(reflectionProperty: $prototype, default:$default))->toVariable($input));
}
public static function providesValidStringForInt(): iterable
{
yield 'positive integer' => [
- 'prototype' => '?float',
+ 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'),
'input' => '1',
'default' => null,
'expected' => 1.0,
];
yield 'zero' => [
- 'prototype' => '?float',
+ 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'),
'input' => '0',
'default' => null,
'expected' => 0.0,
];
yield 'negative integer' => [
- 'prototype' => '?float',
+ 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'),
'input' => '-10',
'default' => null,
'expected' => -10.0,
];
yield 'null value' => [
- 'prototype' => '?float',
+ 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'),
'input' => null,
'default' => null,
'expected' => null,
];
yield 'null value with default value' => [
- 'prototype' => '?float',
+ 'prototype' => new ReflectionProperty(FloatClass::class, 'nullableFloat'),
'input' => null,
'default' => 10,
'expected' => 10.0,
];
+
+ yield 'with union type' => [
+ 'reflectionProperty' => new ReflectionProperty(FloatClass::class, 'unionType'),
+ 'input' => '23',
+ 'default' => 42.0,
+ 'expected' => 23.0,
+ ];
+
+ yield 'with nullable union type' => [
+ 'reflectionProperty' => new ReflectionProperty(FloatClass::class, 'unionType'),
+ 'input' => null,
+ 'default' => 42.0,
+ 'expected' => 42.0,
+ ];
}
public function testItFailsToConvertNonIntegerString(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToFloat(propertyType: '?float'))->toVariable('00foobar');
+ (new CastToFloat(new ReflectionProperty(FloatClass::class, 'nullableFloat')))->toVariable('00foobar');
}
}
+
+class FloatClass
+{
+ public float $float;
+ public ?float $nullableFloat;
+ public mixed $mixed;
+ public int $int;
+ public string $string;
+ public DateTimeInterface|float|null $unionType;
+}
diff --git a/src/Serializer/CastToInTest.php b/src/Serializer/CastToInTest.php
index 6d1087ec..797d64d1 100644
--- a/src/Serializer/CastToInTest.php
+++ b/src/Serializer/CastToInTest.php
@@ -13,8 +13,12 @@
namespace League\Csv\Serializer;
+use Countable;
+use DateTimeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
+use Traversable;
final class CastToInTest extends TestCase
{
@@ -22,71 +26,118 @@ public function testItFailsToInstantiateWithAnUnSupportedType(): void
{
$this->expectException(MappingFailed::class);
- new CastToInt('string');
+ new CastToInt(new ReflectionProperty(IntClass::class, 'string'));
}
#[DataProvider('providesValidStringForInt')]
- public function testItCanConvertToArraygWithoutArguments(string $prototype, ?string $input, ?int $default, ?int $expected): void
+ public function testItCanConvertToArraygWithoutArguments(ReflectionProperty $reflectionProperty, ?string $input, ?int $default, ?int $expected): void
{
- self::assertSame($expected, (new CastToInt(propertyType: $prototype, default:$default))->toVariable($input));
+ self::assertSame($expected, (new CastToInt(reflectionProperty: $reflectionProperty, default:$default))->toVariable($input));
}
public static function providesValidStringForInt(): iterable
{
yield 'positive integer' => [
- 'prototype' => '?int',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'),
'input' => '1',
'default' => null,
'expected' => 1,
];
yield 'zero' => [
- 'prototype' => '?int',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'),
'input' => '0',
'default' => null,
'expected' => 0,
];
yield 'negative integer' => [
- 'prototype' => '?int',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'),
'input' => '-10',
'default' => null,
'expected' => -10,
];
yield 'null value' => [
- 'prototype' => '?int',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'),
'input' => null,
'default' => null,
'expected' => null,
];
yield 'null value with default value' => [
- 'prototype' => '?int',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableInt'),
'input' => null,
'default' => 10,
'expected' => 10,
];
yield 'conversion of the null value with a nullable float' => [
- 'prototype' => '?float',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableFloat'),
'input' => null,
'default' => 10,
'expected' => 10,
];
yield 'conversion with float' => [
- 'prototype' => '?float',
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'nullableFloat'),
'input' => '1',
'default' => null,
'expected' => 1,
];
+
+ yield 'with union type' => [
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'unionType'),
+ 'input' => '23',
+ 'default' => 42,
+ 'expected' => 23,
+ ];
+
+ yield 'with nullable union type' => [
+ 'reflectionProperty' => new ReflectionProperty(IntClass::class, 'unionType'),
+ 'input' => null,
+ 'default' => 42,
+ 'expected' => 42,
+ ];
}
public function testItFailsToConvertNonIntegerString(): void
{
$this->expectException(TypeCastingFailed::class);
- (new CastToInt(propertyType: '?int'))->toVariable('00foobar');
+ (new CastToInt(reflectionProperty: new ReflectionProperty(IntClass::class, 'nullableInt')))->toVariable('00foobar');
+ }
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(IntClass::class, $propertyName);
+
+ new CastToInt($reflectionProperty);
}
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableBool'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
+ }
+}
+
+class IntClass
+{
+ public float $float;
+ public ?float $nullableFloat;
+ public mixed $mixed;
+ public int $int;
+ public ?int $nullableInt;
+ public ?bool $nullableBool;
+ public string $string;
+ public DateTimeInterface|int|null $unionType;
+ public DateTimeInterface|string $invalidUnionType;
+ public Countable&Traversable $intersectionType;
}
diff --git a/src/Serializer/CastToInt.php b/src/Serializer/CastToInt.php
index 5fc15725..27cca588 100644
--- a/src/Serializer/CastToInt.php
+++ b/src/Serializer/CastToInt.php
@@ -13,8 +13,10 @@
namespace League\Csv\Serializer;
+use ReflectionParameter;
+use ReflectionProperty;
+
use function filter_var;
-use function str_starts_with;
/**
* @implements TypeCasting
@@ -24,15 +26,10 @@ final class CastToInt implements TypeCasting
private readonly bool $isNullable;
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
private readonly ?int $default = null,
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::Int, Type::Float)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `int` type is required.');
- }
-
- $this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
+ $this->isNullable = $this->init($reflectionProperty);
}
/**
@@ -54,4 +51,25 @@ public function toVariable(?string $value): ?int
default => $int,
};
}
+
+ private function init(ReflectionProperty|ReflectionParameter $reflectionProperty): bool
+ {
+ $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::Int, Type::Float)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `int`, `float` or `null` type is required.');
+ }
+
+ return $isNullable;
+ }
}
diff --git a/src/Serializer/CastToString.php b/src/Serializer/CastToString.php
index 4bf7e091..b9b28c40 100644
--- a/src/Serializer/CastToString.php
+++ b/src/Serializer/CastToString.php
@@ -13,7 +13,8 @@
namespace League\Csv\Serializer;
-use function str_starts_with;
+use ReflectionParameter;
+use ReflectionProperty;
/**
* @implements TypeCasting
@@ -24,16 +25,10 @@ final class CastToString implements TypeCasting
private readonly Type $type;
public function __construct(
- string $propertyType,
+ ReflectionProperty|ReflectionParameter $reflectionProperty,
private readonly ?string $default = null
) {
- $type = Type::tryFromPropertyType($propertyType);
- if (null === $type || !$type->isOneOf(Type::Mixed, Type::String, Type::Null)) {
- throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `string` or `null` type is required.');
- }
-
- $this->type = $type;
- $this->isNullable = $type->isOneOf(Type::Mixed, Type::Null) || str_starts_with($propertyType, '?');
+ [$this->type, $this->isNullable] = $this->init($reflectionProperty);
}
/**
@@ -52,4 +47,28 @@ public function toVariable(?string $value): ?string
default => $returnedValue,
};
}
+
+ /**
+ * @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::String, Type::Mixed, Type::Null)) {
+ $type = $found;
+ }
+ }
+
+ if (null === $type) {
+ throw new MappingFailed('`'.$reflectionProperty->getName().'` type is not supported; `mixed`, `string` or `null` type is required.');
+ }
+
+ return [$type[0], $isNullable];
+ }
}
diff --git a/src/Serializer/CastToStringTest.php b/src/Serializer/CastToStringTest.php
index c391b509..b9e1b938 100644
--- a/src/Serializer/CastToStringTest.php
+++ b/src/Serializer/CastToStringTest.php
@@ -13,8 +13,12 @@
namespace League\Csv\Serializer;
+use Countable;
+use DateTimeInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionProperty;
+use Traversable;
final class CastToStringTest extends TestCase
{
@@ -22,47 +26,98 @@ public function testItFailsWithNonSupportedType(): void
{
$this->expectException(MappingFailed::class);
- new CastToString('int');
+ new CastToString(new ReflectionProperty(StringClass::class, 'int'));
}
#[DataProvider('providesValidInputValue')]
public function testItCanConvertStringToBool(
- string $propertyType,
+ ReflectionProperty $reflectionProperty,
?string $default,
?string $input,
?string $expected
): void {
- self::assertSame($expected, (new CastToString($propertyType, $default))->toVariable($input));
+ self::assertSame($expected, (new CastToString($reflectionProperty, $default))->toVariable($input));
}
public static function providesValidInputValue(): iterable
{
yield 'with a string/nullable type' => [
- 'propertyType' => '?string',
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'),
'default' => null,
'input' => 'true',
'expected' => 'true',
];
yield 'with a string type' => [
- 'propertyType' => 'string',
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'string'),
'default' => null,
'input' => 'yes',
'expected' => 'yes',
];
yield 'with a nullable string type and the null value' => [
- 'propertyType' => '?string',
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'),
'default' => null,
'input' => null,
'expected' => null,
];
yield 'with a nullable string type and a non null default value' => [
- 'propertyType' => '?string',
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'nullableString'),
+ 'default' => 'foo',
+ 'input' => null,
+ 'expected' => 'foo',
+ ];
+
+ yield 'with union type' => [
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'),
+ 'default' => 'foo',
+ 'input' => 'tata',
+ 'expected' => 'tata',
+ ];
+
+ yield 'with nullable union type' => [
+ 'reflectionProperty' => new ReflectionProperty(StringClass::class, 'unionType'),
'default' => 'foo',
'input' => null,
'expected' => 'foo',
];
}
+
+ #[DataProvider('invalidPropertyName')]
+ public function testItWillThrowIfNotTypeAreSupported(string $propertyName): void
+ {
+ $this->expectException(MappingFailed::class);
+
+ $reflectionProperty = new ReflectionProperty(StringClass::class, $propertyName);
+
+ new CastToString($reflectionProperty);
+ }
+
+ public static function invalidPropertyName(): iterable
+ {
+ return [
+ 'named type not supported' => ['propertyName' => 'nullableBool'],
+ 'union type not supported' => ['propertyName' => 'invalidUnionType'],
+ 'intersection type not supported' => ['propertyName' => 'intersectionType'],
+ ];
+ }
+}
+
+class StringClass
+{
+ public float $float;
+ public ?float $nullableFloat;
+ public int $int;
+ public ?int $nullableInt;
+ public string $string;
+ public ?string $nullableString;
+ public ?bool $nullableBool;
+ public bool $boolean;
+ public mixed $mixed;
+ public ?iterable $nullableIterable;
+ public array $array;
+ public DateTimeInterface|string|null $unionType;
+ public DateTimeInterface|int $invalidUnionType;
+ public Countable&Traversable $intersectionType;
}
diff --git a/src/Serializer/Type.php b/src/Serializer/Type.php
index 3dd3577f..8908a372 100644
--- a/src/Serializer/Type.php
+++ b/src/Serializer/Type.php
@@ -13,12 +13,17 @@
use DateTimeInterface;
+use ReflectionNamedType;
+use ReflectionParameter;
+use ReflectionProperty;
+use ReflectionType;
+use ReflectionUnionType;
+
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;
@@ -51,20 +56,6 @@ public function isOneOf(self ...$types): bool
return in_array($this, $types, true);
}
- public static function tryFromPropertyType(string $propertyType): ?self
- {
- $type = ltrim($propertyType, '?');
- $enumType = self::tryFrom($type);
-
- return match (true) {
- $enumType instanceof self => $enumType,
- 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) {
@@ -89,4 +80,88 @@ public function isScalar(): bool
default => false,
};
}
+
+ /**
+ * @return list
+ */
+ public static function list(ReflectionParameter|ReflectionProperty $reflectionProperty): array
+ {
+ $reflectionType = $reflectionProperty->getType() ?? 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.',
+ });
+
+ return self::typeList($reflectionType);
+ }
+
+ /**
+ * @return list
+ */
+ private static function typeList(ReflectionType $reflectionType): array
+ {
+ $foundTypes = static function (array $res, ReflectionType $reflectionType) {
+ if (!$reflectionType instanceof ReflectionNamedType) {
+ return $res;
+ }
+
+ $type = self::tryFromName($reflectionType->getName());
+ if (null !== $type) {
+ $res[] = [$type, $reflectionType];
+ }
+
+ return $res;
+ };
+
+ if ($reflectionType instanceof ReflectionNamedType) {
+ $type = self::tryFromName($reflectionType->getName());
+ if (null !== $type) {
+ return [[$type, $reflectionType]];
+ }
+
+ return [];
+ }
+
+ if ($reflectionType instanceof ReflectionUnionType) {
+ return array_reduce($reflectionType->getTypes(), $foundTypes, []);
+ }
+
+ return [];
+ }
+
+ public static function tryFromReflectionType(ReflectionType $type): ?self
+ {
+ if ($type instanceof ReflectionNamedType) {
+ return self::tryFromName($type->getName());
+ }
+
+ if (!$type instanceof ReflectionUnionType) {
+ return null;
+ }
+
+ foreach ($type->getTypes() as $innerType) {
+ if (!$innerType instanceof ReflectionNamedType) {
+ continue;
+ }
+
+ $result = self::tryFromName($innerType->getName());
+ if ($result instanceof self) {
+ return $result;
+ }
+ }
+
+ return null;
+ }
+
+ private static function tryFromName(string $propertyType): ?self
+ {
+ $type = self::tryFrom($propertyType);
+
+ return match (true) {
+ $type instanceof self => $type,
+ enum_exists($propertyType) => self::Enum,
+ interface_exists($propertyType) && DateTimeInterface::class === $propertyType,
+ class_exists($propertyType) && in_array(DateTimeInterface::class, class_implements($propertyType), true) => self::Date,
+ default => null,
+ };
+ }
}
diff --git a/src/SerializerTest.php b/src/SerializerTest.php
index f9921591..ed5f7bfa 100644
--- a/src/SerializerTest.php
+++ b/src/SerializerTest.php
@@ -13,6 +13,7 @@
namespace League\Csv;
+use Countable;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
@@ -23,6 +24,7 @@
use PHPUnit\Framework\TestCase;
use SplFileObject;
use stdClass;
+use Traversable;
final class SerializerTest extends TestCase
{
@@ -157,7 +159,7 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi
public function testItWillFailForLackOfTypeCasting(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('No valid type casting for `SplFileObject $observedOn`.');
+ $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$observedOn` type.');
new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']);
}
@@ -165,13 +167,25 @@ public function testItWillFailForLackOfTypeCasting(): void
public function testItWillThrowIfTheClassContainsUninitializedProperties(): void
{
$this->expectException(MappingFailed::class);
- $this->expectExceptionMessage('No valid type casting was found for the property `annee` must be typed.');
+ $this->expectExceptionMessage('No valid type casting was found for the property `annee`; it must be typed.');
Serializer::assign(
InvalidObjectWithUninitializedProperty::class,
['prenoms' => 'John', 'nombre' => '42', 'sexe' => 'M', 'annee' => '2018']
);
}
+
+ public function testItCanNotAutodiscoverWithIntersectionType(): void
+ {
+ $this->expectException(MappingFailed::class);
+ $this->expectExceptionMessage('No built-in `League\Csv\Serializer\TypeCasting` class can handle `$traversable` type.');
+
+ $foobar = new class () {
+ public Countable&Traversable $traversable;
+ };
+
+ Serializer::assign($foobar::class, ['traversable' => '1']);
+ }
}
enum Place: string