From b48f28e9dc74df2914a1548919faf51e92ba573a Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 22:15:52 +0100 Subject: [PATCH] add eager collection transforms --- composer.json | 4 +- src/Collections/Internal/containers.php | 184 ++++++++++ src/Collections/transforms.php | 437 ++++++++++++++++++++++++ tests/Collections/TransformsTest.php | 404 ++++++++++++++++++++++ 4 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 src/Collections/Internal/containers.php create mode 100644 src/Collections/transforms.php create mode 100644 tests/Collections/TransformsTest.php diff --git a/composer.json b/composer.json index 048a906..8034bd2 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,9 @@ "files": [ "./src/functions.php", "./src/Predicates/index.php", - "./src/Collections/Internal/functions.php" + "./src/Collections/Internal/functions.php", + "./src/Collections/Internal/containers.php", + "./src/Collections/transforms.php" ] }, "suggest": { diff --git a/src/Collections/Internal/containers.php b/src/Collections/Internal/containers.php new file mode 100644 index 0000000..8c94210 --- /dev/null +++ b/src/Collections/Internal/containers.php @@ -0,0 +1,184 @@ + + * + * Full license available in separate LICENSE file + */ + +namespace Kadet\Functional\Collections\Internal; + +/** + * Whether php-ds (the extension or the polyfill) is available. + * + * @internal + */ +function dsAvailable(): bool +{ + return class_exists(\Ds\Seq::class); +} + +/** + * Builds a keyed result in the same kind of container as $input. + * + * Arrays and `Ds\Map` keep the keys of $entries, `Ds\Seq` and `Ds\Set` take only the values. Any other iterable + * gives a `Ds\Seq` of the values, or a list array when php-ds is unavailable. + * + * @internal + * + * @param iterable $input + * @param iterable $entries + * + * @return iterable + */ +function keyedLike(iterable $input, iterable $entries): iterable +{ + if (is_array($input)) { + $result = []; + foreach ($entries as $key => $value) { + $result[$key] = $value; + } + + return $result; + } + + if ($input instanceof \Ds\Map) { + $result = new \Ds\Map(); + foreach ($entries as $key => $value) { + $result->put($key, $value); + } + + return $result; + } + + return listLike($input, $entries); +} + +/** + * Builds a list result (sequential keys) for $input from the values of $values. + * + * Arrays give a list array, `Ds\Set` gives a `Ds\Set`, and anything else gives a `Ds\Seq`, or a list array when + * php-ds is unavailable. + * + * @internal + * + * @param iterable $input + * @param iterable $values + * + * @return iterable + */ +function listLike(iterable $input, iterable $values): iterable +{ + $list = []; + foreach ($values as $value) { + $list[] = $value; + } + + if (is_array($input)) { + return $list; + } + + if ($input instanceof \Ds\Set) { + return new \Ds\Set($list); + } + + return seqOf($list); +} + +/** + * Wraps a list in a `Ds\Seq`, or returns it as it is when php-ds is unavailable. + * + * @internal + * + * @template TValue + * + * @param list $list + * + * @return iterable + */ +function seqOf(array $list): iterable +{ + return dsAvailable() ? new \Ds\Seq($list) : $list; +} + +/** + * Collects the entries of an iterable as [key, value] pairs, so that keys of any type survive. + * + * @internal + * + * @param iterable $iterable + * + * @return list + */ +function entries(iterable $iterable): array +{ + $entries = []; + foreach ($iterable as $key => $value) { + $entries[] = [$key, $value]; + } + + return $entries; +} + +/** + * Yields [key, value] pairs back as key => value. + * + * @internal + * + * @param iterable $entries + * + * @return \Generator + */ +function unpairs(iterable $entries): \Generator +{ + foreach ($entries as [$key, $value]) { + yield $key => $value; + } +} + +/** + * Tracks values already seen, compared strictly (objects by identity). + * + * The returned closure answers whether the given value was seen before and remembers it. Objects are kept + * referenced, so a temporary object's id can't be reused by a later one. + * + * @internal + * + * @return \Closure(mixed): bool + */ +function seen(): \Closure +{ + $hashed = []; + $objects = []; + $other = []; + + return static function (mixed $value) use (&$hashed, &$objects, &$other): bool { + if (is_object($value)) { + $id = spl_object_id($value); + if (isset($objects[$id])) { + return true; + } + $objects[$id] = $value; + + return false; + } + + if (is_int($value) || is_string($value) || is_bool($value) || $value === null) { + $hash = get_debug_type($value) . ':' . $value; + if (isset($hashed[$hash])) { + return true; + } + $hashed[$hash] = true; + + return false; + } + + if (in_array($value, $other, true)) { + return true; + } + $other[] = $value; + + return false; + }; +} diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php new file mode 100644 index 0000000..f84ba0e --- /dev/null +++ b/src/Collections/transforms.php @@ -0,0 +1,437 @@ + + * + * Full license available in separate LICENSE file + */ + +/** + * Eager collection transforms. + * + * Every function returns a closure that takes the data, so they compose with the pipe operator. The result is the + * same kind of container as the input: an array gives an array (keys preserved, unless stated otherwise), `Ds\Seq` + * gives `Ds\Seq`, `Ds\Map` gives `Ds\Map` and `Ds\Set` gives `Ds\Set`. Any other iterable (e.g. a generator) is + * consumed and gives a `Ds\Seq` of the results, or a list array when php-ds is unavailable. + * + * Callbacks receive `($value, $key)`, also for `Ds\Map`. + */ + +namespace Kadet\Functional\Collections; + +use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Collections\Internal\entries; +use function Kadet\Functional\Collections\Internal\keyedLike; +use function Kadet\Functional\Collections\Internal\listLike; +use function Kadet\Functional\Collections\Internal\seen; +use function Kadet\Functional\Collections\Internal\seqOf; +use function Kadet\Functional\Collections\Internal\unpairs; +use function Kadet\Functional\compile; + +/** + * Transforms each value with `$fn($value, $key)`, keeping the keys. + * + * For `Ds\Set` this follows `Ds\Set::map`, so equal results collapse into one. + * + * @template TKey + * @template TValue + * @template TResult + * + * @param callable(TValue, TKey): TResult $fn + * + * @return \Closure(iterable): iterable + */ +function map(callable $fn): \Closure +{ + return static function (iterable $input) use ($fn): iterable { + $fn = callback($fn); + + return keyedLike($input, (static function () use ($input, $fn) { + foreach ($input as $key => $value) { + yield $key => $fn($value, $key); + } + })()); + }; +} + +/** + * Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null. + * + * Keys are preserved for arrays and `Ds\Map`; `Ds\Seq` is reindexed. + * + * @template TKey + * @template TValue + * + * @param (callable(TValue, TKey): mixed)|null $fn + * + * @return \Closure(iterable): iterable + */ +function filter(?callable $fn = null): \Closure +{ + return static function (iterable $input) use ($fn): iterable { + $fn = $fn === null ? static fn ($value) => $value : callback($fn); + + return keyedLike($input, (static function () use ($input, $fn) { + foreach ($input as $key => $value) { + if ($fn($value, $key)) { + yield $key => $value; + } + } + })()); + }; +} + +/** + * Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): iterable + */ +function reject(callable $fn): \Closure +{ + return static function (iterable $input) use ($fn): iterable { + $fn = callback($fn); + + return filter(static fn ($value, $key) => !$fn($value, $key))($input); + }; +} + +/** + * Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level into a list. + * + * Arrays give a list array, `Ds\Set` gives `Ds\Set`, anything else gives `Ds\Seq`. + * + * @template TKey + * @template TValue + * @template TResult + * + * @param callable(TValue, TKey): iterable $fn + * + * @return \Closure(iterable): iterable + */ +function flatMap(callable $fn): \Closure +{ + return static function (iterable $input) use ($fn): iterable { + $fn = callback($fn); + + return listLike($input, (static function () use ($input, $fn) { + foreach ($input as $key => $value) { + $result = $fn($value, $key); + + if (!is_iterable($result)) { + throw new \UnexpectedValueException(sprintf( + 'flatMap(): the callback must return an iterable, got %s.', + get_debug_type($result), + )); + } + + foreach ($result as $item) { + yield $item; + } + } + })()); + }; +} + +/** + * Drops the keys: an array gives a list array, `Ds\Map` gives `Ds\Seq`. + * + * @return \Closure(iterable): iterable + */ +function values(): \Closure +{ + return static fn (iterable $input): iterable => listLike($input, $input); +} + +/** + * Gives the keys as a list: a list array for an array, a `Ds\Seq` for anything else. + * + * @return \Closure(iterable): iterable + */ +function keys(): \Closure +{ + return static function (iterable $input): iterable { + if (is_array($input)) { + return array_keys($input); + } + + $keys = []; + foreach ($input as $key => $_) { + $keys[] = $key; + } + + return seqOf($keys); + }; +} + +/** + * Removes duplicate values, keeping the first occurrence. Values are compared strictly, objects by identity. + * + * @return \Closure(iterable): iterable + */ +function unique(): \Closure +{ + return uniqueBy(static fn ($value) => $value); +} + +/** + * Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence. + * Selected values are compared strictly, objects by identity. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $selector + * + * @return \Closure(iterable): iterable + */ +function uniqueBy(callable $selector): \Closure +{ + return static function (iterable $input) use ($selector): iterable { + $selector = callback($selector); + $seen = seen(); + + return keyedLike($input, (static function () use ($input, $selector, $seen) { + foreach ($input as $key => $value) { + if (!$seen($selector($value, $key))) { + yield $key => $value; + } + } + })()); + }; +} + +/** + * Sorts the values with `$comparator($a, $b)` (an int like `<=>`), or with `<=>` when it is null. + * + * The sort is stable and keeps the keys of arrays and `Ds\Map`. The input is never modified. + * + * @template TValue + * + * @param (callable(TValue, TValue): int)|null $comparator + * + * @return \Closure(iterable): iterable + */ +function sort(?callable $comparator = null): \Closure +{ + return static function (iterable $input) use ($comparator): iterable { + // Comparators always take two values, so they are compiled without the ($value, $key) adaptation. + $comparator = $comparator === null ? static fn ($a, $b) => $a <=> $b : compile($comparator); + + $entries = entries($input); + usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1])); + + return keyedLike($input, unpairs($entries)); + }; +} + +/** + * Sorts the values by `$selector($value, $key)`, compared with `<=>`, in ascending or descending order. + * + * The sort is stable (equal values keep their order in both directions) and keeps the keys of arrays and + * `Ds\Map`. The selector is called once per value. The input is never modified. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $selector + * + * @return \Closure(iterable): iterable + */ +function sortBy(callable $selector, bool $descending = false): \Closure +{ + return static function (iterable $input) use ($selector, $descending): iterable { + $selector = callback($selector); + + $entries = []; + foreach ($input as $key => $value) { + $entries[] = [$key, $value, $selector($value, $key)]; + } + + $direction = $descending ? -1 : 1; + usort($entries, static fn ($a, $b) => $direction * ($a[2] <=> $b[2])); + + return keyedLike($input, unpairs($entries)); + }; +} + +/** + * Takes `$length` values (all remaining when null) starting at position `$offset`, like `array_slice()`. + * + * Negative `$offset` counts from the end, negative `$length` stops that many values before the end. Keys of arrays + * and `Ds\Map` are preserved. With non-negative arguments a generator is consumed only as far as needed. + * + * @return \Closure(iterable): iterable + */ +function slice(int $offset, ?int $length = null): \Closure +{ + return static function (iterable $input) use ($offset, $length): iterable { + if ($offset < 0 || ($length !== null && $length < 0)) { + return keyedLike($input, unpairs(array_slice(entries($input), $offset, $length))); + } + + return keyedLike($input, (static function () use ($input, $offset, $length) { + if ($length === 0) { + return; + } + + $position = 0; + $taken = 0; + foreach ($input as $key => $value) { + if ($position++ < $offset) { + continue; + } + + yield $key => $value; + + if (++$taken === $length) { + return; + } + } + })()); + }; +} + +/** + * Takes the first `$n` values. See {@see slice()}. + * + * @return \Closure(iterable): iterable + */ +function take(int $n): \Closure +{ + return slice(0, $n); +} + +/** + * Skips the first `$n` values. See {@see slice()}. + * + * @return \Closure(iterable): iterable + */ +function skip(int $n): \Closure +{ + return slice($n); +} + +/** + * Puts `$values` before the values of the input, as a list (keys are dropped). + * + * @template TValue + * + * @param TValue ...$values + * + * @return \Closure(iterable): iterable + */ +function prepend(mixed ...$values): \Closure +{ + return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $values) { + yield from $values; + yield from $input; + })()); +} + +/** + * Puts `$values` after the values of the input, as a list (keys are dropped). + * + * @template TValue + * + * @param TValue ...$values + * + * @return \Closure(iterable): iterable + */ +function append(mixed ...$values): \Closure +{ + return concat($values); +} + +/** + * Puts the values of `$other` after the values of the input, as a list (keys are dropped). + * + * @template TValue + * + * @param iterable $other + * + * @return \Closure(iterable): iterable + */ +function concat(iterable $other): \Closure +{ + return static fn (iterable $input): iterable => listLike($input, (static function () use ($input, $other) { + yield from $input; + yield from $other; + })()); +} + +/** + * Merges `$other` into the input by key, later keys win. Integer keys of arrays are kept, unlike `array_merge()`. + * + * Arrays and `Ds\Map` are merged by key; for `Ds\Seq`, `Ds\Set` and other iterables the values are appended. + * + * @template TKey + * @template TValue + * + * @param iterable $other + * + * @return \Closure(iterable): iterable + */ +function merge(iterable $other): \Closure +{ + return static fn (iterable $input): iterable => keyedLike($input, (static function () use ($input, $other) { + yield from $input; + yield from $other; + })()); +} + +/** + * Calls `$fn($value, $key)` for each value, for its side effects, and returns the input unchanged. + * + * The return value of `$fn` is ignored. Arrays and php-ds collections are returned as they are; any other + * iterable is consumed and its values are returned as a `Ds\Seq` (or a list array without php-ds). + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): iterable + */ +function each(callable $fn): \Closure +{ + return static function (iterable $input) use ($fn): iterable { + $fn = callback($fn); + + $reusable = is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set; + if ($reusable) { + foreach ($input as $key => $value) { + $fn($value, $key); + } + + return $input; + } + + return listLike($input, (static function () use ($input, $fn) { + foreach ($input as $key => $value) { + $fn($value, $key); + yield $value; + } + })()); + }; +} + +/** + * Alias of {@see each()}. + * + * @template TKey + * @template TValue + * + * @param callable(TValue, TKey): mixed $fn + * + * @return \Closure(iterable): iterable + */ +function tap(callable $fn): \Closure +{ + return each($fn); +} diff --git a/tests/Collections/TransformsTest.php b/tests/Collections/TransformsTest.php new file mode 100644 index 0000000..d701a01 --- /dev/null +++ b/tests/Collections/TransformsTest.php @@ -0,0 +1,404 @@ + + * + * Full license available in separate LICENSE file + */ + +use Ds\Map; +use Ds\Seq; +use Ds\Set; +use Kadet\Functional\Collections as c; +use function Kadet\Functional\partial; +use const Kadet\Functional\_; + +class TransformsTest extends \PHPUnit\Framework\TestCase +{ + private static function gen(iterable $entries): \Generator + { + foreach ($entries as $key => $value) { + yield $key => $value; + } + } + + private function assertSeq(array $expected, $actual) + { + $this->assertInstanceOf(Seq::class, $actual); + $this->assertSame($expected, $actual->toArray()); + } + + private function assertMap(array $expected, $actual) + { + $this->assertInstanceOf(Map::class, $actual); + $this->assertSame($expected, $actual->toArray()); + } + + // map + + public function testMapOnArrayPreservesKeys() + { + $this->assertSame(['a' => 2, 'b' => 4], c\map(fn ($v) => $v * 2)(['a' => 1, 'b' => 2])); + } + + public function testMapOnSeq() + { + $this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(new Seq([1, 2]))); + } + + public function testMapOnMapPassesValueThenKey() + { + $this->assertEquals(new Map(['a' => 'a:1']), c\map(fn ($v, $k) => "$k:$v")(new Map(['a' => 1]))); + } + + public function testMapOnGeneratorGivesSeq() + { + $this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(self::gen([1, 2]))); + } + + public function testMapOnGeneratorPassesGeneratorKeys() + { + $this->assertSeq(['x:1', 'y:2'], c\map(fn ($v, $k) => "$k:$v")(self::gen(['x' => 1, 'y' => 2]))); + } + + public function testMapOnSetFollowsSetMap() + { + $result = c\map(fn ($v) => $v % 2)(new Set([1, 2, 3])); + + $this->assertInstanceOf(Set::class, $result); + $this->assertSame([1, 0], $result->toArray()); + } + + public function testMapWithInternalFunctionGetsValueOnly() + { + $this->assertSame(['a' => 'x'], c\map(trim(...))(['a' => ' x '])); + } + + public function testTransformsWorkWithDecoratedCallback() + { + $this->assertSame(['aa'], c\map(partial(str_repeat(...), _, 2))(['a'])); + } + + // filter / reject + + public function testFilterOnSeqReindexes() + { + $this->assertSame([1, 2], c\filter()(new Seq([0, 1, '', 2]))->toArray()); + } + + public function testFilterOnArrayPreservesKeys() + { + $this->assertSame([1 => 1, 3 => 2], c\filter()([0, 1, '', 2])); + } + + public function testFilterOnMapPassesValueThenKey() + { + $this->assertMap(['b' => 2], c\filter(fn ($v, $k) => $k === 'b')(new Map(['a' => 1, 'b' => 2]))); + } + + public function testFilterOnGeneratorGivesSeq() + { + $this->assertSeq([2], c\filter(fn ($v) => $v % 2 === 0)(self::gen([1, 2, 3]))); + } + + public function testFilterWithInternalPredicate() + { + $this->assertSame([1 => 'a'], c\filter(is_string(...))([1, 'a'])); + } + + public function testReject() + { + $odd = fn ($v) => $v % 2 === 1; + + $this->assertSame([1 => 2], c\reject($odd)([1, 2, 3])); + $this->assertSeq([2], c\reject($odd)(new Seq([1, 2, 3]))); + $this->assertMap(['b' => 2], c\reject($odd)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([2], c\reject($odd)(self::gen([1, 2, 3]))); + } + + // flatMap + + public function testFlatMap() + { + $pair = fn ($v) => [$v, $v * 10]; + + $this->assertSame([1, 10, 2, 20], c\flatMap($pair)(['a' => 1, 'b' => 2])); + $this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Seq([1, 2]))); + $this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(self::gen([1, 2]))); + } + + public function testFlatMapFlattensOneLevelOnly() + { + $this->assertSame([[1], 2], c\flatMap(fn ($v) => $v)([[[1]], [2]])); + } + + public function testFlatMapAcceptsIterableResults() + { + $this->assertSame(['a', 'b', 'c'], c\flatMap(fn ($v) => new Seq(str_split($v)))(['ab', 'c'])); + } + + // values / keys + + public function testValues() + { + $this->assertSame([1, 2], c\values()(['a' => 1, 'b' => 2])); + $this->assertSeq([1, 2], c\values()(new Seq([1, 2]))); + $this->assertSeq([1, 2], c\values()(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([1, 2], c\values()(self::gen(['a' => 1, 'b' => 2]))); + } + + public function testKeys() + { + $this->assertSame(['a', 'b'], c\keys()(['a' => 1, 'b' => 2])); + $this->assertSeq([0, 1], c\keys()(new Seq([5, 6]))); + $this->assertSeq(['a', 'b'], c\keys()(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq(['a', 'b'], c\keys()(self::gen(['a' => 1, 'b' => 2]))); + } + + // unique / uniqueBy + + public function testUnique() + { + $this->assertSame([0 => 1, 1 => '1', 3 => 2], c\unique()([1, '1', 1, 2])); + $this->assertSeq([1, '1', 2], c\unique()(new Seq([1, '1', 1, 2]))); + $this->assertMap(['a' => 1, 'c' => 2], c\unique()(new Map(['a' => 1, 'b' => 1, 'c' => 2]))); + $this->assertSeq([1, 2], c\unique()(self::gen([1, 2, 2, 1]))); + } + + public function testUniqueComparesObjectsByIdentity() + { + $a = new \stdClass(); + $b = new \stdClass(); + + $this->assertSame([$a, $b], array_values(c\unique()([$a, $b, $a]))); + } + + public function testUniqueComparesFloatsAndArraysStrictly() + { + $this->assertSame([0 => 1.0, 1 => 1, 2 => [1], 4 => ['1']], c\unique()([1.0, 1, [1], 1.0, ['1'], [1]])); + } + + public function testUniqueByKeepsFirst() + { + $items = [['id' => 1, 'n' => 'a'], ['id' => 1, 'n' => 'b'], ['id' => 2, 'n' => 'c']]; + + $this->assertSame(['a', 'c'], array_column(c\uniqueBy(fn ($o) => $o['id'])($items), 'n')); + $this->assertSame([0, 2], array_keys(c\uniqueBy(fn ($o) => $o['id'])($items))); + $this->assertSame( + ['a', 'c'], + c\uniqueBy(fn ($o) => $o['id'])(new Seq($items))->map(fn ($o) => $o['n'])->toArray(), + ); + $this->assertSame(['x', 'z'], c\uniqueBy(fn ($o) => $o['id'])(new Map(['x' => $items[0], 'y' => $items[1], 'z' => $items[2]]))->keys()->toArray()); + $this->assertCount(2, c\uniqueBy(fn ($o) => $o['id'])(self::gen($items))); + } + + public function testUniqueByKeepsTemporaryObjectsDistinct() + { + $this->assertSame([1, 2, 3], c\uniqueBy(fn ($v) => new \stdClass())([1, 2, 3])); + } + + // sort / sortBy + + public function testSort() + { + $this->assertSame([1 => 1, 2 => 2, 0 => 3], c\sort()([3, 1, 2])); + $this->assertSeq([1, 2, 3], c\sort()(new Seq([3, 1, 2]))); + $this->assertMap(['b' => 1, 'a' => 3], c\sort()(new Map(['a' => 3, 'b' => 1]))); + $this->assertSeq([1, 2, 3], c\sort()(self::gen([3, 1, 2]))); + } + + public function testSortWithComparator() + { + $this->assertSame([3, 2, 1], array_values(c\sort(fn ($a, $b) => $b <=> $a)([1, 3, 2]))); + } + + public function testSortWithInternalComparatorGetsBothArguments() + { + $this->assertSame(['a', 'b', 'c'], array_values(c\sort(strcmp(...))(['c', 'a', 'b']))); + } + + public function testSortIsStable() + { + $items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd']]; + + $this->assertSame(['b', 'd', 'a', 'c'], array_column(c\sort(fn ($a, $b) => $a['k'] <=> $b['k'])($items), 'n')); + $this->assertSame(['a', 'c', 'b', 'd'], array_column(c\sortBy(fn ($o) => $o['k'], descending: true)($items), 'n')); + } + + public function testSortBy() + { + $len = fn ($v) => strlen($v); + + $this->assertSame([1 => 'a', 2 => 'bb', 0 => 'ccc'], c\sortBy($len)(['ccc', 'a', 'bb'])); + $this->assertSame([0 => 'ccc', 2 => 'bb', 1 => 'a'], c\sortBy($len, true)(['ccc', 'a', 'bb'])); + $this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(new Seq(['ccc', 'a', 'bb']))); + $this->assertMap(['y' => 'a', 'x' => 'ccc'], c\sortBy($len)(new Map(['x' => 'ccc', 'y' => 'a']))); + $this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(self::gen(['ccc', 'a', 'bb']))); + } + + public function testSortByPassesKey() + { + $this->assertSame(['b' => 1, 'a' => 2], c\sortBy(fn ($v, $k) => $k, true)(['a' => 2, 'b' => 1])); + } + + public function testSortDoesNotMutateInput() + { + $seq = new Seq([3, 1, 2]); + $map = new Map(['a' => 3, 'b' => 1]); + + c\sortBy(fn ($v) => $v)($seq); + c\sort()($seq); + c\sort()($map); + + $this->assertSame([3, 1, 2], $seq->toArray()); + $this->assertSame(['a' => 3, 'b' => 1], $map->toArray()); + } + + // slice / take / skip + + public function testSlice() + { + $this->assertSame(['b' => 2, 'c' => 3], c\slice(1, 2)(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4])); + $this->assertSeq([2, 3], c\slice(1, 2)(new Seq([1, 2, 3, 4]))); + $this->assertMap(['b' => 2, 'c' => 3], c\slice(1, 2)(new Map(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]))); + $this->assertSeq([2, 3], c\slice(1, 2)(self::gen([1, 2, 3, 4]))); + } + + public function testSliceWithNegativeArguments() + { + $this->assertSame([2 => 3, 3 => 4], c\slice(-2)([1, 2, 3, 4])); + $this->assertSame([1 => 2], c\slice(1, -2)([1, 2, 3, 4])); + $this->assertSeq([3], c\slice(-2, -1)(self::gen([1, 2, 3, 4]))); + } + + public function testTake() + { + $this->assertSame(['a' => 1], c\take(1)(['a' => 1, 'b' => 2])); + $this->assertSeq([1, 2], c\take(2)(new Seq([1, 2, 3]))); + $this->assertMap(['a' => 1], c\take(1)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([], c\take(0)(new Seq([1, 2, 3]))); + } + + public function testTakeStopsConsumingGenerator() + { + $infinite = (function () { + for ($i = 1; ; $i++) { + yield $i; + } + })(); + + $this->assertSeq([1, 2, 3], c\take(3)($infinite)); + } + + public function testSkip() + { + $this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2])); + $this->assertSeq([3], c\skip(2)(new Seq([1, 2, 3]))); + $this->assertMap(['b' => 2], c\skip(1)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([2, 3], c\skip(1)(self::gen([1, 2, 3]))); + } + + // prepend / append / concat / merge + + public function testPrepend() + { + $this->assertSame([0, 1, 1, 2], c\prepend(0, 1)(['a' => 1, 'b' => 2])); + $this->assertSeq([0, 1, 2], c\prepend(0)(new Seq([1, 2]))); + $this->assertSeq([0, 1, 2], c\prepend(0)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([0, 1, 2], c\prepend(0)(self::gen([1, 2]))); + } + + public function testAppend() + { + $this->assertSame([1, 2, 3, 4], c\append(3, 4)(['a' => 1, 'b' => 2])); + $this->assertSeq([1, 2, 3], c\append(3)(new Seq([1, 2]))); + $this->assertSeq([1, 2, 3], c\append(3)(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([1, 2, 3], c\append(3)(self::gen([1, 2]))); + } + + public function testConcat() + { + $this->assertSame([1, 2, 3, 4], c\concat(['x' => 3, 'a' => 4])(['a' => 1, 'b' => 2])); + $this->assertSeq([1, 2, 3], c\concat(self::gen([3]))(new Seq([1, 2]))); + $this->assertSeq([1, 2, 3], c\concat([3])(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([1, 2, 3], c\concat(new Seq([3]))(self::gen([1, 2]))); + } + + public function testListOperationsOnSetGiveSet() + { + $result = c\append(2, 3)(new Set([1, 2])); + + $this->assertInstanceOf(Set::class, $result); + $this->assertSame([1, 2, 3], $result->toArray()); + } + + public function testMerge() + { + $this->assertSame(['a' => 1, 'b' => 3, 'c' => 4], c\merge(['b' => 3, 'c' => 4])(['a' => 1, 'b' => 2])); + $this->assertSame([0 => 'x', 1 => 'b', 5 => 'c'], c\merge([0 => 'x', 5 => 'c'])(['a', 'b'])); + $this->assertSeq([1, 2, 3], c\merge([3])(new Seq([1, 2]))); + $this->assertMap(['a' => 1, 'b' => 3, 'c' => 4], c\merge(new Map(['b' => 3, 'c' => 4]))(new Map(['a' => 1, 'b' => 2]))); + $this->assertSeq([1, 2, 3], c\merge([3])(self::gen([1, 2]))); + } + + // each / tap + + public function testEachDoesNotReplaceValues() + { + $calls = 0; + $seq = new Seq([1, 2, 3]); + + $result = c\each(function ($v) use (&$calls) { $calls++; return 'x'; })($seq); + + $this->assertSame([1, 2, 3], $result->toArray()); + $this->assertSame([1, 2, 3], $seq->toArray()); + $this->assertSame(3, $calls); + } + + public function testEachPassesValueThenKey() + { + $seen = []; + $fn = function ($v, $k) use (&$seen) { $seen[] = "$k:$v"; }; + + $this->assertSame(['a' => 1], c\each($fn)(['a' => 1])); + $map = new Map(['b' => 2]); + $this->assertSame($map, c\each($fn)($map)); + $this->assertSeq([3], c\each($fn)(self::gen(['c' => 3]))); + + $this->assertSame(['a:1', 'b:2', 'c:3'], $seen); + } + + public function testTapIsEach() + { + $seen = []; + + $this->assertSame([1, 2], c\tap(function ($v) use (&$seen) { $seen[] = $v; })([1, 2])); + $this->assertSame([1, 2], $seen); + } + + // object keys (Review Focus 3) + + public function testMapWithObjectKeysKeepsKeys() + { + $k1 = new \stdClass(); + $k2 = new \stdClass(); + $map = new Map(); + $map->put($k1, 2); + $map->put($k2, 1); + + foreach ([ + c\map(fn ($v) => $v * 10), + c\filter(), + c\sortBy(fn ($v) => $v), + c\uniqueBy(fn ($v, $k) => $k), + ] as $transform) { + $result = $transform($map); + + $this->assertInstanceOf(Map::class, $result); + $this->assertTrue($result->hasKey($k1)); + $this->assertTrue($result->hasKey($k2)); + } + } +}