Skip to content

Commit

Permalink
Adding new features
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Dec 3, 2023
1 parent c1dc31e commit 08a970c
Show file tree
Hide file tree
Showing 8 changed files with 434 additions and 4 deletions.
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All Notable changes to `Csv` will be documented in this file

## [Next] - TBD

### Added

- `SwapDelimiter` stream filter to allow working with multibyte CSV delimiter
- `League\Csv\Serializer\AfterMapping` to work around the limitation aroud constructor usage.

### Deprecated

- None

### Fixed

- None

### Removed

- None

## [9.12.0](https://github.com/thephpleague/csv/compare/9.11.0...9.12.0) - 2023-12-02

### Added
Expand Down Expand Up @@ -1380,7 +1399,7 @@ to manage BOM character with CSV.

Initial Release of `Bakame\Csv`

[Next]: https://github.com/thephpleague/csv/compare/9.11.0...master
[Next]: https://github.com/thephpleague/csv/compare/9.12.0...master
[9.10.0]: https://github.com/thephpleague/csv/compare/9.9.0...9.10.0
[9.9.0]: https://github.com/thephpleague/csv/compare/9.8.0...9.9.0
[9.8.0]: https://github.com/thephpleague/csv/compare/9.7.4...9.8.0
Expand Down
86 changes: 86 additions & 0 deletions docs/9.0/interoperability/multibyte-delimiter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
layout: default
title: Handling multibytes delimiter
---

# Multibyte delimiter

<p class="message-info">Available since version <code>9.13.0</code></p>

The `SwapDelimiter` is a PHP stream filter which enables converting the multibytes delimiter into a
suitable delimiter character to allow processing your CSV document.

## Usage with CSV objects

Out of the box, the package is not able to handle multibytes delimited CSV. You should first try to
see if by changing your PHP locale settings the CSV gets correctly parsed.

```php
use League\Csv\SwapDelimiter;
use League\Csv\Reader;

$document = <<<CSV
csv;content;in;japanese;locale
CSV;

setlocale(LC_ALL, 'ja_JP.SJIS');
$reader = Reader::createFromString($document);
$reader->setHeaderOffset(0);
$reader->first();
```

If that does not work you can then try using the `SwapDelimiter` stream filter.

```php
public static SwapDelimiter::addTo(AbstractCsv $csv, string $sourceDelimiter): void
```

The `SwapDelimiter::addTo` method will:

- register the stream filter if it is not already the case.
- add a stream filter using the specified CSV delimiter.
- for the `Writer` object it will convert the CSV single-byte delimiter into the `$sourceDelimiter`
- for the `Reader` object it will convert the `$sourceDelimiter` delimiter into a CSV single-byte delimiter

```php
use League\Csv\SwapDelimiter;
use League\Csv\Writer;

$writer = Writer::createFromString();
$writer->setDelimiter("\x02");
SwapDelimiter::addTo($writer, '💩');
$writer->insertOne(['toto', 'tata', 'foobar']);
$writer->toString();
//returns toto💩tata💩foobar\n
```

Once the `SwapDelimiter::addTo` is called you should not change your CSV `delimiter` setting. Or put in
other words. You should first set the CSV single-byte delimiter before calling the `SwapDelimiter` method.

Conversely, you can use the same technique with a `Reader` object.

```php
use League\Csv\SwapDelimiter;
use League\Csv\Reader;

$document = <<<CSV
observedOn💩temperature💩place
2023-10-01💩18💩Yamoussokro
2023-10-02💩21💩Yamoussokro
2023-10-03💩15💩Yamoussokro
CSV;

$reader = Reader::createFromString($document);
$reader->setHeaderOffset(0);
$reader->setDelimiter("\x02");
SwapDelimiter::addTo($reader, '💩');
$reader->first();
//returns ['observedOn' => '2023-10-01', 'temperature' => '18', 'place' => 'Yamoussokro']
```

<p class="message-info">For the conversion to work the best you should use a single-byte CSV delimiter
which is not present in the CSV itself. Generally a good candidate is a character in the ASCII range from 1
to 32 included (excluding the end of line character).</p>

<p class="message-warning">The CSV document content is <strong>never changed or replaced</strong> when
reading an existing CSV. The conversion is only persisted during writing after all the formatting is done.</p>
44 changes: 41 additions & 3 deletions docs/9.0/reader/record-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,12 @@ public properties or arguments typed with one of the following type:

the `nullable` aspect of the property is also automatically handled.

If the autodiscovery feature is not enough, you can complete the conversion information using the
`League\Csv\Serializer\MapCell` attribute.
If the autodiscovery feature is not enough, you can complete the conversion information using
the `League\Csv\Serializer\MapCell` attribute and the `League\Csv\Serializer\AfterMapping` attribute.

Here's an example of how the attribute works:
<p class="message-info">The <code>AfterMapping</code> attribute is added in version <code>9.13.0</code></p>

Here's an example of how the `League\Csv\Serializer\MapCell` attribute works:

```php
use League\Csv\Serializer;
Expand Down Expand Up @@ -162,6 +164,42 @@ header value, just like with <code>TabularDataReader::getRecords</code></p>

In any case, if type casting fails, an exception will be thrown.

Because we are not using the object constructor method, you can work around that limitation by
tagging one or more methods to be called after all the mapping is done. Tagging is made using
the `League\Csv\Serializer\AfterMapping` attribute.

<p class="mess">The feature is available since version <code>9.13.0</code></p>

```php
use League\Csv\Serializer;

