From e80001ef74c960b1bee5ca6a6d25693e0020a66d Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Mon, 30 Oct 2023 19:56:42 +0100 Subject: [PATCH] #508 Adding Object Mapping --- docs/9.0/reader/tabular-data-reader.md | 130 ++++++++++++++++- src/Attribute/Column.php | 30 ++++ src/CellMapper.php | 46 ++++++ src/Mapper.php | 52 +++++++ src/Reader.php | 8 ++ src/RecordMapper.php | 128 +++++++++++++++++ src/RecordMapperTest.php | 189 +++++++++++++++++++++++++ src/ResultSet.php | 8 ++ src/TabularDataReader.php | 3 +- src/TypeCasting/CastToDate.php | 77 ++++++++++ src/TypeCasting/CastToDateTest.php | 57 ++++++++ src/TypeCasting/CastToEnum.php | 54 +++++++ src/TypeCasting/CastToEnumTest.php | 89 ++++++++++++ src/TypeCasting/CastToScalar.php | 81 +++++++++++ src/TypeCasting/CastToScalarTest.php | 91 ++++++++++++ src/TypeCasting/TypeCasting.php | 29 ++++ 16 files changed, 1070 insertions(+), 2 deletions(-) create mode 100644 src/Attribute/Column.php create mode 100644 src/CellMapper.php create mode 100644 src/Mapper.php create mode 100644 src/RecordMapper.php create mode 100644 src/RecordMapperTest.php create mode 100644 src/TypeCasting/CastToDate.php create mode 100644 src/TypeCasting/CastToDateTest.php create mode 100644 src/TypeCasting/CastToEnum.php create mode 100644 src/TypeCasting/CastToEnumTest.php create mode 100644 src/TypeCasting/CastToScalar.php create mode 100644 src/TypeCasting/CastToScalarTest.php create mode 100644 src/TypeCasting/TypeCasting.php diff --git a/docs/9.0/reader/tabular-data-reader.md b/docs/9.0/reader/tabular-data-reader.md index 60021ced..8c6e2c49 100644 --- a/docs/9.0/reader/tabular-data-reader.md +++ b/docs/9.0/reader/tabular-data-reader.md @@ -117,6 +117,134 @@ and its value will represent its header value. This means that you can re-arrange the column order as well as removing or adding column to the returned iterator. Added column will only contain the `null` value. +### map + +

New in version 9.12.0

