diff --git a/docs/9.0/reader/index.md b/docs/9.0/reader/index.md
index e520901e..98b24d88 100644
--- a/docs/9.0/reader/index.md
+++ b/docs/9.0/reader/index.md
@@ -384,6 +384,110 @@ $records = $stmt->process($reader);
//$records is a League\Csv\ResultSet object
```
+### Collection methods
+
+
New methods added in version 9.11
.
+
+To ease working with the loaded CSV document the following methods derived from collection are added.
+Some are just wrapper methods around the `Statement` class while others use the iterable nature
+of the CSV document.
+
+#### Reader::each
+
+Iterates over the records in the CSV document and passes each item to a closure:
+
+```php
+use League\Csv\Reader;
+use League\Csv\Writer;
+
+$writer = Writer::createFromString('');
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$reader->each(function (array $record, int $offset) use ($writer) {
+ if ($offset < 10) {
+ return $writer->insertOne($record);
+ }
+
+ return false;
+});
+
+//$writer will contain at most 10 lines coming from the $reader document.
+// the iteration stopped when the closure return false.
+```
+
+#### Reader::exists
+
+Tests for the existence of an element that satisfies the given predicate.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$exists = $reader->exists(fn (array $records) => in_array('twenty-five', $records, true));
+
+//$exists returns true if at cell one cell contains the word `twenty-five` otherwise returns false,
+```
+
+#### Reader::reduce
+
+Applies iteratively the given function to each element in the collection, so as to reduce the collection to
+a single value.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$nbTotalCells = $reader->recude(fn (?int $carry, array $records) => ($carry ?? 0) + count($records));
+
+//$records contains the total number of celle contains in the CSV documents.
+```
+
+#### 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.
+
+ Wraps the functionality of Statement::where
.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$records = $reader->filter(fn (array $record): => 5 === count($record));
+
+//$recors is a ResultSet object with only records with 5 elements
+```
+
+#### 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.
+
+ Wraps the functionality of Statement::offset
and Statement::limit
.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$records = $reader->slice(10, 25);
+
+//$records contains up to 25 rows starting at the offest 10 (the eleventh rows)
+```
+
+#### Reader::sorted
+
+Sorts the CSV document while keeping the original keys.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$records = $reader->sorted(fn (array $recordA, array $recordB) => $recordA['firstname'] <=> $recordB['firstname']);
+
+//$records is a ResultSet containing the sorted CSV document.
+//The original $reader is not changed
+```
+
+ Wraps the functionality of Statement::orderBy
.
+
## Records conversion
### Json serialization
diff --git a/docs/9.0/reader/resultset.md b/docs/9.0/reader/resultset.md
index b59f1e9d..037fb843 100644
--- a/docs/9.0/reader/resultset.md
+++ b/docs/9.0/reader/resultset.md
@@ -352,6 +352,127 @@ 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
+
+New methods added in version 9.11
.
+
+To ease working with the `ResultSet` the following methods derived from collection are added.
+Some are just wrapper methods around the `Statement` class while others use the iterable nature
+of the instance.
+
+#### ResultSet::each
+
+Iterates over the records in the CSV document and passes each item to a closure:
+
+```php
+use League\Csv\Reader;
+use League\Csv\Statement;
+use League\Csv\Writer;
+
+$writer = Writer::createFromString('');
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+
+$resultSet = Statement::create()->process($reader);
+$resultSet->each(function (array $record, int $offset) use ($writer) {
+ if ($offset < 10) {
+ return $writer->insertOne($record);
+ }
+
+ return false;
+});
+
+//$writer will contain at most 10 lines coming from the $resultSet.
+// the iteration stopped when the closure return false.
+```
+
+#### ResultSet::exists
+
+Tests for the existence of an element that satisfies the given predicate.
+
+```php
+use League\Csv\Reader;
+use League\Csv\Statement;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$resultSet = Statement::create()->process($reader);
+
+$exists = $resultSet->exists(fn (array $records) => in_array('twenty-five', $records, true));
+
+//$exists returns true if at cell one cell contains the word `twenty-five` otherwise returns false,
+```
+
+#### Reader::reduce
+
+Applies iteratively the given function to each element in the collection, so as to reduce the collection to
+a single value.
+
+```php
+use League\Csv\Reader;
+use League\Csv\Statement;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$resultSet = Statement::create()->process($reader);
+
+$nbTotalCells = $resultSet->recude(fn (?int $carry, array $records) => ($carry ?? 0) + count($records));
+
+//$records contains the total number of celle contains in the $resultSet
+```
+
+#### 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.
+
+ Wraps the functionality of Statement::where
.
+
+```php
+use League\Csv\Reader;
+use League\Csv\Statement;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$resultSet = Statement::create()->process($reader);
+
+$records = $resultSet->filter(fn (array $record): => 5 === count($record));
+
+//$recors is a ResultSet object with only records with 5 elements
+```
+
+#### 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.
+
+ Wraps the functionality of Statement::offset
and Statement::limit
.
+
+```php
+use League\Csv\Reader;
+use League\Csv\Statement;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$resultSet = Statement::create()->process($reader);
+
+$records = $resultSet->slice(10, 25);
+
+//$records contains up to 25 rows starting at the offset 10 (the eleventh rows)
+```
+
+#### Reader::sorted
+
+Sorts the CSV document while keeping the original keys.
+
+ Wraps the functionality of Statement::orderBy
.
+
+```php
+use League\Csv\Reader;
+
+$reader = Reader::createFromPath('/path/to/my/file.csv', 'r');
+$resultSet = Statement::create()->process($reader);
+
+$records = $resultSet->sorted(fn (array $recordA, array $recordB) => $recordA['firstname'] <=> $recordB['firstname']);
+
+//$records is a ResultSet containing the original resultSet.
+//The original ResultSet is not changed
+```
+
## Conversions
### Json serialization
diff --git a/src/Reader.php b/src/Reader.php
index db6a94fc..7e2ba311 100644
--- a/src/Reader.php
+++ b/src/Reader.php
@@ -14,6 +14,7 @@
namespace League\Csv;
use CallbackFilterIterator;
+use Closure;
use Iterator;
use JsonSerializable;
use SplFileObject;
@@ -232,6 +233,85 @@ public function jsonSerialize(): array
return array_values([...$this->getRecords()]);
}
+ /**
+ * @param Closure(array, array-key=): (void|bool|null) $closure
+ */
+ public function each(Closure $closure): bool
+ {
+ foreach ($this as $offset => $record) {
+ if (false === $closure($record, $offset)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @param Closure(array, array-key=): bool $closure
+ */
+ public function exists(Closure $closure): bool
+ {
+ foreach ($this as $offset => $record) {
+ if (true === $closure($record, $offset)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Closure(TInitial|null, array, array-key=): TInitial $closure
+ * @param TInitial|null $initial
+ *
+ * @template TInitial
+ *
+ * @return TInitial|null
+ */
+ public function reduce(Closure $closure, mixed $initial = null): mixed
+ {
+ foreach ($this as $offset => $record) {
+ $initial = $closure($initial, $record, $offset);
+ }
+
+ return $initial;
+ }
+
+ /**
+ * @param Closure(array, array-key): bool $closure
+ *
+ * @throws Exception
+ * @throws SyntaxError
+ */
+ public function filter(Closure $closure): TabularDataReader
+ {
+ return Statement::create()->where($closure)->process($this);
+ }
+
+ /**
+ * @param int<0, max> $offset
+ * @param int<-1, max> $length
+ *
+ * @throws Exception
+ * @throws SyntaxError
+ */
+ public function slice(int $offset, int $length = -1): TabularDataReader
+ {
+ return Statement::create()->offset($offset)->limit($length)->process($this);
+ }
+
+ /**
+ * @param Closure(array, array): int $orderBy
+ *
+ * @throws Exception
+ * @throws SyntaxError
+ */
+ public function sorted(Closure $orderBy): TabularDataReader
+ {
+ return Statement::create()->orderBy($orderBy)->process($this);
+ }
+
/**
* @param array $header
*
diff --git a/src/ReaderTest.php b/src/ReaderTest.php
index 9e3cfa49..7b948083 100644
--- a/src/ReaderTest.php
+++ b/src/ReaderTest.php
@@ -532,4 +532,75 @@ public function testGetHeaderThrowsIfTheFirstRecordOnlyContainsBOMString(): void
$this->expectException(Exception::class);
$csv->getHeader();
}
+
+ public function testSliceThrowException(): void
+ {
+ $this->expectException(InvalidArgument::class);
+
+ $this->csv->slice(0, -2); /* @phpstan-ignore-line */
+ }
+
+ public function testSlice(): void
+ {
+ self::assertContains(
+ ['jane', 'doe', 'jane.doe@example.com'],
+ [...$this->csv->slice(1)]
+ );
+ }
+
+ public function testOrderBy(): void
+ {
+ $calculated = $this->csv->sorted(fn (array $rowA, array $rowB): int => strcmp($rowA[0], $rowB[0])); /* @phpstan-ignore-line */
+
+ self::assertSame(array_reverse($this->expected), array_values([...$calculated]));
+ }
+
+ public function testOrderByWithEquity(): void
+ {
+ $calculated = $this->csv->sorted(fn (array $rowA, array $rowB): int => strlen($rowA[0]) <=> strlen($rowB[0])); /* @phpstan-ignore-line */
+
+ self::assertSame($this->expected, array_values([...$calculated]));
+ }
+
+ public function testReduce(): void
+ {
+ self::assertSame(7, $this->csv->reduce(fn (?int $carry, array $record): int => ($carry ?? 0) + count($record)));
+ }
+
+ public function testEach(): void
+ {
+ $toto = [];
+
+ $this->csv->each(function (array $record, string|int $offset) use (&$toto) { /* @phpstan-ignore-line */
+ $toto[$offset] = $record;
+
+ return true;
+ });
+
+ self::assertCount(2, $toto);
+ self::assertSame($toto, [...$this->csv]);
+ }
+
+ public function testEachStopped(): void
+ {
+ $toto = [];
+
+ $this->csv->each(function (array $record) use (&$toto) {
+ if (4 === count($record)) {
+ $toto[] = $record;
+
+ return false;
+ }
+
+ return true;
+ });
+
+ self::assertCount(1, $toto);
+ }
+
+ 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));
+ }
}
diff --git a/src/ResultSet.php b/src/ResultSet.php
index acd9b9cc..0c4a9e40 100644
--- a/src/ResultSet.php
+++ b/src/ResultSet.php
@@ -14,6 +14,7 @@
namespace League\Csv;
use CallbackFilterIterator;
+use Closure;
use Generator;
use Iterator;
use JsonSerializable;
@@ -88,6 +89,66 @@ public function getIterator(): Iterator
return $this->getRecords();
}
+ /**
+ * @param Closure(array, array-key=): mixed $closure
+ */
+ public function each(Closure $closure): bool
+ {
+ foreach ($this as $offset => $record) {
+ if (false === $closure($record, $offset)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @param Closure(array, array-key=): bool $closure
+ */
+ public function exists(Closure $closure): bool
+ {
+ foreach ($this as $offset => $record) {
+ if (true === $closure($record, $offset)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Closure(TInitial|null, array, array-key=): TInitial $closure
+ * @param TInitial|null $initial
+ *
+ * @template TInitial
+ *
+ * @return TInitial|null
+ */
+ public function reduce(Closure $closure, mixed $initial = null): mixed
+ {
+ foreach ($this as $offset => $record) {
+ $initial = $closure($initial, $record, $offset);
+ }
+
+ return $initial;
+ }
+
+ public function filter(Closure $closure): TabularDataReader
+ {
+ return Statement::create()->where($closure)->process($this);
+ }
+
+ public function slice(int $offset, int $length = null): TabularDataReader
+ {
+ return Statement::create()->offset($offset)->limit($length ?? -1)->process($this);
+ }
+
+ public function sorted(Closure $orderBy): TabularDataReader
+ {
+ return Statement::create()->orderBy($orderBy)->process($this);
+ }
+
/**
* @param array $header
*
diff --git a/src/ResultSetTest.php b/src/ResultSetTest.php
index 317f322a..3ef4d272 100644
--- a/src/ResultSetTest.php
+++ b/src/ResultSetTest.php
@@ -73,6 +73,18 @@ public function testFilter(): void
self::assertEquals($result3, $result2);
}
+ public function testFilterWithClassFilterMethod(): void
+ {
+ $func2 = fn (array $row): bool => !in_array('john', $row, true);
+ $result1 = $this->csv->filter(fn (array $row): bool => !in_array('jane', $row, true));
+ $result2 = $result1->filter($func2);
+ $result3 = $result2->filter($func2);
+
+ self::assertNotContains(['jane', 'doe', 'jane.doe@example.com'], [...$result1]);
+ self::assertCount(0, $result2);
+ self::assertEquals($result3, $result2);
+ }
+
#[DataProvider('invalidFieldNameProvider')]
public function testFetchColumnTriggersException(int|string $field): void
{
@@ -322,4 +334,80 @@ public function testHeaderThrowsExceptionOnInvalidColumnNames(): void
$resultSet = Statement::create()->process($csv);
Statement::create()->process($resultSet, ['foo', 3]);
}
+
+ public function testSliceThrowException(): void
+ {
+ $this->expectException(InvalidArgument::class);
+
+ Statement::create()->process($this->csv)->slice(0, -2);
+ }
+
+ public function testSlice(): void
+ {
+ self::assertContains(
+ ['jane', 'doe', 'jane.doe@example.com'],
+ [...Statement::create()->process($this->csv)->slice(1)]
+ );
+ }
+
+ public function testOrderBy(): void
+ {
+ $calculated = Statement::create()->process($this->csv)->sorted(fn (array $rowA, array $rowB): int => strcmp($rowA[0], $rowB[0]));
+
+ self::assertSame(array_reverse($this->expected), array_values([...$calculated]));
+ }
+
+ public function testOrderByWithEquity(): void
+ {
+ $calculated = Statement::create()->process($this->csv)->sorted(fn (array $rowA, array $rowB): int => strlen($rowA[0]) <=> strlen($rowB[0]));
+
+ self::assertSame($this->expected, array_values([...$calculated]));
+ }
+
+ public function testReduce(): void
+ {
+ self::assertSame(
+ 6,
+ Statement::create()
+ ->process($this->csv)
+ ->reduce(fn (?int $carry, array $record): int => ($carry ?? 0) + count($record))
+ );
+ }
+
+ public function testEach(): void
+ {
+ $toto = [];
+
+ Statement::create()->process($this->csv)->each(function (array $record, string|int $offset) use (&$toto) {
+ $toto[$offset] = $record;
+
+ return true;
+ });
+
+ self::assertCount(2, $toto);
+ self::assertSame($toto, [...$this->csv]);
+ }
+
+ public function testEachStopped(): void
+ {
+ $toto = [];
+
+ Statement::create()->process($this->csv)->each(function (array $record) use (&$toto) {
+ if (in_array('jane', $record, true)) {
+ $toto[] = $record;
+
+ return false;
+ }
+
+ return true;
+ });
+
+ self::assertCount(1, $toto);
+ }
+
+ public function testExistsRecord(): void
+ {
+ self::assertFalse(Statement::create()->process($this->csv)->exists(fn (array $record) => array_key_exists('foobar', $record)));
+ self::assertTrue(Statement::create()->process($this->csv)->exists(fn (array $record) => count($record) < 5));
+ }
}
diff --git a/src/TabularDataReader.php b/src/TabularDataReader.php
index 1bba5677..c96940c9 100644
--- a/src/TabularDataReader.php
+++ b/src/TabularDataReader.php
@@ -13,6 +13,7 @@
namespace League\Csv;
+use Closure;
use Countable;
use Iterator;
use IteratorAggregate;
@@ -24,6 +25,12 @@
* @method Iterator fetchColumnByOffset(int $offset) returns a column from its offset
* @method array first() returns the first record from the tabular data.
* @method array nth(int $nth_record) returns the nth record from the tabular data.
+ * @method bool each(Closure $closure) iterates over each record and passes it to a closure. Iteration is interrupted if the closure returns false
+ * @method bool exists(Closure $closure) tells whether at least one record satisfies the predicate.
+ * @method mixed reduce(Closure $closure, mixed $initial = null) reduces the collection to a single value, passing the result of each iteration into the subsequent iteration
+ * @method TabularDataReader filter(Closure $closure) returns all the elements of this collection for which your callback function returns `true`
+ * @method TabularDataReader slice(int $offset, int $length = null) extracts a slice of $length elements starting at position $offset from the Collection.
+ * @method TabularDataReader sorted(Closure $orderBy) sorts the Collection according to the closure provided see Statement::orderBy method
*/
interface TabularDataReader extends Countable, IteratorAggregate
{