add collectors and grouping

This commit is contained in:
Kacper Donat 2026-10-27 22:19:33 +01:00
parent 04c40087c2
commit 1cddbe796a
8 changed files with 386 additions and 1 deletions

View File

@ -26,10 +26,17 @@
"./src/Predicates/index.php", "./src/Predicates/index.php",
"./src/Collections/Internal/functions.php", "./src/Collections/Internal/functions.php",
"./src/Collections/Internal/containers.php", "./src/Collections/Internal/containers.php",
"./src/Collections/transforms.php" "./src/Collections/transforms.php",
"./src/Collections/collectors.php"
] ]
}, },
"suggest": { "suggest": {
"php-ds/php-ds": "^2.0 (or ext-ds) for Seq/Map/Set results" "php-ds/php-ds": "^2.0 (or ext-ds) for Seq/Map/Set results"
},
"scripts": {
"test": [
"phpunit",
"phpunit -c phpunit.no-ds.xml"
]
} }
} }

8
phpunit.no-ds.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap-no-ds.php" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="no-ds">
<directory>tests/NoDs</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -3,6 +3,7 @@
<testsuites> <testsuites>
<testsuite name="all"> <testsuite name="all">
<directory>tests</directory> <directory>tests</directory>
<exclude>tests/NoDs</exclude>
</testsuite> </testsuite>
</testsuites> </testsuites>
<source> <source>

View File

@ -0,0 +1,149 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* Collectors: turn any iterable into a concrete collection.
*
* Everything except {@see toArray()} produces php-ds containers and throws {@see DsUnavailableException} when
* php-ds is not available. The exception is thrown when the collector is built, not when it is applied.
*/
namespace Kadet\Functional\Collections;
use Kadet\Functional\Exception\DsUnavailableException;
use function Kadet\Functional\Collections\Internal\callback;
use function Kadet\Functional\Collections\Internal\dsAvailable;
/**
* Indexes the values by `$selector($value, $key)` into a `Ds\Map`. The last value wins on a duplicate key.
*
* @template TKey
* @template TValue
* @template TIndex
*
* @param callable(TValue, TKey): TIndex $selector
*
* @return \Closure(iterable<TKey, TValue>): \Ds\Map<TIndex, TValue>
*/
function keyBy(callable $selector): \Closure
{
dsAvailable() || throw DsUnavailableException::for('keyBy');
$selector = callback($selector);
return static function (iterable $input) use ($selector): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$map->put($selector($value, $key), $value);
}
return $map;
};
}
/**
* Groups the values by `$selector($value, $key)` into a `Ds\Map` of `Ds\Seq`. Groups and their values keep the
* order in which they were encountered.
*
* @template TKey
* @template TValue
* @template TGroup
*
* @param callable(TValue, TKey): TGroup $selector
*
* @return \Closure(iterable<TKey, TValue>): \Ds\Map<TGroup, \Ds\Seq<TValue>>
*/
function groupBy(callable $selector): \Closure
{
dsAvailable() || throw DsUnavailableException::for('groupBy');
$selector = callback($selector);
return static function (iterable $input) use ($selector): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$group = $selector($value, $key);
if (!$map->hasKey($group)) {
$map->put($group, new \Ds\Seq());
}
$map->get($group)->push($value);
}
return $map;
};
}
/**
* Collects the values into a `Ds\Seq`, dropping the keys.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): \Ds\Seq<TValue>
*/
function toSeq(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toSeq');
return static fn (iterable $input): \Ds\Seq => new \Ds\Seq($input);
}
/**
* Collects the entries into a `Ds\Map`, keeping the keys (of any type).
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Ds\Map<TKey, TValue>
*/
function toMap(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toMap');
return static function (iterable $input): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$map->put($key, $value);
}
return $map;
};
}
/**
* Collects the values into a `Ds\Set`, dropping the keys.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): \Ds\Set<TValue>
*/
function toSet(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toSet');
return static fn (iterable $input): \Ds\Set => new \Ds\Set($input);
}
/**
* Collects the entries into an array, keeping the keys. Works without php-ds.
*
* Throws when a key can't be an array key (e.g. an object key of a `Ds\Map`).
*
* @return \Closure<TKey of array-key, TValue>(iterable<TKey, TValue>): array<TKey, TValue>
*/
function toArray(): \Closure
{
return static function (iterable $input): array {
$array = [];
foreach ($input as $key => $value) {
if (!is_scalar($key) && $key !== null) {
throw new \InvalidArgumentException(sprintf(
'toArray(): key of type %s cannot be an array key',
get_debug_type($key),
));
}
$array[$key] = $value;
}
return $array;
};
}

View File

@ -0,0 +1,18 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Exception;
class DsUnavailableException extends FunctionalException
{
public static function for(string $function): self
{
return new self(sprintf('%s() needs php-ds: install ext-ds or require php-ds/php-ds', $function));
}
}

View File

