From 93dccb858a2f47aa0e8b0000ef42a24f8df5cbb8 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Tue, 31 Oct 2023 06:35:14 +0100 Subject: [PATCH] Improve Mapper implementation --- docs/9.0/reader/tabular-data-reader.md | 26 +++++++------- src/Mapper/{Cell.php => Column.php} | 2 +- ...ellMappingFailed.php => MappingFailed.php} | 2 +- .../{CellConverter.php => PropertySetter.php} | 2 +- src/Mapper/Serializer.php | 32 +++++++++++------ src/Mapper/SerializerTest.php | 36 +++++++++---------- 6 files changed, 54 insertions(+), 46 deletions(-) rename src/Mapper/{Cell.php => Column.php} (97%) rename src/Mapper/{CellMappingFailed.php => MappingFailed.php} (85%) rename src/Mapper/{CellConverter.php => PropertySetter.php} (97%) diff --git a/docs/9.0/reader/tabular-data-reader.md b/docs/9.0/reader/tabular-data-reader.md index e6a198d7..d527b47b 100644 --- a/docs/9.0/reader/tabular-data-reader.md +++ b/docs/9.0/reader/tabular-data-reader.md @@ -145,18 +145,18 @@ We can define a PHP DTO using the following class and the `League\Csv\Mapper\Att ```php '!Y-m-d', 'timezone' => 'Africa/Kinshasa'] @@ -177,7 +177,7 @@ foreach ($csv->map(Weather::class) as $weather) { } ``` -The `Cell` attribute is responsible to link the record cell via its numeric or name offset and will +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. @@ -192,7 +192,7 @@ You can also provide your own class to typecast the cell value according to your specify your casting with the attribute: ```php -#[\League\Csv\Mapper\Cell( +#[\League\Csv\Mapper\Column( offset: rating, cast: IntegerRangeCasting, castArguments: ['min' => 0, 'max' => 5, 'default' => 2] @@ -244,7 +244,7 @@ readonly class IntegerRangeCasting implements TypeCasting } ``` -As you have probably noticed, the class constructor arguments are given to the `Cell` attribute via the +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 @@ -318,7 +318,7 @@ $resultSet = Statement::create()->process($reader); $exists = $resultSet->exists(fn (array $records) => in_array('twenty-five', $records, true)); -//$exists returns true if at least one cell contains the word `twenty-five` otherwise returns false, +//$exists returns true if at least one Column contains the word `twenty-five` otherwise returns false, ```

Added in version 9.11.0 for Reader and ResultSet.