#[Serializer\AfterMapping('validate')]
final class ClimateRecord
{
public function __construct(
public readonly Place $place,
public readonly ?float $temperature,
public readonly ?DateTimeImmutable $date,
) {
$this->validate();
}

protected function validate(): void
{
//further validation on your object
//or any other post construction methods
//that is needed to be called
}
}
```

In the above example, the `validate` method will be call once all the properties have been set but
before the object is returned. You can specify as many methods belonging to the class as you want
regardless of their visibility by separating them with a comma. The methods will be called
in the order they have been declared.

<p class="message-notice">If the method does not exist or requires explicit arguments an exception will be thrown.</p>

### Handling the empty string

Out of the box the mechanism makes no distinction between an empty string and the `null` value.
Expand Down
28 changes: 28 additions & 0 deletions src/Serializer/AfterMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <[email protected]>
*
* 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\Serializer;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class AfterMapping
{
/** @var array<string> $methods */
public readonly array $methods;

public function __construct(string ...$methods)
{
$this->methods = $methods;
}
}
43 changes: 43 additions & 0 deletions src/Serializer/Denormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ final class Denormalizer
private readonly array $properties;
/** @var array<PropertySetter> */
private readonly array $propertySetters;
/** @var array<ReflectionMethod> */
private readonly array $postMapCalls;

/**
* @param class-string $className
Expand All @@ -50,6 +52,7 @@ public function __construct(string $className, array $propertyNames = [])
$this->class = $this->setClass($className);
$this->properties = $this->class->getProperties();
$this->propertySetters = $this->setPropertySetters($propertyNames);
$this->postMapCalls = $this->setPostMapCalls();
}

public static function allowEmptyStringAsNull(): void
Expand Down Expand Up @@ -118,6 +121,10 @@ public function denormalizeAll(iterable $records): Iterator
$this->assertObjectIsInValidState($object);
}

foreach ($this->postMapCalls as $accessor) {
$accessor->invoke($object);
}

return $object;
};

Expand All @@ -136,6 +143,10 @@ public function denormalize(array $record): object
$this->hydrate($object, $record);
$this->assertObjectIsInValidState($object);

foreach ($this->postMapCalls as $accessor) {
$accessor->invoke($object);
}

return $object;
}

Expand Down Expand Up @@ -218,6 +229,38 @@ private function setPropertySetters(array $propertyNames): array
};
}

private function setPostMapCalls(): array
{
$methods = [];
$attributes = $this->class->getAttributes(AfterMapping::class, ReflectionAttribute::IS_INSTANCEOF);
$nbAttributes = count($attributes);
if (0 === $nbAttributes) {
return $methods;
}

if (1 < $nbAttributes) {
throw new MappingFailed('Using more than one `'.AfterMapping::class.'` attribute on a class property or method is not supported.');
}

/** @var AfterMapping $postMap */
$postMap = $attributes[0]->newInstance();
foreach ($postMap->methods as $method) {
try {
$accessor = $this->class->getMethod($method);
} catch (ReflectionException $exception) {
throw new MappingFailed('The method `'.$method.'` is not defined on the `'.$this->class->getName().'` class.', 0, $exception);
}

if (0 !== $accessor->getNumberOfRequiredParameters()) {
throw new MappingFailed('The method `'.$this->class->getName().'::'.$accessor->getName().'` has too many required parameters.');
}

$methods[] = $accessor;
}

return $methods;
}

/**
* @param array<string> $propertyNames
* @param array<?string> $methodNames
Expand Down
66 changes: 66 additions & 0 deletions src/Serializer/DenormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,30 @@ public function __construct(
new Denormalizer($foobar::class, ['temperature', 'place', 'observedOn']);
}

public function testItWillCallAMethodAfterMapping(): void
{
/** @var UsingAfterMapping $res */
$res = Denormalizer::assign(UsingAfterMapping::class, ['addition' => '1']);

self::assertSame(2, $res->addition);
}

public function testIfFailsToUseAfterMappingWithUnknownMethod(): void
{
$this->expectException(MappingFailed::class);
$this->expectExceptionMessage('The method `addTow` is not defined on the `'.MissingMethodAfterMapping::class.'` class.');

Denormalizer::assign(MissingMethodAfterMapping::class, ['addition' => '1']);
}

public function testIfFailsToUseAfterMappingWithInvalidArgument(): void
{
$this->expectException(MappingFailed::class);
$this->expectExceptionMessage('The method `'.RequiresArgumentAfterMapping::class.'::addOne` has too many required parameters.');

Denormalizer::assign(RequiresArgumentAfterMapping::class, ['addition' => '1']);
}

public function testItWillThrowIfTheClassContainsUninitializedProperties(): void
{
$foobar = new class ('prenoms', 18, 'M', new SplTempFileObject()) {
Expand Down Expand Up @@ -534,3 +558,45 @@ enum Place: string
case Yamoussokro = 'Yamoussokro';
case Abidjan = 'Abidjan';
}

#[AfterMapping('addOne')]
class UsingAfterMapping
{
public function __construct(public int $addition)
{
$this->addOne();
}

private function addOne(): void
{
++$this->addition;
}
}

#[AfterMapping('addOne', 'addTow')]
class MissingMethodAfterMapping
{
public function __construct(public int $addition)
{
$this->addOne();
}

private function addOne(): void
{
++$this->addition;
}
}

#[AfterMapping('addOne')]
class RequiresArgumentAfterMapping
{
public function __construct(public int $addition)
{
$this->addOne(1);
}

private function addOne(int $add): void
{
$this->addition += $add;
}
}
Loading

0 comments on commit 08a970c

Please sign in to comment.