+ +If you prefer working with objects instead of typed arrays it is possible to convert each record using +the `map` method. This method will cast each array record into your specified object. To do so, +the method excepts: + +- as its sole argument the name of the class; +- the given class to have information about type casting using the `League\Csv\Attribute\Column` attribute; + +As an example if we assume we have the following CSV document + +```csv +date,temperature,place +2011-01-01,1,Galway +2011-01-02,-1,Galway +2011-01-03,0,Galway +2011-01-01,6,Berkeley +2011-01-02,8,Berkeley +2011-01-03,5,Berkeley +``` + +We can define a PHP DTO using the following class and the `League\Csv\Mapper\Attribute\Column` attribute. + +```php +use League\Csv\Attribute\Column; +use League\Csv\TypeCasting\CastToEnum; +use League\Csv\TypeCasting\CastToDate; + +final readonly class Weather +{ + public function __construct( + #[Column(offset:'temperature')] + public int $temperature, + #[Column(offset:2, cast: CastToEnum::class)] + public Place $place, + #[Column( + offset: 'date', + cast: CastToDate::class, + castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'] + )] + public DateTimeImmutable $createdAt; + ) { + } +} +``` + +Finally, to get your object back you will have to call the `map` method as show below: + +```php +$csv = Reader::createFromString($document); +$csv->setHeaderOffset(0); +foreach ($csv->map(Weather::class) as $weather) { + // each $weather entry will be an instance of the Weather class; +} +``` + +The `Column` attribute is responsible to link the record cell via its numeric or name offset and will +tell the mapper how to type cast the cell value to the DTO property. By default, if no casting +rule is provided, the column will attempt to cast the cell value to the scalar type of +the property. If type casting fails or is not possible, an exception will be thrown. + +The library comes bundles with 3 type casting classes which relies on the property type information: + +- `CastToScalar`: converts the cell value to a scalar type or `null` depending on the property type information. +- `CastToDate`: converts the cell value into a PHP `DateTimeInterface` implementing object. You can optionally specify the date format and its timezone if needed. +- `CastToEnum`: converts the cell vale into a PHP `Enum` backed or not. + +You can also provide your own class to typecast the cell value according to your own rules. To do so, first, +specify your casting with the attribute: + +```php +#[\League\Csv\Attribute\Column( + offset: rating, + cast: IntegerRangeCasting, + castArguments: ['min' => 0, 'max' => 5, 'default' => 2] +)] +private int $positiveInt; +``` + +The `IntegerRangeCasting` will convert cell value and return data between `0` and `5` and default to `2` if +the value is wrong or invalid. To allow your object to cast the cell value to your liking it needs to +implement the `TypeCasting` interface. To do so, you must define a `toVariable` method that will return +the correct value once converted. + +```php +use League\Csv\TypeCasting\TypeCasting; + +/** + * @implements TypeCasting + */ +readonly class IntegerRangeCasting implements TypeCasting +{ + public function __construct( + private int $min, + private int $max, + private int $default, + ) { + if ($max < $min) { + throw new LogicException('The maximun value can not be smaller than the minimun value.'); + } + } + + public function toVariable(?string $value, string $type): ?int + { + // if the property is declared as nullable we exist early + if (in_array($value, ['', null], true) && str_starts_with($type, '?')) { + return null; + } + + //the type casting class must only work with property declared as integer + if ('int' !== ltrim($type, '?')) { + throw new RuntimeException('The class '. self::class . ' can only work with integer typed property.'); + } + + return filter_var( + $value, + FILTER_VALIDATE_INT, + ['options' => ['min' => $this->min, 'max' => $this->max, 'default' => $this->default]] + ); + } +} +``` + +As you have probably noticed, the class constructor arguments are given to the `Column` attribute via the +`castArguments` which can provide more fine-grained behaviour. + ### value, first and nth You may access any record using its offset starting at `0` in the collection using the `nth` method. @@ -291,7 +419,7 @@ closure. use League\Csv\Reader; use League\Csv\Writer; -$writer = Writer::createFromString(''); +$writer = Writer::createFromString(); $reader = Reader::createFromPath('/path/to/my/file.csv', 'r'); $reader->each(function (array $record, int $offset) use ($writer) { if ($offset < 10) { diff --git a/src/Attribute/Column.php b/src/Attribute/Column.php new file mode 100644 index 00000000..1a53f747 --- /dev/null +++ b/src/Attribute/Column.php @@ -0,0 +1,30 @@ + + * + * 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\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] +final class Column +{ + /** + * @param ?class-string $cast + */ + public function __construct( + public readonly string|int $offset, + public readonly ?string $cast = null, + public readonly array $castArguments = [] + ) { + } +} diff --git a/src/CellMapper.php b/src/CellMapper.php new file mode 100644 index 00000000..ebb31982 --- /dev/null +++ b/src/CellMapper.php @@ -0,0 +1,46 @@ + + * + * 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; + +use League\Csv\TypeCasting\TypeCasting; +use ReflectionMethod; +use ReflectionProperty; + +/** + * @internal + */ +final class CellMapper +{ + public function __construct( + public readonly int $offset, + private readonly ReflectionMethod|ReflectionProperty $accessor, + private readonly TypeCasting $cast, + ) { + } + + public function __invoke(object $object, ?string $value): void + { + $type = (string) match (true) { + $this->accessor instanceof ReflectionMethod => $this->accessor->getParameters()[0]->getType(), + $this->accessor instanceof ReflectionProperty => $this->accessor->getType(), + }; + + $value = $this->cast->toVariable($value, $type); + + match (true) { + $this->accessor instanceof ReflectionMethod => $this->accessor->invoke($object, $value), + $this->accessor instanceof ReflectionProperty => $this->accessor->setValue($object, $value), + }; + } +} diff --git a/src/Mapper.php b/src/Mapper.php new file mode 100644 index 00000000..ff395942 --- /dev/null +++ b/src/Mapper.php @@ -0,0 +1,52 @@ + + * + * 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; + +use ArrayIterator; +use Iterator; +use ReflectionException; +use RuntimeException; + +class Mapper +{ + /** + * @param class-string $className + */ + public function __construct(private readonly string $className) + { + } + + /** + * @throws RuntimeException + * @throws ReflectionException + */ + public function map(TabularDataReader $tabularDataReader): Iterator + { + return $this($tabularDataReader, $tabularDataReader->getHeader()); + } + + /** + * @throws RuntimeException + * @throws ReflectionException + */ + public function __invoke(iterable $records, array $header): Iterator + { + $mapper = new RecordMapper($this->className, $header); + + return match (true) { + is_array($records) => new MapIterator(new ArrayIterator($records), $mapper(...)), + default => new MapIterator($records, $mapper(...)), + }; + } +} diff --git a/src/Reader.php b/src/Reader.php index 57e8b95b..a584a6ff 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -403,6 +403,14 @@ public function select(string|int ...$columns): TabularDataReader return new ResultSet($this->combineHeader($this->prepareRecords(), $this->computeHeader($header)), $finalHeader); } + /** + * @param class-string $class + */ + public function map(string $class): Iterator + { + return (new Mapper($class))->map($this); + } + /** * @param array $header * diff --git a/src/RecordMapper.php b/src/RecordMapper.php new file mode 100644 index 00000000..3eb2ad2c --- /dev/null +++ b/src/RecordMapper.php @@ -0,0 +1,128 @@ + + * + * 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; + +use League\Csv\Attribute\Column; +use League\Csv\TypeCasting\CastToScalar; +use League\Csv\TypeCasting\TypeCasting; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; +use ReflectionProperty; +use RuntimeException; +use TypeError; + +/** + * @template TValue + */ +final class RecordMapper +{ + /** @var array */ + public readonly array $mappers; + + /** + * @param class-string $className + * @param array $header + * + * @throws TypeError + * @throws RuntimeException + * @throws ReflectionException + */ + public function __construct(public readonly string $className, array $header = []) + { + $addMapper = function (array $mapper, ReflectionProperty|ReflectionMethod $accessor) use ($header) { + [$offset, $caster] = $this->getColumn($accessor, $header); + + return match ($offset) { + null => $mapper, + default => [...$mapper, new CellMapper($offset, $accessor, $caster)], + }; + }; + + $class = new ReflectionClass($this->className); + + $this->mappers = array_reduce( + [...$class->getProperties(), ...$class->getMethods(ReflectionMethod::IS_PUBLIC)], + $addMapper, + [] + ); + } + + public function __invoke(array $record): mixed + { + $record = array_values($record); + $object = (new ReflectionClass($this->className))->newInstanceWithoutConstructor(); + foreach ($this->mappers as $mapper) { + ($mapper)($object, $record[$mapper->offset]); + } + + return $object; + } + + /** + * @param array $header + * + * @throws RuntimeException + * + * @return array{0:int<0, max>|null, 1:TypeCasting} + */ + private function getColumn(ReflectionProperty|ReflectionMethod $target, array $header): array + { + $attributes = $target->getAttributes(Column::class, ReflectionAttribute::IS_INSTANCEOF); + if ([] === $attributes) { + return [null, new CastToScalar()]; + } + + if (1 < count($attributes)) { + throw new RuntimeException('Using multiple '.Column::class.' attributes on '.$target->getDeclaringClass()->getName().'::'.$target->getName().' is not supported.'); + } + + /** @var Column $column */ + $column = $attributes[0]->newInstance(); + $offset = $column->offset; + $cast = $this->getCast($column); + if (is_int($offset)) { + return match (true) { + 0 > $offset => throw new RuntimeException(__CLASS__.' can only use 0 or positive column indices.'), + default => [$offset, $cast], + }; + } + + if ([] === $header) { + throw new RuntimeException(__CLASS__.' can only use named column if the tabular data has a non-empty header.'); + } + + /** @var int<0, max>|false $index */ + $index = array_search($offset, $header, true); + if (false === $index) { + throw new RuntimeException(__CLASS__.' cound not find the offset `'.$offset.'` in the header; Pleaser verify your header data.'); + } + + return [$index, $cast]; + } + + private function getCast(Column $column): TypeCasting + { + $caster = $column->cast; + if (null === $caster) { + return new CastToScalar(); + } + + /** @var TypeCasting $cast */ + $cast = new $caster(...$column->castArguments); + + return $cast; + } +} diff --git a/src/RecordMapperTest.php b/src/RecordMapperTest.php new file mode 100644 index 00000000..c6c5623a --- /dev/null +++ b/src/RecordMapperTest.php @@ -0,0 +1,189 @@ + + * + * 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; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use League\Csv\Attribute\Column; +use League\Csv\TypeCasting\CastToDate; +use League\Csv\TypeCasting\CastToEnum; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use stdClass; +use TypeError; + +final class RecordMapperTest extends TestCase +{ + public function testItConvertsARecordsToAnObjectUsingProperties(): void + { + $record = [ + 'date' => '2023-10-30', + 'temperature' => '-1.5', + 'place' => 'Berkeley', + ]; + + $mapper = new RecordMapper(WeatherProperty::class, ['date', 'temperature', 'place']); + $weather = $mapper($record); + + self::assertInstanceOf(WeatherProperty::class, $weather); + self::assertInstanceOf(DateTimeImmutable::class, $weather->observedOn); + self::assertSame(Place::Berkeley, $weather->place); + self::assertSame(-1.5, $weather->temperature); + } + + public function testItConvertsARecordsToAnObjectUsingMethods(): void + { + $record = [ + 'date' => '2023-10-30', + 'temperature' => '-1.5', + 'place' => 'Berkeley', + ]; + + $mapper = new RecordMapper(WeatherSetterGetter::class, ['date', 'temperature', 'place']); + $weather = $mapper($record); + + self::assertInstanceOf(WeatherSetterGetter::class, $weather); + self::assertSame('2023-10-30', $weather->getObservedOn()->format('Y-m-d')); + self::assertSame(Place::Berkeley, $weather->place); + self::assertSame(-1.5, $weather->getTemperature()); + } + + public function testItWillThrowIfTheHeaderIsMissingAndTheColumnOffsetIsAString(): void + { + $this->expectException(RuntimeException::class); + $mapper = new RecordMapper(WeatherSetterGetter::class); + $mapper([ + 'date' => '2023-10-30', + 'temperature' => '-1.5', + 'place' => 'Berkeley', + ]); + } + + public function testItWillThrowIfTheHeaderContainsInvalidOffsetName(): void + { + $this->expectException(RuntimeException::class); + $mapper = new RecordMapper(WeatherSetterGetter::class, ['date', 'toto', 'foobar']); + $mapper([ + 'date' => '2023-10-30', + 'temperature' => '-1.5', + 'place' => 'Berkeley', + ]); + } + + public function testItWillThrowIfTheColumnAttributesIsUsedMultipleTimeForTheSameAccessor(): void + { + $this->expectException(RuntimeException::class); + + new RecordMapper(InvalidWeatherAttributeUsage::class); + } + + public function testItWillThrowIfTheColumnAttributesCasterIsInvalid(): void + { + $this->expectException(TypeError::class); + + new RecordMapper(InvalidWeatherAttributeCasterNotSupported::class); + } +} + +final class WeatherProperty +{ + public function __construct( + #[Column(offset:'temperature')] + public readonly float $temperature, + #[Column(offset:2, cast: CastToEnum::class)] + public readonly \League\Csv\Mapper\Place $place, + #[Column( + offset: 'date', + cast: CastToDate::class, + castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], + )] + public readonly DateTimeInterface $observedOn + ) { + } +} + +final class WeatherSetterGetter +{ + private float $temperature; + + public function __construct( + #[Column(offset:2, cast: CastToEnum::class)] + public readonly Place $place, + #[Column( + offset: 'date', + cast: CastToDate::class, + castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], + )] + private readonly DateTime $observedOn + ) { + } + + #[Column(offset:'temperature')] + public function setTemperature(float $temperature): void + { + $this->temperature = $temperature; + } + + public function getTemperature(): float + { + return $this->temperature; + } + + public function getObservedOn(): DateTime + { + return $this->observedOn; + } +} + +enum Place: string +{ + case Galway = 'Galway'; + case Berkeley = 'Berkeley'; +} + +final class InvalidWeatherAttributeUsage +{ + public function __construct( + /* @phpstan-ignore-next-line */ + #[Column(offset:'temperature'), Column(offset:'date')] + public readonly float $temperature, + #[Column(offset:2, cast: CastToEnum::class)] + public readonly Place $place, + #[Column( + offset: 'date', + cast: CastToDate::class, + castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], + )] + public readonly DateTimeInterface $observedOn + ) { + } +} + +final class InvalidWeatherAttributeCasterNotSupported +{ + public function __construct( + #[Column(offset:'temperature', cast: stdClass::class)] + public readonly float $temperature, + #[Column(offset:2, cast: CastToEnum::class)] + public readonly Place $place, + #[Column( + offset: 'date', + cast: CastToDate::class, + castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], + )] + public readonly DateTimeInterface $observedOn + ) { + } +} diff --git a/src/ResultSet.php b/src/ResultSet.php index 7e85243f..5b043610 100644 --- a/src/ResultSet.php +++ b/src/ResultSet.php @@ -112,6 +112,14 @@ public function getIterator(): Iterator return $this->getRecords(); } + /** + * @param class-string $class + */ + public function map(string $class): Iterator + { + return (new Mapper($class))->map($this); + } + /** * @param Closure(array, array-key=): mixed $closure */ diff --git a/src/TabularDataReader.php b/src/TabularDataReader.php index 78d65d00..78c7b4d9 100644 --- a/src/TabularDataReader.php +++ b/src/TabularDataReader.php @@ -29,6 +29,7 @@ * @method bool each(Closure $closure) iterates over each record and passes it to a closure. Iteration is interrupted if the closure returns false * @method bool exists(Closure $closure) tells whether at least one record satisfies the predicate. * @method mixed reduce(Closure $closure, mixed $initial = null) reduces the collection to a single value, passing the result of each iteration into the subsequent iteration + * @method Iterator map(string $class) Returns the tabular data records as an iterator object containing instance of the defined class name. * @method TabularDataReader filter(Closure $closure) returns all the elements of this collection for which your callback function returns `true` * @method TabularDataReader slice(int $offset, int $length = null) extracts a slice of $length elements starting at position $offset from the Collection. * @method TabularDataReader sorted(Closure $orderBy) sorts the Collection according to the closure provided see Statement::orderBy method @@ -46,7 +47,7 @@ interface TabularDataReader extends Countable, IteratorAggregate public function count(): int; /** - * Returns the tabular data records as an iterator object. + * Returns the tabular data records as an iterator object containing flat array. * * Each record is represented as a simple array containing strings or null values. * diff --git a/src/TypeCasting/CastToDate.php b/src/TypeCasting/CastToDate.php new file mode 100644 index 00000000..c74222cf --- /dev/null +++ b/src/TypeCasting/CastToDate.php @@ -0,0 +1,77 @@ + + * + * 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\TypeCasting; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use RuntimeException; +use Throwable; + +/** + * @implements TypeCasting + */ +final class CastToDate implements TypeCasting +{ + private readonly ?DateTimeZone $timezone; + + /** + * @throws Exception + */ + public function __construct( + private readonly ?string $format = null, + DateTimeZone|string|null $timezone = null + ) { + $this->timezone = match (true) { + is_string($timezone) => new DateTimeZone($timezone), + default => $timezone, + }; + } + + /** + * @throws RuntimeException + */ + public function toVariable(?string $value, string $type): DateTimeImmutable|DateTime|null + { + if (in_array($value, ['', null], true)) { + return match (true) { + str_starts_with($type, '?') => null, + default => throw new RuntimeException('Unable to convert the `null` value.'), + }; + } + + try { + $date = match (ltrim($type, '?')) { + DateTimeImmutable::class, + DateTimeInterface::class => null !== $this->format ? DateTimeImmutable::createFromFormat($this->format, $value, $this->timezone) : new DateTimeImmutable($value, $this->timezone), + DateTime::class => null !== $this->format ? DateTime::createFromFormat($this->format, $value, $this->timezone) : new DateTime($value, $this->timezone), + default => throw new RuntimeException('Unable to cast the given data to a PHP DateTime related object.'), + }; + + if (false === $date) { + throw new RuntimeException('Unable to cast the given data to a PHP DateTime related object.'); + } + } catch (Throwable $exception) { + if (! $exception instanceof RuntimeException) { + $exception = new RuntimeException('Unable to cast the given data to a PHP DateTime related object.', 0, $exception); + } + + throw $exception; + } + + return $date; + } +} diff --git a/src/TypeCasting/CastToDateTest.php b/src/TypeCasting/CastToDateTest.php new file mode 100644 index 00000000..9321bafc --- /dev/null +++ b/src/TypeCasting/CastToDateTest.php @@ -0,0 +1,57 @@ + + * + * 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\TypeCasting; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class CastToDateTest extends TestCase +{ + public function testItCanConvertADateWithoutArguments(): void + { + $cast = new CastToDate(); + $date = $cast->toVariable('2023-10-30', DateTime::class); + + self::assertInstanceOf(DateTime::class, $date); + self::assertSame('30-10-2023', $date->format('d-m-Y')); + } + + public function testItCanConvertADateWithASpecificFormat(): void + { + $cast = new CastToDate('!Y-m-d', 'Africa/Kinshasa'); + $date = $cast->toVariable('2023-10-30', DateTimeInterface::class); + + self::assertInstanceOf(DateTimeImmutable::class, $date); + self::assertSame('30-10-2023 00:00:00', $date->format('d-m-Y H:i:s')); + self::assertEquals(new DateTimeZone('Africa/Kinshasa'), $date->getTimezone()); + } + + public function testItCShouldThrowIfNoConversionIsPossible(): void + { + $this->expectException(RuntimeException::class); + + (new CastToDate())->toVariable('foobar', DateTimeInterface::class); + } + + public function testItReturnsNullWhenTheVariableIsNullable(): void + { + $cast = new CastToDate(); + + self::assertNull($cast->toVariable(null, '?'.DateTime::class)); + } +} diff --git a/src/TypeCasting/CastToEnum.php b/src/TypeCasting/CastToEnum.php new file mode 100644 index 00000000..60d31383 --- /dev/null +++ b/src/TypeCasting/CastToEnum.php @@ -0,0 +1,54 @@ + + * + * 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\TypeCasting; + +use BackedEnum; +use ReflectionEnum; +use RuntimeException; +use Throwable; +use UnitEnum; + +/** + * @implements TypeCasting + */ +class CastToEnum implements TypeCasting +{ + /** + * @throws RuntimeException + */ + public function toVariable(?string $value, string $type): BackedEnum|UnitEnum|null + { + if (in_array($value, ['', null], true)) { + return match (true) { + str_starts_with($type, '?') => null, + default => throw new RuntimeException('Unable to convert the `null` value.'), + }; + } + + $enumName = ltrim($type, '?'); + + try { + $enum = new ReflectionEnum($enumName); + if (!$enum->isBacked()) { + return $enum->getCase($value)->getValue(); + } + + $backedValue = 'int' === $enum->getBackingType()?->getName() ? (int) $value : $value; + + return $enumName::from($backedValue); + } catch (Throwable $exception) { + throw new RuntimeException('Unable to cast to `'.$enumName.'` the value `'.$value.'`.', 0, $exception); + } + } +} diff --git a/src/TypeCasting/CastToEnumTest.php b/src/TypeCasting/CastToEnumTest.php new file mode 100644 index 00000000..3d616232 --- /dev/null +++ b/src/TypeCasting/CastToEnumTest.php @@ -0,0 +1,89 @@ + + * + * 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\TypeCasting; + +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class CastToEnumTest extends TestCase +{ + public function testItCanConvertAStringBackedEnum(): void + { + $cast = new CastToEnum(); + $orange = $cast->toVariable('orange', Colour::class); + + self::assertInstanceOf(Colour::class, $orange); + self::assertSame('Orange', $orange->name); + self::assertSame('orange', $orange->value); + } + + public function testItCanConvertAIntegerBackedEnum(): void + { + $cast = new CastToEnum(); + $monday = $cast->toVariable('1', DayOfTheWeek::class); + + self::assertInstanceOf(DayOfTheWeek::class, $monday); + self::assertSame('Monday', $monday->name); + self::assertSame(1, $monday->value); + } + + public function testItCanConvertAUnitEnum(): void + { + $cast = new CastToEnum(); + $naira = $cast->toVariable('Naira', Currency::class); + + self::assertInstanceOf(Currency::class, $naira); + self::assertSame('Naira', $naira->name); + } + + public function testItReturnsNullWhenTheVariableIsNullable(): void + { + $cast = new CastToEnum(); + + self::assertNull($cast->toVariable(null, '?'.Currency::class)); + } + + public function testThrowsOnNullIfTheVariableIsNotNullable(): void + { + $this->expectException(RuntimeException::class); + + (new CastToEnum())->toVariable(null, Currency::class); + } + + public function testThrowsIfTheValueIsNotRecognizedByTheEnum(): void + { + $this->expectException(RuntimeException::class); + + (new CastToEnum())->toVariable('green', Colour::class); + } +} + +enum Colour: string +{ + case Orange = 'orange'; + case Violet = 'violet'; +} + +enum DayOfTheWeek: int +{ + case Monday = 1; + case Tuesday = 2; +} + +enum Currency +{ + case Dollar; + case Euro; + case Naira; +} diff --git a/src/TypeCasting/CastToScalar.php b/src/TypeCasting/CastToScalar.php new file mode 100644 index 00000000..a4bb3440 --- /dev/null +++ b/src/TypeCasting/CastToScalar.php @@ -0,0 +1,81 @@ + + * + * 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\TypeCasting; + +use RuntimeException; + +use const FILTER_VALIDATE_BOOL; +use const FILTER_VALIDATE_FLOAT; +use const FILTER_VALIDATE_INT; + +/** + * @implements TypeCasting + */ +final class CastToScalar implements TypeCasting +{ + /** + * @throws RuntimeException + */ + public function toVariable(?string $value, string $type): int|float|bool|string|null + { + if (in_array($value, ['', null], true) && str_starts_with($type, '?')) { + return null; + } + + return match (ltrim($type, '?')) { + 'int' => $this->castToInt($value), + 'float' => $this->castToFloat($value), + 'bool' => filter_var($value, FILTER_VALIDATE_BOOL), + 'string' => $this->castToString($value), + 'null' => $this->castToNull($value), + default => throw new RuntimeException('Unable to convert the given data to a PHP scalar variable.'), + }; + } + + private function castToNull(?string $value) + { + return match ($value) { + null => $value, + default => throw new RuntimeException('The value `'.$value.'` can not be cast to an integer.'), + }; + } + + private function castToString(?string $value): string + { + return match (null) { + $value => throw new RuntimeException('The `null` value can not be cast to a string.'), + default => $value, + }; + } + + private function castToInt(?string $value): int + { + $returnedValue = filter_var($value, FILTER_VALIDATE_INT); + + return match (false) { + $returnedValue => throw new RuntimeException('The value `'.$value.'` can not be cast to an integer.'), + default => $returnedValue, + }; + } + + private function castToFloat(?string $value): float + { + $returnedValue = filter_var($value, FILTER_VALIDATE_FLOAT); + + return match (false) { + $returnedValue => throw new RuntimeException('The value `'.$value.'` can not be cast to a float.'), + default => $returnedValue, + }; + } +} diff --git a/src/TypeCasting/CastToScalarTest.php b/src/TypeCasting/CastToScalarTest.php new file mode 100644 index 00000000..932a2065 --- /dev/null +++ b/src/TypeCasting/CastToScalarTest.php @@ -0,0 +1,91 @@ + + * + * 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\TypeCasting; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class CastToScalarTest extends TestCase +{ + #[DataProvider('providesValidScalarValues')] + public function testItCanConvertWithValidValue(?string $value, string $type, int|float|string|bool|null $expected): void + { + self::assertSame($expected, (new CastToScalar())->toVariable($value, $type)); + } + + public static function providesValidScalarValues(): iterable + { + yield 'it can convert an integer' => [ + 'value' => '-1', + 'type' => 'int', + 'expected' => -1, + ]; + + yield 'it can convert a float' => [ + 'value' => '-1.5', + 'type' => 'float', + 'expected' => -1.5, + ]; + + yield 'it can convert a boolean true' => [ + 'value' => 'true', + 'type' => 'bool', + 'expected' => true, + ]; + + yield 'it can convert a string' => [ + 'value' => '1', + 'type' => 'string', + 'expected' => '1', + ]; + + yield 'it can convert a boolean false' => [ + 'value' => 'f', + 'type' => 'bool', + 'expected' => false, + ]; + + yield 'it can convert null to null' => [ + 'value' => null, + 'type' => 'null', + 'expected' => null, + ]; + + yield 'it can accept nullable int' => [ + 'value' => null, + 'type' => '?int', + 'expected' => null, + ]; + + yield 'it can accept nullable float' => [ + 'value' => null, + 'type' => '?float', + 'expected' => null, + ]; + + yield 'it can accept nullable string' => [ + 'value' => null, + 'type' => '?string', + 'expected' => null, + ]; + } + + public function testItThrowsIfTheConversionFails(): void + { + $this->expectException(RuntimeException::class); + + (new CastToScalar())->toVariable(null, 'int'); + } +} diff --git a/src/TypeCasting/TypeCasting.php b/src/TypeCasting/TypeCasting.php new file mode 100644 index 00000000..d988641a --- /dev/null +++ b/src/TypeCasting/TypeCasting.php @@ -0,0 +1,29 @@ + + * + * 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\TypeCasting; + +use RuntimeException; + +/** + * @template TValue + */ +interface TypeCasting +{ + /** + * @throws RuntimeException + * + * @return TValue + */ + public function toVariable(?string $value, string $type): mixed; +}