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 {