diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cce49a1..12f79d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,17 @@ All Notable changes to `Csv` will be documented in this file ### Added - `EscapeFormula::unescapeRecord` does the opposite of `EscapeFormula::escapeRecord` -- `TabularReader::each` -- `TabularReader::exists` -- `TabularReader::reduce` -- `TabularReader::filter` -- `TabularReader::slice` -- `TabularReader::sorted` +- `TabularReader::each` (implemented on the `Reader` and the `ResultSet` object) +- `TabularReader::exists` (implemented on the `Reader` and the `ResultSet` object) +- `TabularReader::reduce` (implemented on the `Reader` and the `ResultSet` object)**** +- `TabularReader::filter` (implemented on the `Reader` and the `ResultSet` object) +- `TabularReader::slice` (implemented on the `Reader` and the `ResultSet` object) +- `TabularReader::sorted` (implemented on the `Reader` and the `ResultSet` object) +- `Reader::addFormatter` (implemented on the `Reader` and the `ResultSet` object) ### Deprecated -- None +- `EscapeFormula::__invoke` use `EscapeFormula::__escapeRecord` instead ### Fixed diff --git a/docs/9.0/reader/index.md b/docs/9.0/reader/index.md index 7c9d542b..8a1394ad 100644 --- a/docs/9.0/reader/index.md +++ b/docs/9.0/reader/index.md @@ -235,6 +235,53 @@ foreach ($records as $offset => $record) { } ``` +### Reader::addFormatter + +

New methods added in version 9.11.

+ +#### Record Formatter + +A formatter is a `callable` which accepts a single CSV record as an `array` on input and returns an array +representing the formatted CSV record according to its inner rules. + +```php +function(array $record): array +``` + +#### Adding a Formatter to a Reader object + +You can attach as many formatters as you want to the `Reader` class using the `Reader::addFormatter` method. +Formatters are applied following the *First In First Out* rule. + +Fornatting happens **AFTER** combining headers and CSV value **BUT BEFORE** you can access the actual value. + +```php +use League\Csv\Reader; + +$csv = << array_map(strtoupper(...), $row); +$reader = Reader::createFromString($csv) + ->setHeaderOffset(0) + ->addFormatter($formatter); +[...$reader]; +// [ +// [ +// 'firstname' => 'JOHN', +// 'lastname' => DOE', +// 'e-mail' => 'JOHN.DOE@EXAMPLE.COM', +// ], +//]; + +echo $reader->toString(); //returns the original $csv value without the formatting. +``` + +

If a header is selected it won't be affected by the formatting

+

The CSV document is not affected by the formatting and keeps its original value

+ ### Controlling the presence of empty records

New since version 9.4.0

