diff --git a/composer.json b/composer.json index 8034bd2..1906b89 100644 --- a/composer.json +++ b/composer.json @@ -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" + ] } } diff --git a/phpunit.no-ds.xml b/phpunit.no-ds.xml new file mode 100644 index 0000000..20f8b94 --- /dev/null +++ b/phpunit.no-ds.xml @@ -0,0 +1,8 @@ + + + + + tests/NoDs + + + diff --git a/phpunit.xml b/phpunit.xml index 773c5ba..099d00a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,6 +3,7 @@ tests + tests/NoDs diff --git a/src/Collections/collectors.php b/src/Collections/collectors.php new file mode 100644 index 0000000..1674089 --- /dev/null +++ b/src/Collections/collectors.php @@ -0,0 +1,149 @@ + + * + * 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): \Ds\Map + */ +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): \Ds\Map> + */ +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(iterable): \Ds\Seq + */ +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(iterable): \Ds\Map + */ +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(iterable): \Ds\Set + */ +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(iterable): array + */ +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; + }; +} diff --git a/src/Exception/DsUnavailableException.php b/src/Exception/DsUnavailableException.php new file mode 100644 index 0000000..0638ebd --- /dev/null +++ b/src/Exception/DsUnavailableException.php @@ -0,0 +1,18 @@ + + * + * 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)); + } +} diff --git a/tests/Collections/CollectorsTest.php b/tests/Collections/CollectorsTest.php new file mode 100644 index 0000000..8eb0e03 --- /dev/null +++ b/tests/Collections/CollectorsTest.php @@ -0,0 +1,108 @@ + + * + * 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()); + } +} diff --git a/tests/NoDs/NoDsTest.php b/tests/NoDs/NoDsTest.php new file mode 100644 index 0000000..b01fd16 --- /dev/null +++ b/tests/NoDs/NoDsTest.php @@ -0,0 +1,76 @@ + + * + * 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])); + } +} diff --git a/tests/bootstrap-no-ds.php b/tests/bootstrap-no-ds.php new file mode 100644 index 0000000..53ca8b7 --- /dev/null +++ b/tests/bootstrap-no-ds.php @@ -0,0 +1,18 @@ + + * + * 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\\', []);