diff --git a/README.md b/README.md index 0aebc75..5bb12e9 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ array. The details: ¹ `map`, `filter`, `reject`, `unique`, `uniqueBy`, `sort`, `sortBy`, `slice`, `take`, `skip`, `merge`, `each`, `tap`. ² `flatMap`, `values`, `keys`, `prepend`, `append`, `concat`: these produce a list, and a `Ds\Map` can't hold -a list, so it becomes a `Ds\Seq`. +a list, so it becomes a `Ds\Seq`. `chunk` also produces a list, of arrays, and gives a `Ds\Seq` for a `Ds\Set` too. A few consequences worth knowing: @@ -311,7 +311,8 @@ exactly like its target: `map`, `filter` (keeps truthy values without a callback), `reject`, `flatMap`, `values`, `keys`, `unique`, `uniqueBy`, `sort` (comparator or `<=>`), `sortBy` (selector, optionally `descending: true`), `slice`, `take`, -`skip`, `prepend`, `append`, `concat`, `merge`, `each`, `tap`. +`skip`, `chunk` (size, optionally `preserveKeys: true`, like `array_chunk()`), `prepend`, `append`, `concat`, `merge`, +`each`, `tap`. ```php $delays = $departures diff --git a/src/Collections/Lazy/transforms.php b/src/Collections/Lazy/transforms.php index 086af37..87e3eab 100644 --- a/src/Collections/Lazy/transforms.php +++ b/src/Collections/Lazy/transforms.php @@ -12,7 +12,8 @@ * * Every function returns a closure that takes any iterable and returns a generator, so nothing is done until the * result is consumed and a chain walks its source once. Keys are preserved, except for {@see values()}, - * {@see keys()}, {@see flatMap()}, {@see prepend()}, {@see append()} and {@see concat()}, which yield sequential keys. + * {@see keys()}, {@see flatMap()}, {@see prepend()}, {@see append()}, {@see concat()} and {@see chunk()}, which yield + * sequential keys. * End a chain with a collector or query from `Kadet\Functional\Collections`, e.g. `toArray()`. * * The eager transforms in `Kadet\Functional\Collections` are built on these, so both flavours behave the same. @@ -257,6 +258,42 @@ function skip(int $n): \Closure return slice($n); } +/** + * Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`, yielding each as soon + * as it is full, so the source is never held whole. + * + * Chunks are lists unless `$preserveKeys` is true, which keeps the keys in them. Keys must then be valid array keys, + * and values under a key repeated within one chunk replace each other, as in any array. + * + * @return \Closure(iterable): \Generator> + */ +function chunk(int $size, bool $preserveKeys = false): \Closure +{ + if ($size < 1) { + throw new \InvalidArgumentException(sprintf('chunk(): size must be >= 1, got %d', $size)); + } + + return static function (iterable $input) use ($size, $preserveKeys): \Generator { + $chunk = []; + foreach ($input as $key => $value) { + if ($preserveKeys) { + $chunk[$key] = $value; + } else { + $chunk[] = $value; + } + + if (count($chunk) === $size) { + yield $chunk; + $chunk = []; + } + } + + if ($chunk !== []) { + yield $chunk; + } + }; +} + /** * Puts `$values` before the values of the input, with sequential keys. * diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index 8fbaf63..027b345 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -490,6 +490,25 @@ function skip(int $n): \Closure return slice($n); } +/** + * Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`. See + * {@see Lazy\chunk()}. + * + * The chunks come in a list array for an array, and in a `Ds\Seq` (a list array without php-ds) for anything else. + * + * @return \Closure(iterable): iterable> + */ +function chunk(int $size, bool $preserveKeys = false): \Closure +{ + $lazy = lazy\chunk($size, $preserveKeys); + + return static function (iterable $input) use ($lazy): iterable { + $chunks = iterator_to_array($lazy(iterated($input)), false); + + return is_array($input) ? $chunks : seqOf($chunks); + }; +} + /** * Puts `$values` before the values of the input, as a list (keys are dropped). * diff --git a/tests/Collections/LazyTest.php b/tests/Collections/LazyTest.php index 25dc805..744ba4d 100644 --- a/tests/Collections/LazyTest.php +++ b/tests/Collections/LazyTest.php @@ -167,4 +167,48 @@ class LazyTest extends \PHPUnit\Framework\TestCase $this->assertSame([2, 3], iterator_to_array($step([1, 2]))); $this->assertSame([4], iterator_to_array($step([3]))); } + + public function testChunkSplitsIntoListsOfTheSize() + { + $this->assertSame( + [[1, 2], [3, 4], [5]], + iterator_to_array(lazy\chunk(2)(self::gen(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]))), + ); + } + + public function testChunkPreservesKeysWhenAsked() + { + $this->assertSame( + [['a' => 1, 'b' => 2], ['c' => 3]], + iterator_to_array(lazy\chunk(2, preserveKeys: true)(self::gen(['a' => 1, 'b' => 2, 'c' => 3]))), + ); + } + + public function testChunkKeepsValuesUnderRepeatedKeys() + { + $repeated = (function () { + yield 'a' => 1; + yield 'a' => 2; + })(); + + $this->assertSame([[1, 2]], iterator_to_array(lazy\chunk(2)($repeated))); + } + + public function testChunkYieldsEachChunkOnceFull() + { + $this->assertSame([1, 2, 3], lazy\chunk(3)(self::naturals())->current()); + } + + public function testChunkOfNothingYieldsNothing() + { + $this->assertSame([], iterator_to_array(lazy\chunk(2)([]))); + } + + public function testChunkRejectsSizeBelowOneWhenBuilt() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('chunk(): size must be >= 1, got 0'); + + lazy\chunk(0); + } } diff --git a/tests/Collections/TransformsTest.php b/tests/Collections/TransformsTest.php index e9a10a7..d3854fa 100644 --- a/tests/Collections/TransformsTest.php +++ b/tests/Collections/TransformsTest.php @@ -426,6 +426,23 @@ class TransformsTest extends \PHPUnit\Framework\TestCase $this->assertSeq([1, 2, 3], c\take(3)($infinite)); } + public function testChunk() + { + $this->assertSame([[1, 2], [3]], c\chunk(2)(['a' => 1, 'b' => 2, 'c' => 3])); + $this->assertSame([['a' => 1, 'b' => 2], ['c' => 3]], c\chunk(2, preserveKeys: true)(['a' => 1, 'b' => 2, 'c' => 3])); + $this->assertSeq([[1, 2], [3]], c\chunk(2)(new Seq([1, 2, 3]))); + $this->assertSeq([['a' => 1], ['b' => 2]], c\chunk(1, preserveKeys: true)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([[1, 2], [3]], c\chunk(2)(self::gen([1, 2, 3]))); + } + + public function testChunkRejectsSizeBelowOne() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('chunk(): size must be >= 1, got 0'); + + c\chunk(0); + } + public function testSkip() { $this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2])); diff --git a/tests/types/collections.php b/tests/types/collections.php index d0c642f..0ba9554 100644 --- a/tests/types/collections.php +++ b/tests/types/collections.php @@ -67,10 +67,12 @@ function transforms( // Transforms without a callback keep the element types, but not the container. assertType('iterable', c\values()($foos)); assertType('iterable, Foo>', c\take(2)($list)); + assertType('iterable, Foo>>', c\chunk(2)($list)); // Lazy transforms give generators. assertType('Generator', lazy\map(fn (Foo $foo): string => $foo->name)($list)); assertType('Generator', lazy\filter(fn (Foo $foo): bool => $foo->id > 0)($list)); + assertType('Generator, Foo>, mixed, mixed>', lazy\chunk(2)($list)); } /**