add lazy collection transforms

This commit is contained in:
Kacper Donat 2026-10-27 22:24:58 +01:00
parent b836bc5e8f
commit 364b97deaa
4 changed files with 584 additions and 120 deletions

View File

@ -27,6 +27,7 @@
"./src/Collections/Internal/functions.php", "./src/Collections/Internal/functions.php",
"./src/Collections/Internal/containers.php", "./src/Collections/Internal/containers.php",
"./src/Collections/Internal/queries.php", "./src/Collections/Internal/queries.php",
"./src/Collections/Lazy/transforms.php",
"./src/Collections/transforms.php", "./src/Collections/transforms.php",
"./src/Collections/collectors.php", "./src/Collections/collectors.php",
"./src/Collections/queries.php" "./src/Collections/queries.php"

View File

@ -0,0 +1,356 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* 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<TKey, TValue>): \Generator<TKey, TResult>
*/
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<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>): \Generator<TKey, TValue>
*/
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<mixed, TResult> $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<int, TResult>
*/
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<TValue>(iterable<mixed, TValue>): \Generator<int, TValue>
*/
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<TKey>(iterable<TKey, mixed>): \Generator<int, TKey>
*/
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<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
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<mixed, TValue>): \Generator<int, TValue>
*/
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<mixed, TValue>): \Generator<int, TValue>
*/
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<mixed, TValue> $other
*
* @return \Closure(iterable<mixed, TValue>): \Generator<int, TValue>
*/
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<TKey, TValue>): \Generator<TKey, TValue>
*/
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<TKey, TValue>): \Generator<TKey, TValue>
*/
function tap(callable $fn): \Closure
{
return each($fn);
}

View File

@ -20,11 +20,11 @@
namespace Kadet\Functional\Collections; namespace Kadet\Functional\Collections;
use Kadet\Functional\Collections\Lazy as lazy;
use function Kadet\Functional\Collections\Internal\callback; use function Kadet\Functional\Collections\Internal\callback;
use function Kadet\Functional\Collections\Internal\entries; use function Kadet\Functional\Collections\Internal\entries;
use function Kadet\Functional\Collections\Internal\keyedLike; use function Kadet\Functional\Collections\Internal\keyedLike;
use function Kadet\Functional\Collections\Internal\listLike; 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\seqOf;
use function Kadet\Functional\Collections\Internal\unpairs; use function Kadet\Functional\Collections\Internal\unpairs;
use function Kadet\Functional\compile; use function Kadet\Functional\compile;
@ -44,15 +44,9 @@ use function Kadet\Functional\compile;
*/ */
function map(callable $fn): \Closure function map(callable $fn): \Closure
{ {
$fn = callback($fn); $lazy = lazy\map($fn);
return static function (iterable $input) use ($fn): iterable { return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return keyedLike($input, (static function () use ($input, $fn) {
foreach ($input as $key => $value) {
yield $key => $fn($value, $key);
}
})());
};
} }
/** /**
@ -69,17 +63,9 @@ function map(callable $fn): \Closure
*/ */
function filter(?callable $fn = null): \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 static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return keyedLike($input, (static function () use ($input, $fn) {
foreach ($input as $key => $value) {
if ($fn($value, $key)) {
yield $key => $value;
}
}
})());
};
} }
/** /**
@ -94,17 +80,9 @@ function filter(?callable $fn = null): \Closure
*/ */
function reject(callable $fn): \Closure function reject(callable $fn): \Closure
{ {
$fn = callback($fn); $lazy = lazy\reject($fn);
return static function (iterable $input) use ($fn): iterable { return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
return keyedLike($input, (static function () use ($input, $fn) {
foreach ($input as $key => $value) {
if (!$fn($value, $key)) {
yield $key => $value;
}
}
})());
};
} }
/** /**
@ -122,26 +100,9 @@ function reject(callable $fn): \Closure
*/ */
function flatMap(callable $fn): \Closure function flatMap(callable $fn): \Closure
{ {
$fn = callback($fn); $lazy = lazy\flatMap($fn);
return static function (iterable $input) use ($fn): iterable { return static fn (iterable $input): iterable => listLike($input, $lazy($input));
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;
}
}
})());
};
} }
/** /**
@ -151,7 +112,9 @@ function flatMap(callable $fn): \Closure
*/ */
function values(): \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 function keys(): \Closure
{ {
return static function (iterable $input): iterable { $lazy = lazy\keys();
if (is_array($input)) {
return array_keys($input);
}
$keys = []; return static function (iterable $input) use ($lazy): iterable {
foreach ($input as $key => $_) { $keys = iterator_to_array($lazy($input), false);
$keys[] = $key;
}
return seqOf($keys); return is_array($input) ? $keys : seqOf($keys);
}; };
} }
@ -198,19 +156,9 @@ function unique(): \Closure
*/ */
function uniqueBy(callable $selector): \Closure function uniqueBy(callable $selector): \Closure
{ {
$selector = callback($selector); $lazy = lazy\uniqueBy($selector);
return static function (iterable $input) use ($selector): iterable { return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
$seen = seen();
return keyedLike($input, (static function () use ($input, $selector, $seen) {
foreach ($input as $key => $value) {
if (!$seen($selector($value, $key))) {
yield $key => $value;
}
}
})());
};
} }
/** /**
@ -277,31 +225,9 @@ function sortBy(callable $selector, bool $descending = false): \Closure
*/ */
function slice(int $offset, ?int $length = null): \Closure function slice(int $offset, ?int $length = null): \Closure
{ {
return static function (iterable $input) use ($offset, $length): iterable { $lazy = lazy\slice($offset, $length);
if ($offset < 0 || ($length !== null && $length < 0)) {
return keyedLike($input, unpairs(array_slice(entries($input), $offset, $length)));
}
return keyedLike($input, (static function () use ($input, $offset, $length) { return static fn (iterable $input): iterable => keyedLike($input, $lazy($input));
if ($length === 0) {
return;
}
$position = 0;
$taken = 0;
foreach ($input as $key => $value) {
if ($position++ < $offset) {
continue;
}
yield $key => $value;
if (++$taken === $length) {
return;
}
}
})());
};
} }
/** /**
@ -311,11 +237,9 @@ function slice(int $offset, ?int $length = null): \Closure
*/ */
function take(int $n): \Closure function take(int $n): \Closure
{ {
if ($n < 0) { $lazy = lazy\take($n);
throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $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 function skip(int $n): \Closure
{ {
if ($n < 0) { $lazy = lazy\skip($n);
throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $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 function prepend(mixed ...$values): \Closure
{ {
return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $values) { $lazy = lazy\prepend(...$values);
yield from $values;
yield from $input; return static fn (iterable $input): iterable => listLike($input, $lazy($input));
})());
} }
/** /**
@ -374,10 +295,9 @@ function append(mixed ...$values): \Closure
*/ */
function concat(iterable $other): \Closure function concat(iterable $other): \Closure
{ {
return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $other) { $lazy = lazy\concat($other);
yield from $input;
yield from $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 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; $reusable = is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set;
if ($reusable) { if ($reusable) {
foreach ($input as $key => $value) { foreach ($lazy($input) as $_) {
$fn($value, $key);
} }
return $input; return $input;
} }
return listLike($input, (static function () use ($input, $fn) { return listLike($input, $lazy($input));
foreach ($input as $key => $value) {
$fn($value, $key);
yield $value;
}
})());
}; };
} }

View File

@ -0,0 +1,193 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* 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])));
}
}