diff --git a/docs/9.0/reader/resultset.md b/docs/9.0/reader/resultset.md index 037fb843..241cd2c8 100644 --- a/docs/9.0/reader/resultset.md +++ b/docs/9.0/reader/resultset.md @@ -352,7 +352,7 @@ foreach ($records->fetchPairs() as $firstname => $lastname) {

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

-### Collection methods +## Collection methods

New methods added in version 9.11.

@@ -360,7 +360,7 @@ To ease working with the `ResultSet` the following methods derived from collecti Some are just wrapper methods around the `Statement` class while others use the iterable nature of the instance. -#### ResultSet::each +### ResultSet::each Iterates over the records in the CSV document and passes each item to a closure: @@ -385,7 +385,7 @@ $resultSet->each(function (array $record, int $offset) use ($writer) { // the iteration stopped when the closure return false. ``` -#### ResultSet::exists +### ResultSet::exists Tests for the existence of an element that satisfies the given predicate. @@ -401,7 +401,7 @@ $exists = $resultSet->exists(fn (array $records) => in_array('twenty-five', $rec //$exists returns true if at cell one cell contains the word `twenty-five` otherwise returns false, ``` -#### Reader::reduce +### Reader::reduce Applies iteratively the given function to each element in the collection, so as to reduce the collection to a single value. @@ -418,7 +418,7 @@ $nbTotalCells = $resultSet->recude(fn (?int $carry, array $records) => ($carry ? //$records contains the total number of celle contains in the $resultSet ``` -#### Reader::filter +### Reader::filter Returns all the elements of this collection for which your callback function returns `true`. The order and keys of the elements are preserved. @@ -436,7 +436,7 @@ $records = $resultSet->filter(fn (array $record): => 5 === count($record)); //$recors is a ResultSet object with only records with 5 elements ``` -#### Reader::slice +### Reader::slice Extracts a slice of $length elements starting at position $offset from the Collection. If $length is `-1` it returns all elements from `$offset` to the end of the Collection. Keys have to be preserved by this method. Calling this method will only return the selected slice and NOT change the elements contained in the collection slice is called on. @@ -455,7 +455,7 @@ $records = $resultSet->slice(10, 25); //$records contains up to 25 rows starting at the offset 10 (the eleventh rows) ``` -#### Reader::sorted +### Reader::sorted Sorts the CSV document while keeping the original keys. diff --git a/src/EscapeFormula.php b/src/EscapeFormula.php index bad1cdda..12969756 100644 --- a/src/EscapeFormula.php +++ b/src/EscapeFormula.php @@ -84,16 +84,6 @@ public function getEscape(): string return $this->escape; } - /** - * League CSV formatter hook. - * - * @see escapeRecord - */ - public function __invoke(array $record): array - { - return $this->escapeRecord($record); - } - /** * Escapes a CSV record. */ @@ -154,4 +144,17 @@ protected function isStringable(mixed $value): bool { return is_string($value) || $value instanceof Stringable; } + + /** + * @deprecated since 9.11.0 will be removed in the next major release + * @codeCoverageIgnore + * + * League CSV formatter hook. + * + * @see escapeRecord + */ + public function __invoke(array $record): array + { + return $this->escapeRecord($record); + } } diff --git a/src/EscapeFormulaTest.php b/src/EscapeFormulaTest.php index 95b57ea7..1e5130da 100644 --- a/src/EscapeFormulaTest.php +++ b/src/EscapeFormulaTest.php @@ -76,6 +76,21 @@ public function testUnescapeRecord(): void self::assertEquals($expected, $formatter->unescapeRecord($record)); } + public function testFormatterOnReader(): void + { + $escaoeFormula = new EscapeFormula(); + $record = ['2', '2017-07-25', 'Important Client', '=2+5', '240', "\ttab", "\rcr", '']; + $csv = Writer::createFromString(); + $csv->addFormatter($escaoeFormula->escapeRecord(...)); + $csv->insertOne($record); + + $reader = Reader::createFromString($csv->toString()); + self::assertNotEquals($record, $reader->first()); + + $reader->addFormatter($escaoeFormula->unescapeRecord(...)); + self::assertSame($record, $reader->first()); + } + public function testUnformatReader(): void { $formatter = new EscapeFormula(); diff --git a/src/Reader.php b/src/Reader.php index 7e2ba311..a2c00a31 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -46,12 +46,24 @@ class Reader extends AbstractCsv implements TabularDataReader, JsonSerializable protected bool $is_empty_records_included = false; /** @var array header record. */ protected array $header = []; + /** @var array callable collection to format the record before reading. */ + protected array $formatters = []; public static function createFromPath(string $path, string $open_mode = 'r', $context = null): static { return parent::createFromPath($path, $open_mode, $context); } + /** + * Adds a record formatter. + */ + public function addFormatter(callable $formatter): self + { + $this->formatters[] = $formatter; + + return $this; + } + protected function resetProperties(): void { parent::resetProperties(); @@ -374,12 +386,18 @@ protected function computeHeader(array $header): array */ protected function combineHeader(Iterator $iterator, array $header): Iterator { + $formatter = fn (array $record): array => array_reduce( + $this->formatters, + fn (array $record, callable $formatter): array => $formatter($record), + $record + ); + if ([] === $header) { - return $iterator; + return new MapIterator($iterator, $formatter(...)); } $field_count = count($header); - $mapper = function (array $record) use ($header, $field_count): array { + $mapper = function (array $record) use ($header, $field_count, $formatter): array { if (count($record) !== $field_count) { $record = array_slice(array_pad($record, $field_count, null), 0, $field_count); } @@ -387,7 +405,7 @@ protected function combineHeader(Iterator $iterator, array $header): Iterator /** @var array $assocRecord */ $assocRecord = array_combine($header, $record); - return $assocRecord; + return $formatter($assocRecord); }; return new MapIterator($iterator, $mapper); diff --git a/src/ReaderTest.php b/src/ReaderTest.php index 7b948083..a4a76394 100644 --- a/src/ReaderTest.php +++ b/src/ReaderTest.php @@ -603,4 +603,47 @@ public function testExistsRecord(): void self::assertFalse($this->csv->exists(fn (array $record) => array_key_exists('foobar', $record))); self::assertTrue($this->csv->exists(fn (array $record) => count($record) < 5)); } + + public function testReaderFormatterUsesOffset(): void + { + $csv = <<setHeaderOffset(0); + self::assertSame([ + [ + 'FirstName' => 'John', + 'LastName' => 'Doe', + 'Year' => '2001', + ], + [ + 'FirstName' => 'Jane', + 'LastName' => 'Doe', + 'Year' => '2005', + ], + ], [...$reader]); + + $reader->addFormatter(function (array $record): array { + $record['Year'] = (int) $record['Year']; + + return $record; + }); + + self::assertSame([ + [ + 'FirstName' => 'John', + 'LastName' => 'Doe', + 'Year' => 2001, + ], + [ + 'FirstName' => 'Jane', + 'LastName' => 'Doe', + 'Year' => 2005, + ], + ], [...$reader]); + } }