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\\', []);