Skip to content

Commit

Permalink
Improve Mapper implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Oct 31, 2023
1 parent e60bcff commit 93dccb8
Show file tree
Hide file tree
Showing 6 changed files with 54 additions and 46 deletions.
26 changes: 13 additions & 13 deletions docs/9.0/reader/tabular-data-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,18 @@ We can define a PHP DTO using the following class and the `League\Csv\Mapper\Att
```php
<?php

use League\Csv\Mapper\Cell;
use League\Csv\Mapper\Column;
use League\Csv\Mapper\CastToEnum;
use League\Csv\Mapper\CastToDate;

final readonly class Weather
{
public function __construct(
#[Cell(offset:'temperature')]
#[Column(offset:'temperature')]
public int $temperature,
#[Cell(offset:2, cast: CastToEnum::class)]
#[Column(offset:2, cast: CastToEnum::class)]
public Place $place,
#[Cell(
#[Column(
offset: 'date',
cast: CastToDate::class,
castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa']
Expand All @@ -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.
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
```

<p class="message-notice">Added in version <code>9.11.0</code> for <code>Reader</code> and <code>ResultSet</code>.</p>
Expand Down Expand Up @@ -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;

<p class="message-warning">If the <code>TabularDataReader</code> contains column names and the submitted arguments are not found, an <code>Exception</code> exception is thrown.</p>

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/Mapper/Cell.php → src/Mapper/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)]
final class Cell
final class Column
{
/**
* @param ?class-string $cast
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@

use RuntimeException;

final class CellMappingFailed extends RuntimeException
final class MappingFailed extends RuntimeException
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
/**
* @internal
*/
final class CellConverter
final class PropertySetter
{
public function __construct(
private readonly ReflectionMethod|ReflectionProperty $accessor,
Expand Down
32 changes: 21 additions & 11 deletions src/Mapper/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

final class Serializer
{
/** @var array<CellConverter> */
/** @var array<PropertySetter> */
public readonly array $converters;

/**
Expand All @@ -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)],
};
};

Expand All @@ -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<string> $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) {
Expand Down
36 changes: 17 additions & 19 deletions src/Mapper/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'));
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -80,7 +78,7 @@ public function testItWillThrowIfTheHeaderContainsInvalidOffsetName(): void

public function testItWillThrowIfTheColumnAttributesIsUsedMultipleTimeForTheSameAccessor(): void
{
$this->expectException(CellMappingFailed::class);
$this->expectException(MappingFailed::class);

new Serializer(InvalidWeatherAttributeUsage::class);
}
Expand All @@ -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'],
Expand All @@ -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'],
Expand All @@ -126,7 +124,7 @@ public function __construct(
) {
}

#[Cell(offset:'temperature')]
#[Column(offset:'temperature')]
public function setTemperature(float $temperature): void
{
$this->temperature = $temperature;
Expand All @@ -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'],
Expand All @@ -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'],
Expand Down

0 comments on commit 93dccb8

Please sign in to comment.