Skip to content

Commit dbf73c6

Browse files
committed
#508 Adding Object Mapping
1 parent 114c2c3 commit dbf73c6

17 files changed

+1072
-3
lines changed

.php-cs-fixer.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'],
4343
'phpdoc_add_missing_param_annotation' => ['only_untyped' => true],
4444
'phpdoc_align' => ['align' => 'left'],
45-
'phpdoc_no_empty_return' => true,
45+
'phpdoc_no_empty_return' => false,
4646
'phpdoc_order' => true,
4747
'phpdoc_scalar' => true,
4848
'phpdoc_to_comment' => true,

docs/9.0/reader/tabular-data-reader.md

+129-1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,134 @@ and its value will represent its header value.
117117
This means that you can re-arrange the column order as well as removing or adding column to the
118118
returned iterator. Added column will only contain the `null` value.
119119

120+
### map
121+
122+
<p class="message-notice">New in version <code>9.12.0</code></p>
123+
124+
If you prefer working with objects instead of typed arrays it is possible to convert each record using
125+
the `map` method. This method will cast each array record into your specified object. To do so,
126+
the method excepts:
127+
128+
- as its sole argument the name of the class;
129+
- the given class to have information about type casting using the `League\Csv\Attribute\Column` attribute;
130+
131+
As an example if we assume we have the following CSV document
132+
133+
```csv
134+
date,temperature,place
135+
2011-01-01,1,Galway
136+
2011-01-02,-1,Galway
137+
2011-01-03,0,Galway
138+
2011-01-01,6,Berkeley
139+
2011-01-02,8,Berkeley
140+
2011-01-03,5,Berkeley
141+
```
142+
143+
We can define a PHP DTO using the following class and the `League\Csv\Mapper\Attribute\Column` attribute.
144+
145+
```php
146+
use League\Csv\Attribute\Column;
147+
use League\Csv\TypeCasting\CastToEnum;
148+
use League\Csv\TypeCasting\CastToDate;
149+
150+
final readonly class Weather
151+
{
152+
public function __construct(
153+
#[Column(offset:'temperature')]
154+
public int $temperature,
155+
#[Column(offset:2, cast: CastToEnum::class)]
156+
public Place $place,
157+
#[Column(
158+
offset: 'date',
159+
cast: CastToDate::class,
160+
castArguments: ['format' => '!Y-m-d', 'timezone' => 'Africa/Kinshasa']
161+
)]
162+
public DateTimeImmutable $createdAt;
163+
) {
164+
}
165+
}
166+
```
167+
168+
Finally, to get your object back you will have to call the `map` method as show below:
169+
170+
```php
171+
$csv = Reader::createFromString($document);
172+
$csv->setHeaderOffset(0);
173+
foreach ($csv->map(Weather::class) as $weather) {
174+
// each $weather entry will be an instance of the Weather class;
175+
}
176+
```
177+
178+
The `Column` attribute is responsible to link the record cell via its numeric or name offset and will
179+
tell the mapper how to type cast the cell value to the DTO property. By default, if no casting
180+
rule is provided, the column will attempt to cast the cell value to the scalar type of
181+
the property. If type casting fails or is not possible, an exception will be thrown.
182+
183+
The library comes bundles with 3 type casting classes which relies on the property type information:
184+
185+
- `CastToScalar`: converts the cell value to a scalar type or `null` depending on the property type information.
186+
- `CastToDate`: converts the cell value into a PHP `DateTimeInterface` implementing object. You can optionally specify the date format and its timezone if needed.
187+
- `CastToEnum`: converts the cell vale into a PHP `Enum` backed or not.
188+
189+
You can also provide your own class to typecast the cell value according to your own rules. To do so, first,
190+
specify your casting with the attribute:
191+
192+
```php
193+
#[\League\Csv\Attribute\Column(
194+
offset: rating,
195+
cast: IntegerRangeCasting,
196+
castArguments: ['min' => 0, 'max' => 5, 'default' => 2]
197+
)]
198+
private int $positiveInt;
199+
```
200+
201+
The `IntegerRangeCasting` will convert cell value and return data between `0` and `5` and default to `2` if
202+
the value is wrong or invalid. To allow your object to cast the cell value to your liking it needs to
203+
implement the `TypeCasting` interface. To do so, you must define a `toVariable` method that will return
204+
the correct value once converted.
205+
206+
```php
207+
use League\Csv\TypeCasting\TypeCasting;
208+
209+
/**
210+
* @implements TypeCasting<int|null>
211+
*/
212+
readonly class IntegerRangeCasting implements TypeCasting
213+
{
214+
public function __construct(
215+
private int $min,
216+
private int $max,
217+
private int $default,
218+
) {
219+
if ($max < $min) {
220+
throw new LogicException('The maximun value can not be smaller than the minimun value.');
221+
}
222+
}
223+
224+
public function toVariable(?string $value, string $type): ?int
225+
{
226+
// if the property is declared as nullable we exist early
227+
if (in_array($value, ['', null], true) && str_starts_with($type, '?')) {
228+
return null;
229+
}
230+
231+
//the type casting class must only work with property declared as integer
232+
if ('int' !== ltrim($type, '?')) {
233+
throw new RuntimeException('The class '. self::class . ' can only work with integer typed property.');
234+
}
235+
236+
return filter_var(
237+
$value,
238+
FILTER_VALIDATE_INT,
239+
['options' => ['min' => $this->min, 'max' => $this->max, 'default' => $this->default]]
240+
);
241+
}
242+
}
243+
```
244+
245+
As you have probably noticed, the class constructor arguments are given to the `Column` attribute via the
246+
`castArguments` which can provide more fine-grained behaviour.
247+
120248
### value, first and nth
121249

