From 364b97deaaaa048f77d59ce5a7029b26d7a09787 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 22:24:58 +0100 Subject: [PATCH] add lazy collection transforms --- composer.json | 1 + src/Collections/Lazy/transforms.php | 356 ++++++++++++++++++++++++++++ src/Collections/transforms.php | 154 +++--------- tests/Collections/LazyTest.php | 193 +++++++++++++++ 4 files changed, 584 insertions(+), 120 deletions(-) create mode 100644 src/Collections/Lazy/transforms.php create mode 100644 tests/Collections/LazyTest.php diff --git a/composer.json b/composer.json index 753c43b..a078368 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "./src/Collections/Internal/functions.php", "./src/Collections/Internal/containers.php", "./src/Collections/Internal/queries.php", + "./src/Collections/Lazy/transforms.php", "./src/Collections/transforms.php", "./src/Collections/collectors.php", "./src/Collections/queries.php" diff --git a/src/Collections/Lazy/transforms.php b/src/Collections/Lazy/transforms.php new file mode 100644 index 0000000..cc575b7 --- /dev/null +++ b/src/Collections/Lazy/transforms.php @@ -0,0 +1,356 @@ + + * + * Full license available in separate LICENSE file + */ + +/** + * Lazy collection transforms. + * + * 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. + * 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. + * Callbacks receive `($value, $key)`. There are no sorting, grouping or keyed `merge` functions here, as those + * need the whole input. + */ + +namespace Kadet\Functional\Collections\Lazy; + +use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Collections\Internal\entries; +use function Kadet\Functional\Collections\Internal\seen; +use function Kadet\Functional\Collections\Internal\unpairs; + +/** + * Transforms each value with `$fn($value, $key)`, keeping the keys. + * + * @template TKey + * @template TValue + * @template TResult + * + * @param callable(TValue, TKey): TResult $fn + * + * @return \Closure(iterable): \Generator + */ +function map(callable $fn): \Closure +{ + $fn = callback($fn); + + return static function (iterable $input) use ($fn): \Generator { + foreach ($input as $key => $value) { + yield $key => $fn($value, $key); + } + }; +} + +/** + * Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $fn + * + * @return \Closure(iterable): \Generator + */ +function filter(?callable $fn = null): \Closure +{ + $fn = $fn === null ? static fn ($value) => $value : callback($fn); + + return static function (iterable $input) use ($fn): \Generator { + foreach ($input as $key => $value) { + if ($fn($value, $key)) { + yield $key => $value; + } + } + }; +} + +/** + * Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): \Generator + */ +function reject(callable $fn): \Closure +{ + $fn = callback($fn); + + return static function (iterable $input) use ($fn): \Generator { + foreach ($input as $key => $value) { + if (!$fn($value, $key)) { + yield $key => $value; + } + } + }; +} + +/** + * Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level, with sequential keys. + * + * @template TKey + * @template TValue + * @template TResult + * + * @param callable(TValue, TKey): iterable $fn + * + * @return \Closure(iterable): \Generator + */ +function flatMap(callable $fn): \Closure +{ + $fn = callback($fn); + + return static function (iterable $input) use ($fn): \Generator { + foreach ($input as $key => $value) { + $result = $fn($value, $key); + + if (!is_iterable($result)) { + throw new \UnexpectedValueException(sprintf( + 'flatMap(): the callback must return an iterable, got %s.', + get_debug_type($result), + )); + } + + foreach ($result as $item) { + yield $item; + } + } + }; +} + +/** + * Drops the keys, yielding the values with sequential keys. + * + * @return \Closure(iterable): \Generator + */ +function values(): \Closure +{ + return static function (iterable $input): \Generator { + foreach ($input as $value) { + yield $value; + } + }; +} + +/** + * Yields the keys as values, with sequential keys. + * + * @return \Closure(iterable): \Generator + */ +function keys(): \Closure +{ + return static function (iterable $input): \Generator { + foreach ($input as $key => $_) { + yield $key; + } + }; +} + +/** + * Removes duplicate values, keeping the first occurrence. Values are compared strictly, objects by identity. + * + * @return \Closure(iterable): \Generator + */ +function unique(): \Closure +{ + return uniqueBy(static fn ($value) => $value); +} + +/** + * Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence. + * Selected values are compared strictly, objects by identity. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $selector + * + * @return \Closure(iterable): \Generator + */ +function uniqueBy(callable $selector): \Closure +{ + $selector = callback($selector); + + return static function (iterable $input) use ($selector): \Generator { + $seen = seen(); + + foreach ($input as $key => $value) { + if (!$seen($selector($value, $key))) { + yield $key => $value; + } + } + }; +} + +/** + * Takes `$length` values (all remaining when null) starting at position `$offset`, like `array_slice()`. + * + * Negative `$offset` counts from the end, negative `$length` stops that many values before the end. With + * non-negative arguments the source is consumed only as far as needed; a negative argument needs the whole input. + * + * @return \Closure(iterable): \Generator + */ +function slice(int $offset, ?int $length = null): \Closure +{ + return static function (iterable $input) use ($offset, $length): \Generator { + if ($offset < 0 || ($length !== null && $length < 0)) { + yield from unpairs(array_slice(entries($input), $offset, $length)); + + return; + } + + if ($length === 0) { + return; + } + + $position = 0; + $taken = 0; + foreach ($input as $key => $value) { + if ($position++ < $offset) { + continue; + } + + yield $key => $value; + + if (++$taken === $length) { + return; + } + } + }; +} + +/** + * Takes the first `$n` values. See {@see slice()}. + * + * @return \Closure(iterable): \Generator + */ +function take(int $n): \Closure +{ + if ($n < 0) { + throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $n)); + } + + return slice(0, $n); +} + +/** + * Skips the first `$n` values. See {@see slice()}. + * + * @return \Closure(iterable): \Generator + */ +function skip(int $n): \Closure +{ + if ($n < 0) { + throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $n)); + } + + return slice($n); +} + +/** + * Puts `$values` before the values of the input, with sequential keys. + * + * @template TValue + * + * @param TValue ...$values + * + * @return \Closure(iterable): \Generator + */ +function prepend(mixed ...$values): \Closure +{ + return static function (iterable $input) use ($values): \Generator { + foreach ($values as $value) { + yield $value; + } + + foreach ($input as $value) { + yield $value; + } + }; +} + +/** + * Puts `$values` after the values of the input, with sequential keys. + * + * @template TValue + * + * @param TValue ...$values + * + * @return \Closure(iterable): \Generator + */ +function append(mixed ...$values): \Closure +{ + return concat($values); +} + +/** + * Puts the values of `$other` after the values of the input, with sequential keys. + * + * @template TValue + * + * @param iterable $other + * + * @return \Closure(iterable): \Generator + */ +function concat(iterable $other): \Closure +{ + return static function (iterable $input) use ($other): \Generator { + foreach ($input as $value) { + yield $value; + } + + foreach ($other as $value) { + yield $value; + } + }; +} + +/** + * Calls `$fn($value, $key)` for each value, for its side effects, and yields every element through unchanged. + * + * The return value of `$fn` is ignored. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): \Generator + */ +function each(callable $fn): \Closure +{ + $fn = callback($fn); + + return static function (iterable $input) use ($fn): \Generator { + foreach ($input as $key => $value) { + $fn($value, $key); + + yield $key => $value; + } + }; +} + +/** + * Alias of {@see each()}. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): \Generator + */ +function tap(callable $fn): \Closure +{ + return each($fn); +} diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index 52c94d5..216ca80 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -20,11 +20,11 @@ namespace Kadet\Functional\Collections; +use Kadet\Functional\Collections\Lazy as lazy; use function Kadet\Functional\Collections\Internal\callback; use function Kadet\Functional\Collections\Internal\entries; use function Kadet\Functional\Collections\Internal\keyedLike; use function Kadet\Functional\Collections\Internal\listLike; -use function Kadet\Functional\Collections\Internal\seen; use function Kadet\Functional\Collections\Internal\seqOf; use function Kadet\Functional\Collections\Internal\unpairs; use function Kadet\Functional\compile; @@ -44,15 +44,9 @@ use function Kadet\Functional\compile; */ function map(callable $fn): \Closure { - $fn = callback($fn); + $lazy = lazy\map($fn); - return static function (iterable $input) use ($fn): iterable { - return keyedLike($input, (static function () use ($input, $fn) { - foreach ($input as $key => $value) { - yield $key => $fn($value, $key); - } - })()); - }; + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -69,17 +63,9 @@ function map(callable $fn): \Closure */ function filter(?callable $fn = null): \Closure { - $fn = $fn === null ? static fn ($value) => $value : callback($fn); + $lazy = lazy\filter($fn); - return static function (iterable $input) use ($fn): iterable { - return keyedLike($input, (static function () use ($input, $fn) { - foreach ($input as $key => $value) { - if ($fn($value, $key)) { - yield $key => $value; - } - } - })()); - }; + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -94,17 +80,9 @@ function filter(?callable $fn = null): \Closure */ function reject(callable $fn): \Closure { - $fn = callback($fn); + $lazy = lazy\reject($fn); - return static function (iterable $input) use ($fn): iterable { - return keyedLike($input, (static function () use ($input, $fn) { - foreach ($input as $key => $value) { - if (!$fn($value, $key)) { - yield $key => $value; - } - } - })()); - }; + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -122,26 +100,9 @@ function reject(callable $fn): \Closure */ function flatMap(callable $fn): \Closure { - $fn = callback($fn); + $lazy = lazy\flatMap($fn); - return static function (iterable $input) use ($fn): iterable { - return listLike($input, (static function () use ($input, $fn) { - foreach ($input as $key => $value) { - $result = $fn($value, $key); - - if (!is_iterable($result)) { - throw new \UnexpectedValueException(sprintf( - 'flatMap(): the callback must return an iterable, got %s.', - get_debug_type($result), - )); - } - - foreach ($result as $item) { - yield $item; - } - } - })()); - }; + return static fn (iterable $input): iterable => listLike($input, $lazy($input)); } /** @@ -151,7 +112,9 @@ function flatMap(callable $fn): \Closure */ function values(): \Closure { - return static fn (iterable $input): iterable => listLike($input, $input); + $lazy = lazy\values(); + + return static fn (iterable $input): iterable => listLike($input, $lazy($input)); } /** @@ -161,17 +124,12 @@ function values(): \Closure */ function keys(): \Closure { - return static function (iterable $input): iterable { - if (is_array($input)) { - return array_keys($input); - } + $lazy = lazy\keys(); - $keys = []; - foreach ($input as $key => $_) { - $keys[] = $key; - } + return static function (iterable $input) use ($lazy): iterable { + $keys = iterator_to_array($lazy($input), false); - return seqOf($keys); + return is_array($input) ? $keys : seqOf($keys); }; } @@ -198,19 +156,9 @@ function unique(): \Closure */ function uniqueBy(callable $selector): \Closure { - $selector = callback($selector); + $lazy = lazy\uniqueBy($selector); - return static function (iterable $input) use ($selector): iterable { - $seen = seen(); - - return keyedLike($input, (static function () use ($input, $selector, $seen) { - foreach ($input as $key => $value) { - if (!$seen($selector($value, $key))) { - yield $key => $value; - } - } - })()); - }; + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -277,31 +225,9 @@ function sortBy(callable $selector, bool $descending = false): \Closure */ function slice(int $offset, ?int $length = null): \Closure { - return static function (iterable $input) use ($offset, $length): iterable { - if ($offset < 0 || ($length !== null && $length < 0)) { - return keyedLike($input, unpairs(array_slice(entries($input), $offset, $length))); - } + $lazy = lazy\slice($offset, $length); - return keyedLike($input, (static function () use ($input, $offset, $length) { - if ($length === 0) { - return; - } - - $position = 0; - $taken = 0; - foreach ($input as $key => $value) { - if ($position++ < $offset) { - continue; - } - - yield $key => $value; - - if (++$taken === $length) { - return; - } - } - })()); - }; + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -311,11 +237,9 @@ function slice(int $offset, ?int $length = null): \Closure */ function take(int $n): \Closure { - if ($n < 0) { - throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $n)); - } + $lazy = lazy\take($n); - return slice(0, $n); + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -325,11 +249,9 @@ function take(int $n): \Closure */ function skip(int $n): \Closure { - if ($n < 0) { - throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $n)); - } + $lazy = lazy\skip($n); - return slice($n); + return static fn (iterable $input): iterable => keyedLike($input, $lazy($input)); } /** @@ -343,10 +265,9 @@ function skip(int $n): \Closure */ function prepend(mixed ...$values): \Closure { - return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $values) { - yield from $values; - yield from $input; - })()); + $lazy = lazy\prepend(...$values); + + return static fn (iterable $input): iterable => listLike($input, $lazy($input)); } /** @@ -374,10 +295,9 @@ function append(mixed ...$values): \Closure */ function concat(iterable $other): \Closure { - return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $other) { - yield from $input; - yield from $other; - })()); + $lazy = lazy\concat($other); + + return static fn (iterable $input): iterable => listLike($input, $lazy($input)); } /** @@ -415,24 +335,18 @@ function merge(iterable $other): \Closure */ function each(callable $fn): \Closure { - $fn = callback($fn); + $lazy = lazy\each($fn); - return static function (iterable $input) use ($fn): iterable { + return static function (iterable $input) use ($lazy): iterable { $reusable = is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set; if ($reusable) { - foreach ($input as $key => $value) { - $fn($value, $key); + foreach ($lazy($input) as $_) { } return $input; } - return listLike($input, (static function () use ($input, $fn) { - foreach ($input as $key => $value) { - $fn($value, $key); - yield $value; - } - })()); + return listLike($input, $lazy($input)); }; } diff --git a/tests/Collections/LazyTest.php b/tests/Collections/LazyTest.php new file mode 100644 index 0000000..3897030 --- /dev/null +++ b/tests/Collections/LazyTest.php @@ -0,0 +1,193 @@ + + * + * Full license available in separate LICENSE file + */ + +use Kadet\Functional\Collections as c; +use Kadet\Functional\Collections\Lazy as lazy; + +class LazyTest extends \PHPUnit\Framework\TestCase +{ + private static function gen(iterable $entries): \Generator + { + foreach ($entries as $key => $value) { + yield $key => $value; + } + } + + private static function naturals(): \Generator + { + for ($i = 1; ; $i++) { + yield $i; + } + } + + public function testLazyDoesNothingUntilConsumed() + { + $calls = 0; + $spy = static function ($value) use (&$calls) { + $calls++; + + return $value; + }; + + $steps = [ + lazy\map($spy), lazy\filter($spy), lazy\reject($spy), lazy\uniqueBy($spy), lazy\each($spy), lazy\tap($spy), + lazy\flatMap(static function ($value) use ($spy) { $spy($value); return [$value]; }), + ]; + + foreach ($steps as $step) { + $result = $step(self::gen(['a' => 1, 'b' => 2])); + $this->assertInstanceOf(\Generator::class, $result); + } + + $this->assertSame(0, $calls); + + iterator_to_array(lazy\map($spy)(self::gen(['a' => 1, 'b' => 2]))); + $this->assertSame(2, $calls); + } + + public function testLazyChainSingleWalk() + { + $walked = 0; + + $result = c\toArray()(lazy\take(3)(lazy\filter(static fn ($x) => $x % 2 === 0)(lazy\map(static function ($x) use (&$walked) { + $walked++; + + return $x * 3; + })(self::naturals())))); + + $this->assertSame([1 => 6, 3 => 12, 5 => 18], $result); + $this->assertSame(6, $walked); + } + + public function testLazyChainWorksWithPipe() + { + $result = self::naturals() + |> lazy\map(static fn ($x) => $x * 2) + |> lazy\skip(1) + |> lazy\take(2) + |> c\toArray(); + + $this->assertSame([1 => 4, 2 => 6], $result); + } + + public static function provideEagerEquivalents(): iterable + { + $double = static fn ($x) => $x * 2; + $isTwo = static fn ($x) => $x === 2; + $inner = static fn ($x, $k) => [$x, $k]; + + yield 'map' => [fn () => lazy\map($double), fn () => c\map($double)]; + yield 'filter' => [fn () => lazy\filter($isTwo), fn () => c\filter($isTwo)]; + yield 'filter null' => [fn () => lazy\filter(), fn () => c\filter()]; + yield 'reject' => [fn () => lazy\reject($isTwo), fn () => c\reject($isTwo)]; + yield 'flatMap' => [fn () => lazy\flatMap($inner), fn () => c\flatMap($inner)]; + yield 'values' => [fn () => lazy\values(), fn () => c\values()]; + yield 'unique' => [fn () => lazy\unique(), fn () => c\unique()]; + yield 'uniqueBy' => [fn () => lazy\uniqueBy($double), fn () => c\uniqueBy($double)]; + yield 'slice' => [fn () => lazy\slice(1, 1), fn () => c\slice(1, 1)]; + yield 'slice open' => [fn () => lazy\slice(1), fn () => c\slice(1)]; + yield 'slice neg' => [fn () => lazy\slice(-2, 1), fn () => c\slice(-2, 1)]; + yield 'slice negend' => [fn () => lazy\slice(0, -1), fn () => c\slice(0, -1)]; + yield 'take' => [fn () => lazy\take(2), fn () => c\take(2)]; + yield 'skip' => [fn () => lazy\skip(2), fn () => c\skip(2)]; + yield 'prepend' => [fn () => lazy\prepend(7, 8), fn () => c\prepend(7, 8)]; + yield 'append' => [fn () => lazy\append(7, 8), fn () => c\append(7, 8)]; + yield 'concat' => [fn () => lazy\concat(['x' => 9]), fn () => c\concat(['x' => 9])]; + yield 'each' => [fn () => lazy\each($double), fn () => c\each($double)]; + yield 'tap' => [fn () => lazy\tap($double), fn () => c\tap($double)]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('provideEagerEquivalents')] + public function testLazyMatchesEagerResults(\Closure $lazy, \Closure $eager) + { + $fixture = ['a' => 1, 'b' => 2, 'c' => 2]; + + $this->assertSame($eager()($fixture), iterator_to_array($lazy()($fixture))); + } + + public function testKeysMatchesEager() + { + $fixture = ['a' => 1, 'b' => 2]; + + $this->assertSame(['a', 'b'], iterator_to_array(lazy\keys()($fixture))); + $this->assertSame([0, 1], array_keys(iterator_to_array(lazy\keys()($fixture)))); + } + + public function testLazyUniqueStreams() + { + $walked = 0; + $source = (static function () use (&$walked) { + foreach ([1, 1, 2, 2, 3, 4] as $value) { + $walked++; + yield $value; + } + })(); + + $generator = lazy\unique()($source); + $this->assertSame(0, $walked); + $this->assertSame(1, $generator->current()); + $this->assertSame(1, $walked); + + $generator->next(); + $this->assertSame(2, $generator->current()); + $this->assertSame(3, $walked); + } + + public function testUniqueCanBeReusedAcrossRuns() + { + $step = lazy\unique(); + + $this->assertSame([1, 2], array_values(iterator_to_array($step([1, 1, 2])))); + $this->assertSame([1, 2], array_values(iterator_to_array($step([1, 2, 2])))); + } + + public function testFlatMapRejectsNonIterable() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('flatMap(): the callback must return an iterable, got int.'); + + iterator_to_array(lazy\flatMap(static fn ($x) => $x)([1])); + } + + public function testTakeAndSkipRejectNegativeWhenBuilt() + { + try { + lazy\take(-1); + $this->fail('take(-1) should throw'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('take(): count must be >= 0, got -1', $e->getMessage()); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('skip(): count must be >= 0, got -1'); + lazy\skip(-1); + } + + public function testTakeStopsPullingTheSource() + { + $this->assertSame([1, 2], array_values(iterator_to_array(lazy\take(2)(self::naturals())))); + } + + public function testEachYieldsEverythingThroughWithKeys() + { + $seen = []; + $out = iterator_to_array(lazy\each(static function ($v, $k) use (&$seen) { $seen[] = [$k, $v]; })(self::gen(['a' => 1, 'b' => 2]))); + + $this->assertSame(['a' => 1, 'b' => 2], $out); + $this->assertSame([['a', 1], ['b', 2]], $seen); + } + + public function testCallbackIsResolvedOncePerStep() + { + $step = lazy\map(static fn ($x) => $x + 1); + + $this->assertSame([2, 3], iterator_to_array($step([1, 2]))); + $this->assertSame([4], iterator_to_array($step([3]))); + } +}