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]);
+ }
}