@ -0,0 +1,108 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Ds\Map;
use Ds\Seq;
use Ds\Set;
use Kadet\Functional\Collections as c;
class CollectorsTest extends \PHPUnit\Framework\TestCase
{
private static function gen(iterable $entries): \Generator
{
foreach ($entries as $key => $value) {
yield $key => $value;
}
}
public function testKeyByLastWins()
{
$map = c\keyBy(fn ($v) => $v['id'])([['id' => 1, 'n' => 'a'], ['id' => 2, 'n' => 'b'], ['id' => 1, 'n' => 'c']]);
$this->assertInstanceOf(Map::class, $map);
$this->assertSame(2, $map->count());
$this->assertSame('c', $map->get(1)['n']);
$this->assertSame([1, 2], $map->keys()->toArray());
}
public function testKeyBySelectorReceivesKey()
{
$map = c\keyBy(fn ($v, $k) => $k . $v)(['a' => 'x']);
$this->assertSame(['ax' => 'x'], $map->toArray());
}
public function testKeyByAcceptsInternalFunction()
{
$this->assertSame(['A' => 'a'], c\keyBy(strtoupper(...))(['a'])->toArray());
}
public function testGroupByKeepsOrderAndUsesSeq()
{
$map = c\groupBy(fn ($v) => $v % 2)([1, 2, 3, 4]);
$this->assertEquals(new Map([1 => new Seq([1, 3]), 0 => new Seq([2, 4])]), $map);
$this->assertSame([1, 0], $map->keys()->toArray());
$this->assertInstanceOf(Seq::class, $map->get(1));
}
public function testToSeqDropsKeys()
{
$this->assertSame([1, 2], c\toSeq()(['a' => 1, 'b' => 2])->toArray());
}
public function testToMapPreservesGeneratorKeys()
{
$key = new \stdClass();
$map = c\toMap()((static function () use ($key) {
yield 'a' => 1;
yield 5 => 2;
yield $key => 3;
})());
$this->assertInstanceOf(Map::class, $map);
$this->assertSame(1, $map->get('a'));
$this->assertSame(2, $map->get(5));
$this->assertSame(3, $map->get($key));
}
public function testToSet()
{
$set = c\toSet()(['a' => 1, 'b' => 1, 'c' => 2]);
$this->assertInstanceOf(Set::class, $set);
$this->assertSame([1, 2], $set->toArray());
}
public function testToArrayPreservesKeys()
{
$this->assertSame(['a' => 1, 5 => 2], c\toArray()(self::gen(['a' => 1, 5 => 2])));
$this->assertSame(['a' => 1], c\toArray()(new Map(['a' => 1])));
$this->assertSame([1, 2], c\toArray()(new Seq([1, 2])));
}
public function testToArrayRejectsObjectKeys()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('toArray(): key of type stdClass cannot be an array key');
$map = new Map();
$map->put(new \stdClass(), 1);
c\toArray()($map);
}
public function testCollectorsAcceptLazyInput()
{
$map = c\keyBy(fn ($v) => $v * 10)(self::gen([1, 2]));
$this->assertSame([10 => 1, 20 => 2], $map->toArray());
$this->assertSame([0 => [1, 3], 1 => [2, 4]], c\groupBy(fn ($v) => $v % 2 ? 0 : 1)(self::gen([1, 2, 3, 4]))->map(fn ($k, $v) => $v->toArray())->toArray());
}
}

76
tests/NoDs/NoDsTest.php Normal file
View File

@ -0,0 +1,76 @@
<?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\Exception\DsUnavailableException;
/**
* Runs without php-ds, see phpunit.no-ds.xml.
*/
class NoDsTest extends \PHPUnit\Framework\TestCase
{
public function testDsIsHidden()
{
$this->assertFalse(class_exists(\Ds\Seq::class));
}
public function testTransformsOnArraysWork()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map(fn ($v) => $v * 2)(['a' => 1, 'b' => 2]));
$this->assertSame(['b' => 2], c\filter(fn ($v) => $v > 1)(['a' => 1, 'b' => 2]));
$this->assertSame([1, 2], c\values()(['a' => 1, 'b' => 2]));
$this->assertSame([1, 1, 2, 2], c\flatMap(fn ($v) => [$v, $v])([1, 2]));
}
public function testEagerTransformOnGeneratorGivesList()
{
$gen = static function () {
yield 'a' => 1;
yield 'b' => 2;
};
$this->assertSame([1, 2], c\map(fn ($v) => $v)($gen()));
$this->assertSame([2], c\filter(fn ($v) => $v > 1)($gen()));
$this->assertSame([1, 2], c\values()($gen()));
$this->assertSame([1, 1, 2, 2], c\flatMap(fn ($v) => [$v, $v])($gen()));
}
public function testToSeqThrowsNamedException()
{
$this->expectException(DsUnavailableException::class);
$this->expectExceptionMessage('toSeq() needs php-ds: install ext-ds or require php-ds/php-ds');
c\toSeq();
}
public function testGroupByThrows()
{
$this->expectException(DsUnavailableException::class);
$this->expectExceptionMessage('groupBy()');
c\groupBy(fn ($v) => $v);
}
public function testOtherDsCollectorsThrow()
{
foreach (['toMap' => fn () => c\toMap(), 'toSet' => fn () => c\toSet(), 'keyBy' => fn () => c\keyBy(fn ($v) => $v)] as $name => $build) {
try {
$build();
$this->fail("$name() should throw");
} catch (DsUnavailableException $e) {
$this->assertStringContainsString("$name()", $e->getMessage());
}
}
}
public function testToArrayWorks()
{
$this->assertSame(['a' => 1], c\toArray()(['a' => 1]));
}
}

18
tests/bootstrap-no-ds.php Normal file
View File

@ -0,0 +1,18 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
$loader = require __DIR__.'/../vendor/autoload.php';
if (extension_loaded('ds')) {
echo "Skipping the no-php-ds suite: ext-ds is loaded.\n";
exit(0);
}
// Hide the php-ds polyfill, so the library runs as if php-ds was not installed.
$loader->setPsr4('Ds\\', []);