add collectors and grouping
This commit is contained in:
parent
04c40087c2
commit
1cddbe796a
@ -26,10 +26,17 @@
|
||||
"./src/Predicates/index.php",
|
||||
"./src/Collections/Internal/functions.php",
|
||||
"./src/Collections/Internal/containers.php",
|
||||
"./src/Collections/transforms.php"
|
||||
"./src/Collections/transforms.php",
|
||||
"./src/Collections/collectors.php"
|
||||
]
|
||||
},
|
||||
"suggest": {
|
||||
"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
8
phpunit.no-ds.xml
Normal 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>
|
||||
@ -3,6 +3,7 @@
|
||||
<testsuites>
|
||||
<testsuite name="all">
|
||||
<directory>tests</directory>
|
||||
<exclude>tests/NoDs</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
|
||||
149
src/Collections/collectors.php
Normal file
149
src/Collections/collectors.php
Normal 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;
|
||||
};
|
||||
}
|
||||
18
src/Exception/DsUnavailableException.php
Normal file
18
src/Exception/DsUnavailableException.php
Normal 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));
|
||||
}
|
||||
}
|
||||
108
tests/Collections/CollectorsTest.php
Normal file
108
tests/Collections/CollectorsTest.php
Normal 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
76
tests/NoDs/NoDsTest.php
Normal 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
18
tests/bootstrap-no-ds.php
Normal 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\\', []);
|
||||
Loading…
Reference in New Issue
Block a user