Skip to content

Commit

Permalink
#508 Adding Object Mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Oct 30, 2023
1 parent 114c2c3 commit e80001e
Show file tree
Hide file tree
Showing 16 changed files with 1,070 additions and 2 deletions.
130 changes: 129 additions & 1 deletion docs/9.0/reader/tabular-data-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<p class="message-notice">New in version <code>9.12.0</code></p>

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<int|null>
*/
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.
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/Attribute/Column.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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 = []
) {
}
}
46 changes: 46 additions & 0 deletions src/CellMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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),
};
}
}
52 changes: 52 additions & 0 deletions src/Mapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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(...)),
};
}
}
8 changes: 8 additions & 0 deletions src/Reader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $header
*
Expand Down
Loading

0 comments on commit e80001e

Please sign in to comment.