122250
You may access any record using its offset starting at `0` in the collection using the `nth` method.
@@ -291,7 +419,7 @@ closure.
291419
use League\Csv\Reader;
292420
use League\Csv\Writer;
293421

294-
$writer = Writer::createFromString('');
422+
$writer = Writer::createFromString();
295423
$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
296424
$reader->each(function (array $record, int $offset) use ($writer) {
297425
if ($offset < 10) {

src/Attribute/Column.php

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Attribute;
15+
16+
use Attribute;
17+
18+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)]
19+
final class Column
20+
{
21+
/**
22+
* @param ?class-string $cast
23+
*/
24+
public function __construct(
25+
public readonly string|int $offset,
26+
public readonly ?string $cast = null,
27+
public readonly array $castArguments = []
28+
) {
29+
}
30+
}

src/CellMapper.php

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv;
15+
16+
use League\Csv\TypeCasting\TypeCasting;
17+
use ReflectionMethod;
18+
use ReflectionProperty;
19+
20+
/**
21+
* @internal
22+
*/
23+
final class CellMapper
24+
{
25+
public function __construct(
26+
public readonly int $offset,
27+
private readonly ReflectionMethod|ReflectionProperty $accessor,
28+
private readonly TypeCasting $cast,
29+
) {
30+
}
31+
32+
public function __invoke(object $object, ?string $value): void
33+
{
34+
$type = (string) match (true) {
35+
$this->accessor instanceof ReflectionMethod => $this->accessor->getParameters()[0]->getType(),
36+
$this->accessor instanceof ReflectionProperty => $this->accessor->getType(),
37+
};
38+
39+
$value = $this->cast->toVariable($value, $type);
40+
41+
match (true) {
42+
$this->accessor instanceof ReflectionMethod => $this->accessor->invoke($object, $value),
43+
$this->accessor instanceof ReflectionProperty => $this->accessor->setValue($object, $value),
44+
};
45+
}
46+
}

src/Mapper.php

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv;
15+
16+
use ArrayIterator;
17+
use Iterator;
18+
use ReflectionException;
19+
use RuntimeException;
20+
21+
class Mapper
22+
{
23+
/**
24+
* @param class-string $className
25+
*/
26+
public function __construct(private readonly string $className)
27+
{
28+
}
29+
30+
/**
31+
* @throws RuntimeException
32+
* @throws ReflectionException
33+
*/
34+
public function map(TabularDataReader $tabularDataReader): Iterator
35+
{
36+
return $this($tabularDataReader, $tabularDataReader->getHeader());
37+
}
38+
39+
/**
40+
* @throws RuntimeException
41+
* @throws ReflectionException
42+
*/
43+
public function __invoke(iterable $records, array $header): Iterator
44+
{
45+
$mapper = new RecordMapper($this->className, $header);
46+
47+
return match (true) {
48+
is_array($records) => new MapIterator(new ArrayIterator($records), $mapper(...)),
49+
default => new MapIterator($records, $mapper(...)),
50+
};
51+
}
52+
}

src/Reader.php

+8
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,14 @@ public function select(string|int ...$columns): TabularDataReader
403403
return new ResultSet($this->combineHeader($this->prepareRecords(), $this->computeHeader($header)), $finalHeader);
404404
}
405405

406+
/**
407+
* @param class-string $class
408+
*/
409+
public function map(string $class): Iterator
410+
{
411+
return (new Mapper($class))->map($this);
412+
}
413+
406414
/**
407415
* @param array<string> $header
408416
*

0 commit comments

Comments
 (0)