@@ -405,8 +405,8 @@ foreach ($records->fetchPairs() as $firstname => $lastname) { - If no `$offsetIndex` is provided it defaults to `0`; - If no `$valueIndex` is provided it defaults to `1`; -- If no cell is found corresponding to `$offsetIndex` the row is skipped; -- If no cell is found corresponding to `$valueIndex` the `null` value is used; +- If no Column is found corresponding to `$offsetIndex` the row is skipped; +- If no Column is found corresponding to `$valueIndex` the `null` value is used;

If the TabularDataReader contains column names and the submitted arguments are not found, an Exception exception is thrown.

@@ -451,9 +451,9 @@ use League\Csv\ResultSet; $reader = Reader::createFromPath('/path/to/my/file.csv', 'r'); $resultSet = ResultSet::createFromTabularDataReader($reader); -$nbTotalCells = $resultSet->recude(fn (?int $carry, array $records) => ($carry ?? 0) + count($records)); +$nbTotalColumns = $resultSet->recude(fn (?int $carry, array $records) => ($carry ?? 0) + count($records)); -//$records contains the total number of celle contains in the $resultSet +//$records contains the total number of Columne contains in the $resultSet ``` The closure is similar as the one used with `array_reduce`. @@ -541,7 +541,7 @@ $reader = Reader::createFromPath('/path/to/my/file.csv') ### matching, matchingFirst, matchingFirstOrFail -The `matching` method allows selecting records, columns or cells from the tabular data reader that match the +The `matching` method allows selecting records, columns or Columns from the tabular data reader that match the [RFC7111](https://www.rfc-editor.org/rfc/rfc7111) expression and returns a new collection containing these elements without preserving the keys. The method wraps the functionality of `FragmentFinder::findAll`. Conversely, `matchingFirst` wraps the functionality of `FragmentFinder::findFirst` and last but not least, diff --git a/src/Mapper/Cell.php b/src/Mapper/Column.php similarity index 97% rename from src/Mapper/Cell.php rename to src/Mapper/Column.php index de44b7f9..68cee4e9 100644 --- a/src/Mapper/Cell.php +++ b/src/Mapper/Column.php @@ -16,7 +16,7 @@ use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] -final class Cell +final class Column { /** * @param ?class-string $cast diff --git a/src/Mapper/CellMappingFailed.php b/src/Mapper/MappingFailed.php similarity index 85% rename from src/Mapper/CellMappingFailed.php rename to src/Mapper/MappingFailed.php index 4fc51c7b..92b46607 100644 --- a/src/Mapper/CellMappingFailed.php +++ b/src/Mapper/MappingFailed.php @@ -15,6 +15,6 @@ use RuntimeException; -final class CellMappingFailed extends RuntimeException +final class MappingFailed extends RuntimeException { } diff --git a/src/Mapper/CellConverter.php b/src/Mapper/PropertySetter.php similarity index 97% rename from src/Mapper/CellConverter.php rename to src/Mapper/PropertySetter.php index 7d6ee940..1f41f021 100644 --- a/src/Mapper/CellConverter.php +++ b/src/Mapper/PropertySetter.php @@ -19,7 +19,7 @@ /** * @internal */ -final class CellConverter +final class PropertySetter { public function __construct( private readonly ReflectionMethod|ReflectionProperty $accessor, diff --git a/src/Mapper/Serializer.php b/src/Mapper/Serializer.php index 1d529a6b..a9965a52 100644 --- a/src/Mapper/Serializer.php +++ b/src/Mapper/Serializer.php @@ -23,7 +23,7 @@ final class Serializer { - /** @var array */ + /** @var array */ public readonly array $converters; /** @@ -41,7 +41,7 @@ public function __construct(public readonly string $className, array $header = [ return match ($offset) { null => $carry, - default => [...$carry, new CellConverter($accessor, $offset, $caster)], + default => [...$carry, new PropertySetter($accessor, $offset, $caster)], }; }; @@ -68,50 +68,60 @@ public function deserialize(array $record): object return $object; } + /** + * @param class-string $className + * + * @throws ReflectionException + */ + public static function map(string $className, array $record): object + { + return (new self($className, array_keys($record)))->deserialize($record); + } + /** * @param array $header * - * @throws CellMappingFailed + * @throws MappingFailed * * @return array{0:int<0, max>|null, 1:TypeCasting} */ private function getArguments(ReflectionProperty|ReflectionMethod $target, array $header): array { - $attributes = $target->getAttributes(Cell::class, ReflectionAttribute::IS_INSTANCEOF); + $attributes = $target->getAttributes(Column::class, ReflectionAttribute::IS_INSTANCEOF); if ([] === $attributes) { return [null, new CastToScalar()]; } if (1 < count($attributes)) { - throw new CellMappingFailed('Using more than one '.Cell::class.' attribute on a class property or method is not supported.'); + throw new MappingFailed('Using more than one '.Column::class.' attribute on a class property or method is not supported.'); } - /** @var Cell $cell */ + /** @var Column $cell */ $cell = $attributes[0]->newInstance(); $offset = $cell->offset; $cast = $this->getTypeCasting($cell); if (is_int($offset)) { return match (true) { - 0 > $offset, - [] !== $header && $offset > count($header) - 1 => throw new CellMappingFailed('cell integer position can only be positive or equals to 0; received `'.$offset.'`'), + 0 > $offset => throw new MappingFailed('cell integer position can only be positive or equals to 0; received `'.$offset.'`'), + [] !== $header && $offset > count($header) - 1 => throw new MappingFailed('cell integer position can not exceed header cell count.'), default => [$offset, $cast], }; } if ([] === $header) { - throw new CellMappingFailed('Cell name as string are only supported if the tabular data has a non-empty header.'); + throw new MappingFailed('Cell name as string are only supported 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 CellMappingFailed('The offset `'.$offset.'` could not be found in the header; Pleaser verify your header data.'); + throw new MappingFailed('The offset `'.$offset.'` could not be found in the header; Pleaser verify your header data.'); } return [$index, $cast]; } - private function getTypeCasting(Cell $cell): TypeCasting + private function getTypeCasting(Column $cell): TypeCasting { $caster = $cell->cast; if (null === $caster) { diff --git a/src/Mapper/SerializerTest.php b/src/Mapper/SerializerTest.php index 280b375d..58eec135 100644 --- a/src/Mapper/SerializerTest.php +++ b/src/Mapper/SerializerTest.php @@ -30,8 +30,7 @@ public function testItConvertsARecordsToAnObjectUsingProperties(): void 'place' => 'Berkeley', ]; - $serializer = new Serializer(WeatherProperty::class, ['date', 'temperature', 'place']); - $weather = $serializer->deserialize($record); + $weather = Serializer::map(WeatherProperty::class, $record); self::assertInstanceOf(WeatherProperty::class, $weather); self::assertInstanceOf(DateTimeImmutable::class, $weather->observedOn); @@ -47,8 +46,7 @@ public function testItConvertsARecordsToAnObjectUsingMethods(): void 'place' => 'Berkeley', ]; - $serializer = new Serializer(WeatherSetterGetter::class, ['date', 'temperature', 'place']); - $weather = $serializer->deserialize($record); + $weather = Serializer::map(WeatherSetterGetter::class, $record); self::assertInstanceOf(WeatherSetterGetter::class, $weather); self::assertSame('2023-10-30', $weather->getObservedOn()->format('Y-m-d')); @@ -58,7 +56,7 @@ public function testItConvertsARecordsToAnObjectUsingMethods(): void public function testItWillThrowIfTheHeaderIsMissingAndTheColumnOffsetIsAString(): void { - $this->expectException(CellMappingFailed::class); + $this->expectException(MappingFailed::class); $serializer = new Serializer(WeatherSetterGetter::class); $serializer->deserialize([ 'date' => '2023-10-30', @@ -69,7 +67,7 @@ public function testItWillThrowIfTheHeaderIsMissingAndTheColumnOffsetIsAString() public function testItWillThrowIfTheHeaderContainsInvalidOffsetName(): void { - $this->expectException(CellMappingFailed::class); + $this->expectException(MappingFailed::class); $serializer = new Serializer(WeatherSetterGetter::class, ['date', 'toto', 'foobar']); $serializer->deserialize([ 'date' => '2023-10-30', @@ -80,7 +78,7 @@ public function testItWillThrowIfTheHeaderContainsInvalidOffsetName(): void public function testItWillThrowIfTheColumnAttributesIsUsedMultipleTimeForTheSameAccessor(): void { - $this->expectException(CellMappingFailed::class); + $this->expectException(MappingFailed::class); new Serializer(InvalidWeatherAttributeUsage::class); } @@ -96,11 +94,11 @@ public function testItWillThrowIfTheColumnAttributesCasterIsInvalid(): void final class WeatherProperty { public function __construct( - #[Cell(offset:'temperature')] + #[Column(offset:'temperature')] public readonly float $temperature, - #[Cell(offset:2, cast: CastToEnum::class)] + #[Column(offset:2, cast: CastToEnum::class)] public readonly Place $place, - #[Cell( + #[Column( offset: 'date', cast: CastToDate::class, castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], @@ -115,9 +113,9 @@ final class WeatherSetterGetter private float $temperature; public function __construct( - #[Cell(offset:2, cast: CastToEnum::class)] + #[Column(offset:2, cast: CastToEnum::class)] public readonly Place $place, - #[Cell( + #[Column( offset: 'date', cast: CastToDate::class, castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], @@ -126,7 +124,7 @@ public function __construct( ) { } - #[Cell(offset:'temperature')] + #[Column(offset:'temperature')] public function setTemperature(float $temperature): void { $this->temperature = $temperature; @@ -153,11 +151,11 @@ final class InvalidWeatherAttributeUsage { public function __construct( /* @phpstan-ignore-next-line */ - #[Cell(offset:'temperature'), Cell(offset:'date')] + #[Column(offset:'temperature'), Column(offset:'date')] public readonly float $temperature, - #[Cell(offset:2, cast: CastToEnum::class)] + #[Column(offset:2, cast: CastToEnum::class)] public readonly Place $place, - #[Cell( + #[Column( offset: 'date', cast: CastToDate::class, castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'], @@ -170,11 +168,11 @@ public function __construct( final class InvalidWeatherAttributeCasterNotSupported { public function __construct( - #[Cell(offset:'temperature', cast: stdClass::class)] + #[Column(offset:'temperature', cast: stdClass::class)] public readonly float $temperature, - #[Cell(offset:2, cast: CastToEnum::class)] + #[Column(offset:2, cast: CastToEnum::class)] public readonly Place $place, - #[Cell( + #[Column( offset: 'date', cast: CastToDate::class, castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa'],