From 483375240b7b398606e247edb99845e5bbba4756 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 22:21:50 +0100 Subject: [PATCH] add collection queries --- composer.json | 3 +- src/Collections/queries.php | 425 +++++++++++++++++++++ src/Exception/EmptyCollectionException.php | 18 + tests/Collections/QueriesTest.php | 225 +++++++++++ 4 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 src/Collections/queries.php create mode 100644 src/Exception/EmptyCollectionException.php create mode 100644 tests/Collections/QueriesTest.php diff --git a/composer.json b/composer.json index 1906b89..2ad997c 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "./src/Collections/Internal/functions.php", "./src/Collections/Internal/containers.php", "./src/Collections/transforms.php", - "./src/Collections/collectors.php" + "./src/Collections/collectors.php", + "./src/Collections/queries.php" ] }, "suggest": { diff --git a/src/Collections/queries.php b/src/Collections/queries.php new file mode 100644 index 0000000..5a100fc --- /dev/null +++ b/src/Collections/queries.php @@ -0,0 +1,425 @@ + + * + * Full license available in separate LICENSE file + */ + +/** + * Collection queries. + * + * Every function returns a closure that takes any iterable (generators included) and gives a single result, so they + * end a pipeline, eager or lazy. They stop reading as soon as the answer is known. + * + * Callbacks receive `($value, $key)`, also for `Ds\Map`. Inside this namespace `count`, `min`, `max` and `join` + * shadow the PHP builtins, so library code uses `\count()` and friends. + */ + +namespace Kadet\Functional\Collections; + +use Kadet\Functional\Exception\EmptyCollectionException; + +use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\compile; + +/** + * Yields [key, value] pairs of $data from the end, without copying it where the container allows. + * + * @internal + * + * @param iterable $data + * + * @return \Generator + */ +function reversedEntries(iterable $data): \Generator +{ + if (is_array($data)) { + for (end($data); ($key = key($data)) !== null; prev($data)) { + yield [$key, current($data)]; + } + } elseif ($data instanceof \Ds\Seq || $data instanceof \Ds\Set) { + for ($index = \count($data) - 1; $index >= 0; $index--) { + yield [$index, $data->get($index)]; + } + } elseif ($data instanceof \Ds\Map) { + foreach ($data->pairs()->reversed() as $pair) { + yield [$pair->key, $pair->value]; + } + } else { + $entries = []; + foreach ($data as $key => $value) { + $entries[] = [$key, $value]; + } + + yield from array_reverse($entries); + } +} + +/** + * Returns the first value for which `$predicate($value, $key)` is truthy, or the first value without a predicate. + * + * Returns null when nothing matches; with `$required` it throws instead, so a found `null` stays distinguishable. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $predicate + * + * @return \Closure(iterable): ?TValue + * + * @throws EmptyCollectionException when `$required` is set and nothing matched + */ +function first(?callable $predicate = null, bool $required = false): \Closure +{ + return firstOf('first', $predicate, $required); +} + +/** + * Returns the last value for which `$predicate($value, $key)` is truthy, or the last value without a predicate. + * + * Arrays and php-ds containers are read from the end, other iterables are read to the end. Returns null when + * nothing matches; with `$required` it throws instead. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $predicate + * + * @return \Closure(iterable): ?TValue + * + * @throws EmptyCollectionException when `$required` is set and nothing matched + */ +function last(?callable $predicate = null, bool $required = false): \Closure +{ + $predicate = $predicate === null ? null : callback($predicate); + + return static function (iterable $data) use ($predicate, $required) { + $reversible = is_array($data) || $data instanceof \Ds\Seq || $data instanceof \Ds\Set + || $data instanceof \Ds\Map; + + if ($reversible) { + foreach (reversedEntries($data) as [$key, $value]) { + if ($predicate === null || $predicate($value, $key)) { + return $value; + } + } + } else { + $found = false; + $result = null; + foreach ($data as $key => $value) { + if ($predicate === null || $predicate($value, $key)) { + $found = true; + $result = $value; + } + } + + if ($found) { + return $result; + } + } + + if ($required) { + throw EmptyCollectionException::noMatch('last', $predicate !== null); + } + + return null; + }; +} + +/** + * Alias of {@see first()} with a mandatory predicate: returns the first value matching `$predicate($value, $key)`. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $predicate + * + * @return \Closure(iterable): ?TValue + * + * @throws EmptyCollectionException when `$required` is set and nothing matched + */ +function find(callable $predicate, bool $required = false): \Closure +{ + return firstOf('find', $predicate, $required); +} + +/** + * Shared implementation of {@see first()} and {@see find()}, so that the exception names the called function. + * + * @internal + * + * @return \Closure(iterable): mixed + */ +function firstOf(string $name, ?callable $predicate, bool $required): \Closure +{ + $predicate = $predicate === null ? null : callback($predicate); + + return static function (iterable $data) use ($name, $predicate, $required) { + foreach ($data as $key => $value) { + if ($predicate === null || $predicate($value, $key)) { + return $value; + } + } + + if ($required) { + throw EmptyCollectionException::noMatch($name, $predicate !== null); + } + + return null; + }; +} + +/** + * Whether the collection holds `$value`, compared strictly (`Ds\Key::equals` when `$value` implements `Ds\Key`). + * + * @template TKey + * @template TValue + * + * @return \Closure(iterable): bool + */ +function contains(mixed $value): \Closure +{ + $isKey = interface_exists(\Ds\Key::class) && $value instanceof \Ds\Key; + + return static function (iterable $data) use ($value, $isKey): bool { + foreach ($data as $item) { + if ($isKey ? $item instanceof \Ds\Key && $value->equals($item) : $item === $value) { + return true; + } + } + + return false; + }; +} + +/** + * Whether any value satisfies `$predicate($value, $key)`. False for an empty collection. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $predicate + * + * @return \Closure(iterable): bool + */ +function any(callable $predicate): \Closure +{ + $predicate = callback($predicate); + + return static function (iterable $data) use ($predicate): bool { + foreach ($data as $key => $value) { + if ($predicate($value, $key)) { + return true; + } + } + + return false; + }; +} + +/** + * Alias of {@see any()}. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $predicate + * + * @return \Closure(iterable): bool + */ +function some(callable $predicate): \Closure +{ + return any($predicate); +} + +/** + * Whether every value satisfies `$predicate($value, $key)`. True for an empty collection. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $predicate + * + * @return \Closure(iterable): bool + */ +function every(callable $predicate): \Closure +{ + $predicate = callback($predicate); + + return static function (iterable $data) use ($predicate): bool { + foreach ($data as $key => $value) { + if (!$predicate($value, $key)) { + return false; + } + } + + return true; + }; +} + +/** + * Counts the values. Containers that know their size are not iterated. + * + * @return \Closure(iterable): int + */ +function count(): \Closure +{ + return static function (iterable $data): int { + if (is_array($data) || $data instanceof \Countable) { + return \count($data); + } + + $count = 0; + foreach ($data as $_) { + $count++; + } + + return $count; + }; +} + +/** + * Whether the collection has no values. Reads at most one value. + * + * @return \Closure(iterable): bool + */ +function isEmpty(): \Closure +{ + return static function (iterable $data): bool { + if (is_array($data) || $data instanceof \Countable) { + return \count($data) === 0; + } + + foreach ($data as $_) { + return false; + } + + return true; + }; +} + +/** + * The smallest value (compared with `<=>`), or the smallest of `$selector($value, $key)` when given. Null when empty. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $selector + * + * @return \Closure(iterable): mixed + */ +function min(?callable $selector = null): \Closure +{ + return extremum(-1, $selector); +} + +/** + * The largest value (compared with `<=>`), or the largest of `$selector($value, $key)` when given. Null when empty. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $selector + * + * @return \Closure(iterable): mixed + */ +function max(?callable $selector = null): \Closure +{ + return extremum(1, $selector); +} + +/** + * Shared implementation of {@see min()} (`$direction` -1) and {@see max()} (1). + * + * @internal + * + * @return \Closure(iterable): mixed + */ +function extremum(int $direction, ?callable $selector): \Closure +{ + $selector = $selector === null ? static fn ($value) => $value : callback($selector); + + return static function (iterable $data) use ($direction, $selector) { + $best = null; + $first = true; + foreach ($data as $key => $value) { + $value = $selector($value, $key); + if ($first || ($value <=> $best) === $direction) { + $best = $value; + $first = false; + } + } + + return $best; + }; +} + +/** + * Folds the collection into a single value with `$fn($carry, $value, $key)`, starting from `$initial`. + * + * @template TKey + * @template TValue + * @template TCarry + * + * @param callable(TCarry, TValue, TKey): TCarry $fn + * @param TCarry $initial + * + * @return \Closure(iterable): TCarry + */ +function reduce(callable $fn, mixed $initial = null): \Closure +{ + // The reducer takes the carry first, so it is compiled without the ($value, $key) adaptation. + $fn = compile($fn); + + return static function (iterable $data) use ($fn, $initial) { + $carry = $initial; + foreach ($data as $key => $value) { + $carry = $fn($carry, $value, $key); + } + + return $carry; + }; +} + +/** + * Sums the values, or `$selector($value, $key)` when given. An empty collection sums to 0. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): int|float)|null $selector + * + * @return \Closure(iterable): (int|float) + */ +function sum(?callable $selector = null): \Closure +{ + $selector = $selector === null ? static fn ($value) => $value : callback($selector); + + return static function (iterable $data) use ($selector): int|float { + $sum = 0; + foreach ($data as $key => $value) { + $sum += $selector($value, $key); + } + + return $sum; + }; +} + +/** + * Joins the values into a string with `$glue`. + * + * @return \Closure(iterable): string + */ +function join(string $glue = ''): \Closure +{ + return static function (iterable $data) use ($glue): string { + $parts = []; + foreach ($data as $value) { + $parts[] = (string) $value; + } + + return implode($glue, $parts); + }; +} diff --git a/src/Exception/EmptyCollectionException.php b/src/Exception/EmptyCollectionException.php new file mode 100644 index 0000000..217e1f3 --- /dev/null +++ b/src/Exception/EmptyCollectionException.php @@ -0,0 +1,18 @@ + + * + * Full license available in separate LICENSE file + */ + +namespace Kadet\Functional\Exception; + +class EmptyCollectionException extends FunctionalException +{ + public static function noMatch(string $function, bool $filtered): self + { + return new self(sprintf('%s(): %s', $function, $filtered ? 'no element matched' : 'collection is empty')); + } +} diff --git a/tests/Collections/QueriesTest.php b/tests/Collections/QueriesTest.php new file mode 100644 index 0000000..88b24eb --- /dev/null +++ b/tests/Collections/QueriesTest.php @@ -0,0 +1,225 @@ + + * + * Full license available in separate LICENSE file + */ + +use Ds\Map; +use Ds\Seq; +use Kadet\Functional\Collections as c; +use Kadet\Functional\Exception\EmptyCollectionException; + +class QueriesTest extends \PHPUnit\Framework\TestCase +{ + public function testFirstReturnsNullOnEmpty() + { + $this->assertNull(c\first()([])); + $this->assertNull(c\first(fn ($v) => $v > 5)([1, 2])); + } + + public function testFirstRequiredThrowsWithMessage() + { + $this->expectException(EmptyCollectionException::class); + $this->expectExceptionMessage('first(): no element matched'); + c\first(fn ($v) => $v > 5, required: true)([1, 2]); + } + + public function testFirstRequiredWithoutPredicateSaysEmpty() + { + $this->expectException(EmptyCollectionException::class); + $this->expectExceptionMessage('first(): collection is empty'); + c\first(required: true)([]); + } + + public function testFirstRequiredAcceptsNullMatch() + { + $this->assertNull(c\first(fn ($v) => $v === null, required: true)([1, null])); + } + + public function testFirstFalsyValueFound() + { + $this->assertSame(0, c\first()([0])); + } + + public function testFirstStopsEarly() + { + $gen = (static function () { + yield 1; + throw new \LogicException('consumed too far'); + })(); + + $this->assertSame(1, c\first()($gen)); + } + + public function testFirstPassesKeyToPredicate() + { + $this->assertSame(2, c\first(fn ($v, $k) => $k === 'b')(['a' => 1, 'b' => 2])); + $this->assertSame(2, c\first(fn ($v, $k) => $k === 'b')(new Map(['a' => 1, 'b' => 2]))); + } + + public function testFindIsFirstWithPredicate() + { + $this->assertSame(2, c\find(fn ($v) => $v > 1)([1, 2, 3])); + + $this->expectException(EmptyCollectionException::class); + $this->expectExceptionMessage('find(): no element matched'); + c\find(fn ($v) => $v > 5, required: true)([1, 2, 3]); + } + + public function testLastOnArrayAndPredicate() + { + $this->assertSame(3, c\last()([1, 2, 3])); + $this->assertSame(2, c\last(fn ($v) => $v < 3)([1, 2, 3])); + $this->assertNull(c\last()([])); + } + + public function testLastOnGenerator() + { + $gen = (static function () { + yield 'a' => 1; + yield 'b' => 2; + })(); + + $this->assertSame(2, c\last()($gen)); + } + + public function testLastOnSeqAndSet() + { + $this->assertSame(3, c\last()(new Seq([1, 2, 3]))); + $this->assertSame(2, c\last(fn ($v, $k) => $k < 2)(new Seq([1, 2, 3]))); + $this->assertSame(3, c\last()(new \Ds\Set([1, 2, 3]))); + $this->assertNull(c\last()(new Seq())); + } + + public function testLastOnMapReturnsValue() + { + $this->assertSame('y', c\last()(new Map(['a' => 'x', 'b' => 'y']))); + $this->assertSame('x', c\last(fn ($v, $k) => $k === 'a')(new Map(['a' => 'x', 'b' => 'y']))); + } + + public function testLastRequired() + { + $this->assertNull(c\last(fn ($v) => $v === null, required: true)([null, 1])); + + $this->expectException(EmptyCollectionException::class); + $this->expectExceptionMessage('last(): collection is empty'); + c\last(required: true)([]); + } + + public function testLastRequiredNoMatchMessage() + { + $this->expectException(EmptyCollectionException::class); + $this->expectExceptionMessage('last(): no element matched'); + c\last(fn ($v) => false, required: true)([1]); + } + + public function testContainsIsStrict() + { + $this->assertFalse(c\contains('1')([1])); + $this->assertTrue(c\contains(1)([3, 1])); + $this->assertTrue(c\contains(null)([1, null])); + } + + public function testContainsUsesDsKey() + { + $key = new class (1) implements \Ds\Key { + public function __construct(public int $id) {} + + public function equals(mixed $obj): bool + { + return $obj->id === $this->id; + } + + public function hash(): mixed + { + return $this->id; + } + }; + + $this->assertTrue(c\contains($key)([new $key(1)])); + $this->assertFalse(c\contains($key)([new $key(2)])); + } + + public function testAnySomeEvery() + { + $this->assertTrue(c\any(fn ($v) => $v > 2)([1, 3])); + $this->assertFalse(c\any(fn ($v) => $v > 5)([1, 3])); + $this->assertFalse(c\any(fn ($v) => true)([])); + $this->assertTrue(c\some(fn ($v) => $v > 2)([1, 3])); + $this->assertTrue(c\every(fn ($v) => $v > 0)([1, 3])); + $this->assertFalse(c\every(fn ($v) => $v > 1)([1, 3])); + } + + public function testEveryOnEmptyIsTrue() + { + $this->assertTrue(c\every(fn ($v) => false)([])); + } + + public function testAnyStopsEarly() + { + $gen = (static function () { + yield 1; + throw new \LogicException('consumed too far'); + })(); + + $this->assertTrue(c\any(fn ($v) => $v === 1)($gen)); + } + + public function testCountAndIsEmpty() + { + $gen = (static function () { + yield 1; + yield 2; + })(); + + $this->assertSame(2, c\count()($gen)); + $this->assertSame(3, c\count()(new Seq([1, 2, 3]))); + $this->assertSame(0, c\count()([])); + $this->assertTrue(c\isEmpty()([])); + $this->assertFalse(c\isEmpty()(new Seq([0]))); + $this->assertTrue(c\isEmpty()(new Map())); + } + + public function testMinMax() + { + $this->assertSame(1, c\min()([3, 1, 2])); + $this->assertSame(3, c\max()([3, 1, 2])); + $this->assertNull(c\min()([])); + $this->assertNull(c\max()([])); + } + + public function testMinWithSelector() + { + $this->assertSame(1, c\min(fn ($v) => strlen($v))(['ccc', 'a', 'bb'])); + $this->assertSame(3, c\max(fn ($v) => strlen($v))(['ccc', 'a', 'bb'])); + } + + public function testReduce() + { + $this->assertSame(6, c\reduce(fn ($carry, $v) => $carry + $v, 0)([1, 2, 3])); + $this->assertSame('a:1,b:2,', c\reduce(fn ($carry, $v, $k) => "$carry$k:$v,", '')(['a' => 1, 'b' => 2])); + $this->assertNull(c\reduce(fn ($carry, $v) => $v)([])); + } + + public function testSum() + { + $this->assertSame(6, c\sum()([1, 2, 3])); + $this->assertSame(3.5, c\sum()([1, 2.5])); + $this->assertSame(4, c\sum(fn ($v) => strlen($v))(['ab', 'cd'])); + } + + public function testSumEmptyIsZero() + { + $this->assertSame(0, c\sum()([])); + } + + public function testJoin() + { + $this->assertSame('a, b', c\join(', ')(['a', 'b'])); + $this->assertSame('12', c\join()(new Seq([1, 2]))); + $this->assertSame('', c\join(',')([])); + } +}