Compare commits

..

8 Commits

Author SHA1 Message Date
Kacper Donat
6cb6a22f18 release 1.1.0 2026-10-28 21:33:44 +01:00
Kacper Donat
a61db75c7e add chunk to split values into arrays of a size 2026-10-28 21:32:47 +01:00
Kacper Donat
ab3034e2be release 1.0.1 2026-10-28 10:05:43 +01:00
Kacper Donat
4ad971b431 document enum ordering of native sorts 2026-10-28 10:05:33 +01:00
Kacper Donat
b2550cf80b pass by-value only to native filter paths 2026-10-28 10:05:33 +01:00
Kacper Donat
d013a84f90 avoid by-reference warnings in filter 2026-10-28 09:56:11 +01:00
Kacper Donat
71e1b2cbdc keep currying when compiling curried closures 2026-10-28 09:56:11 +01:00
Kacper Donat
a4a51e32a1 sort natively in sortBy and sort 2026-10-28 09:56:11 +01:00
11 changed files with 425 additions and 18 deletions

View File

@ -1,5 +1,23 @@
# Changelog # Changelog
## 1.1.0
### Added
- `chunk($size, $preserveKeys = false)` splits the values into arrays of `$size`, like `array_chunk()`. The lazy one yields each chunk as soon as it is full, so a streamed source is never held whole; the eager one gives a list array for an array and a `Ds\Seq` for anything else. A size below 1 throws `InvalidArgumentException`.
## 1.0.1
### Changed
- `sort()` without a comparator and `sortBy()` sort natively, comparing like `<=>`. Enums used directly as the sort key (or as the values for `sort()`), which `<=>` can't order, are now grouped by case, in an unspecified order, and placed after the other values (before them in descending order). Enums nested inside arrays are unaffected.
### Fixed
- `sortBy()` and `sort()` are several times faster: they use PHP's native sort instead of `usort()` with a userland comparator.
- `compile()` of a closure of a curried function (`$curried(...)`), alone or in a pipeline, keeps currying instead of throwing `ArgumentCountError`.
- `filter()` on arrays with a callback taking its value by reference (`fn (&$value) => ...`) no longer emits "must be passed by reference" warnings.
## 1.0.0 ## 1.0.0
### Breaking ### Breaking

View File

@ -253,7 +253,7 @@ array. The details:
¹ `map`, `filter`, `reject`, `unique`, `uniqueBy`, `sort`, `sortBy`, `slice`, `take`, `skip`, `merge`, `each`, ¹ `map`, `filter`, `reject`, `unique`, `uniqueBy`, `sort`, `sortBy`, `slice`, `take`, `skip`, `merge`, `each`,
`tap`. ² `flatMap`, `values`, `keys`, `prepend`, `append`, `concat`: these produce a list, and a `Ds\Map` can't hold `tap`. ² `flatMap`, `values`, `keys`, `prepend`, `append`, `concat`: these produce a list, and a `Ds\Map` can't hold
a list, so it becomes a `Ds\Seq`. a list, so it becomes a `Ds\Seq`. `chunk` also produces a list, of arrays, and gives a `Ds\Seq` for a `Ds\Set` too.
A few consequences worth knowing: A few consequences worth knowing:
@ -268,6 +268,10 @@ A few consequences worth knowing:
- `merge()` merges arrays and `Ds\Map` by key (integer keys of arrays are kept, unlike `array_merge()`); for other - `merge()` merges arrays and `Ds\Map` by key (integer keys of arrays are kept, unlike `array_merge()`); for other
inputs it appends. inputs it appends.
- `sort()`/`sortBy()` are stable and never modify their input, unlike `Ds\Seq::sort()`. - `sort()`/`sortBy()` are stable and never modify their input, unlike `Ds\Seq::sort()`.
- `sort()` without a comparator and `sortBy()` sort natively, comparing like `<=>`. The one difference is enums
used directly as the sort key (or as the values for `sort()`), which `<=>` can't order: they are grouped by case,
in an unspecified order (PHP compares object addresses, not declaration order), and placed after the other values
(before them in descending order). Enums nested inside arrays compare as `<=>` does.
### Finding things: `first`, `find` and `required` ### Finding things: `first`, `find` and `required`
@ -307,7 +311,8 @@ exactly like its target:
`map`, `filter` (keeps truthy values without a callback), `reject`, `flatMap`, `values`, `keys`, `unique`, `map`, `filter` (keeps truthy values without a callback), `reject`, `flatMap`, `values`, `keys`, `unique`,
`uniqueBy`, `sort` (comparator or `<=>`), `sortBy` (selector, optionally `descending: true`), `slice`, `take`, `uniqueBy`, `sort` (comparator or `<=>`), `sortBy` (selector, optionally `descending: true`), `slice`, `take`,
`skip`, `prepend`, `append`, `concat`, `merge`, `each`, `tap`. `skip`, `chunk` (size, optionally `preserveKeys: true`, like `array_chunk()`), `prepend`, `append`, `concat`, `merge`,
`each`, `tap`.
```php ```php
$delays = $departures $delays = $departures

View File

@ -147,6 +147,28 @@ function entries(iterable $iterable): array
return $entries; return $entries;
} }
/**
* Builds a `Ds\Map` from entries given by position, in the order of the keys of $order (positions).
*
* @internal
*
* @param list<mixed> $keys
* @param array<int, mixed> $values
* @param array<int, mixed> $order
*
* @return \Ds\Map<mixed, mixed>
*/
function mapInOrder(array $keys, array $values, array $order): \Ds\Map
{
$result = new \Ds\Map();
$result->allocate(count($order));
foreach ($order as $position => $_) {
$result->put($keys[$position], $values[$position]);
}
return $result;
}
/** /**
* Yields [key, value] pairs back as key => value. * Yields [key, value] pairs back as key => value.
* *

View File

@ -12,7 +12,8 @@
* *
* Every function returns a closure that takes any iterable and returns a generator, so nothing is done until the * Every function returns a closure that takes any iterable and returns a generator, so nothing is done until the
* result is consumed and a chain walks its source once. Keys are preserved, except for {@see values()}, * result is consumed and a chain walks its source once. Keys are preserved, except for {@see values()},
* {@see keys()}, {@see flatMap()}, {@see prepend()}, {@see append()} and {@see concat()}, which yield sequential keys. * {@see keys()}, {@see flatMap()}, {@see prepend()}, {@see append()}, {@see concat()} and {@see chunk()}, which yield
* sequential keys.
* End a chain with a collector or query from `Kadet\Functional\Collections`, e.g. `toArray()`. * End a chain with a collector or query from `Kadet\Functional\Collections`, e.g. `toArray()`.
* *
* The eager transforms in `Kadet\Functional\Collections` are built on these, so both flavours behave the same. * The eager transforms in `Kadet\Functional\Collections` are built on these, so both flavours behave the same.
@ -257,6 +258,42 @@ function skip(int $n): \Closure
return slice($n); return slice($n);
} }
/**
* Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`, yielding each as soon
* as it is full, so the source is never held whole.
*
* Chunks are lists unless `$preserveKeys` is true, which keeps the keys in them. Keys must then be valid array keys,
* and values under a key repeated within one chunk replace each other, as in any array.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<int, array<TKey, TValue>>
*/
function chunk(int $size, bool $preserveKeys = false): \Closure
{
if ($size < 1) {
throw new \InvalidArgumentException(sprintf('chunk(): size must be >= 1, got %d', $size));
}
return static function (iterable $input) use ($size, $preserveKeys): \Generator {
$chunk = [];
foreach ($input as $key => $value) {
if ($preserveKeys) {
$chunk[$key] = $value;
} else {
$chunk[] = $value;
}
if (count($chunk) === $size) {
yield $chunk;
$chunk = [];
}
}
if ($chunk !== []) {
yield $chunk;
}
};
}
/** /**
* Puts `$values` before the values of the input, with sequential keys. * Puts `$values` before the values of the input, with sequential keys.
* *

View File

@ -22,11 +22,13 @@ namespace Kadet\Functional\Collections;
use Kadet\Functional\Collections\Lazy as lazy; use Kadet\Functional\Collections\Lazy as lazy;
use function Kadet\Functional\Internal\callback; use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Internal\takesByReference;
use function Kadet\Functional\Collections\Internal\entries; use function Kadet\Functional\Collections\Internal\entries;
use function Kadet\Functional\Collections\Internal\iterated; use function Kadet\Functional\Collections\Internal\iterated;
use function Kadet\Functional\Collections\Internal\keyedLike; use function Kadet\Functional\Collections\Internal\keyedLike;
use function Kadet\Functional\Collections\Internal\seen; use function Kadet\Functional\Collections\Internal\seen;
use function Kadet\Functional\Collections\Internal\listLike; use function Kadet\Functional\Collections\Internal\listLike;
use function Kadet\Functional\Collections\Internal\mapInOrder;
use function Kadet\Functional\Collections\Internal\seqOf; use function Kadet\Functional\Collections\Internal\seqOf;
use function Kadet\Functional\Collections\Internal\unpairs; use function Kadet\Functional\Collections\Internal\unpairs;
use function Kadet\Functional\Collections\Internal\valuesOf; use function Kadet\Functional\Collections\Internal\valuesOf;
@ -100,14 +102,18 @@ function filter(?callable $fn = null): \Closure
{ {
$fn = $fn === null ? static fn ($value) => $value : callback($fn); $fn = $fn === null ? static fn ($value) => $value : callback($fn);
return static function (iterable $input) use ($fn): iterable { // array_filter() passes values, not variables, so a callback taking a parameter by reference would warn on each
// call. Through this wrapper it gets variables; as before, its changes don't reach the result.
$native = takesByReference($fn) ? static fn ($value, $key) => $fn($value, $key) : $fn;
return static function (iterable $input) use ($fn, $native): iterable {
if ($input instanceof \Ds\Map) { if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => $fn($value, $key)); return $input->filter(static fn ($key, $value) => $fn($value, $key));
} }
$values = iterated($input); $values = iterated($input);
if (is_array($values)) { if (is_array($values)) {
$values = array_filter($values, $fn, ARRAY_FILTER_USE_BOTH); $values = array_filter($values, $native, ARRAY_FILTER_USE_BOTH);
return is_array($input) ? $values : listLike($input, $values); return is_array($input) ? $values : listLike($input, $values);
} }
@ -304,7 +310,8 @@ function uniqueBy(callable $selector): \Closure
/** /**
* Sorts the values with `$comparator($a, $b)` (an int like `<=>`), or with `<=>` when it is null. * 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. * The sort is stable and keeps the keys of arrays and `Ds\Map`. The input is never modified. Without a comparator,
* enums are ordered differently from `<=>` (see "What you get back" in the README).
* *
* @template TValue = mixed * @template TValue = mixed
* *
@ -314,14 +321,51 @@ function uniqueBy(callable $selector): \Closure
*/ */
function sort(?callable $comparator = null): \Closure function sort(?callable $comparator = null): \Closure
{ {
if ($comparator === null) {
// The native SORT_REGULAR sorts compare like `<=>` and are stable.
return static function (iterable $input): iterable {
if (is_array($input)) {
asort($input);
return $input;
}
if ($input instanceof \Ds\Map) {
$keys = $input->keys()->toArray();
$values = $input->values()->toArray();
asort($values);
return mapInOrder($keys, $values, $values);
}
$values = valuesOf($input);
\sort($values);
return listLike($input, $values);
};
}
// Comparators always take two values, so they are compiled without the ($value, $key) adaptation. // 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); $comparator = compile($comparator);
return static function (iterable $input) use ($comparator): iterable { return static function (iterable $input) use ($comparator): iterable {
$entries = entries($input); if (is_array($input)) {
usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1])); uasort($input, $comparator);
return keyedLike($input, unpairs($entries)); return $input;
}
if ($input instanceof \Ds\Map) {
$entries = entries($input);
usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1]));
return keyedLike($input, unpairs($entries));
}
$values = valuesOf($input);
usort($values, $comparator);
return listLike($input, $values);
}; };
} }
@ -329,7 +373,8 @@ function sort(?callable $comparator = null): \Closure
* Sorts the values by `$selector($value, $key)`, compared with `<=>`, in ascending or descending order. * 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 * 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. * `Ds\Map`. The selector is called once per value. The input is never modified. Enum keys are ordered differently
* from `<=>` (see "What you get back" in the README).
* *
* @template TKey = mixed * @template TKey = mixed
* @template TValue = mixed * @template TValue = mixed
@ -348,16 +393,47 @@ function sortBy(callable $selector, bool $descending = false): \Closure
{ {
$selector = callback($selector); $selector = callback($selector);
// The selected values are sorted natively: SORT_REGULAR compares like `<=>`, and PHP's sorts are stable, also
// arsort() for ties.
return static function (iterable $input) use ($selector, $descending): iterable { return static function (iterable $input) use ($selector, $descending): iterable {
$entries = []; if (is_array($input)) {
foreach ($input as $key => $value) { $order = [];
$entries[] = [$key, $value, $selector($value, $key)]; foreach ($input as $key => $value) {
$order[$key] = $selector($value, $key);
}
$descending ? arsort($order) : asort($order);
$result = [];
foreach ($order as $key => $_) {
$result[$key] = $input[$key];
}
return $result;
} }
$direction = $descending ? -1 : 1; // Keys of `Ds\Map` can be objects, so the entries are sorted by position.
usort($entries, static fn ($a, $b) => $direction * ($a[2] <=> $b[2])); $keys = [];
$values = [];
$order = [];
foreach (iterated($input) as $key => $value) {
$keys[] = $key;
$values[] = $value;
$order[] = $selector($value, $key);
}
return keyedLike($input, unpairs($entries)); $descending ? arsort($order) : asort($order);
if ($input instanceof \Ds\Map) {
return mapInOrder($keys, $values, $order);
}
$result = [];
foreach ($order as $position => $_) {
$result[] = $values[$position];
}
return listLike($input, $result);
}; };
} }
@ -414,6 +490,25 @@ function skip(int $n): \Closure
return slice($n); return slice($n);
} }
/**
* Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`. See
* {@see Lazy\chunk()}.
*
* The chunks come in a list array for an array, and in a `Ds\Seq` (a list array without php-ds) for anything else.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<int, array<TKey, TValue>>
*/
function chunk(int $size, bool $preserveKeys = false): \Closure
{
$lazy = lazy\chunk($size, $preserveKeys);
return static function (iterable $input) use ($lazy): iterable {
$chunks = iterator_to_array($lazy(iterated($input)), false);
return is_array($input) ? $chunks : seqOf($chunks);
};
}
/** /**
* Puts `$values` before the values of the input, as a list (keys are dropped). * Puts `$values` before the values of the input, as a list (keys are dropped).
* *

View File

@ -79,6 +79,22 @@ function callback(callable $callable, int $arity = 1): \Closure
: static fn ($carry, $value) => $compiled($carry, $value); : static fn ($carry, $value) => $compiled($carry, $value);
} }
/**
* Whether a function takes any of its parameters by reference.
*
* @internal
*/
function takesByReference(\Closure $closure): bool
{
foreach ((new \ReflectionFunction($closure))->getParameters() as $parameter) {
if ($parameter->isPassedByReference()) {
return true;
}
}
return false;
}
/** /**
* Finds the function a callable ultimately calls: decorators are unwrapped, also when they were turned into * Finds the function a callable ultimately calls: decorators are unwrapped, also when they were turned into
* closures, and a pipeline gives its first function, which gets all of its arguments. * closures, and a pipeline gives its first function, which gets all of its arguments.

View File

@ -71,6 +71,9 @@ function memoize(callable $callable)
* Flattens a callable into a plain closure. Compilable objects (decorators, pipelines, predicates) are compiled, so * Flattens a callable into a plain closure. Compilable objects (decorators, pipelines, predicates) are compiled, so
* calling the result does not go through them, also when given as a closure of their `__invoke` (e.g. `$pipe(...)`); * calling the result does not go through them, also when given as a closure of their `__invoke` (e.g. `$pipe(...)`);
* any other callable is converted with closure(). * any other callable is converted with closure().
*
* A closure of a curried function's `__invoke` (`$curried(...)`) is kept as it is, so it keeps currying; the compiled
* curried function itself calls its target and does not curry further.
*/ */
function compile(callable $callable): \Closure function compile(callable $callable): \Closure
{ {
@ -78,7 +81,9 @@ function compile(callable $callable): \Closure
$reflection = new \ReflectionFunction($callable); $reflection = new \ReflectionFunction($callable);
$bound = $reflection->getClosureThis(); $bound = $reflection->getClosureThis();
return $bound instanceof Compilable && $reflection->getName() === '__invoke' ? $bound->compile() : $callable; return $bound instanceof Compilable && !$bound instanceof CurriedFunction && $reflection->getName() === '__invoke'
? $bound->compile()
: $callable;
} }
return $callable instanceof Compilable ? $callable->compile() : closure($callable); return $callable instanceof Compilable ? $callable->compile() : closure($callable);

View File

@ -167,4 +167,48 @@ class LazyTest extends \PHPUnit\Framework\TestCase
$this->assertSame([2, 3], iterator_to_array($step([1, 2]))); $this->assertSame([2, 3], iterator_to_array($step([1, 2])));
$this->assertSame([4], iterator_to_array($step([3]))); $this->assertSame([4], iterator_to_array($step([3])));
} }
public function testChunkSplitsIntoListsOfTheSize()
{
$this->assertSame(
[[1, 2], [3, 4], [5]],
iterator_to_array(lazy\chunk(2)(self::gen(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]))),
);
}
public function testChunkPreservesKeysWhenAsked()
{
$this->assertSame(
[['a' => 1, 'b' => 2], ['c' => 3]],
iterator_to_array(lazy\chunk(2, preserveKeys: true)(self::gen(['a' => 1, 'b' => 2, 'c' => 3]))),
);
}
public function testChunkKeepsValuesUnderRepeatedKeys()
{
$repeated = (function () {
yield 'a' => 1;
yield 'a' => 2;
})();
$this->assertSame([[1, 2]], iterator_to_array(lazy\chunk(2)($repeated)));
}
public function testChunkYieldsEachChunkOnceFull()
{
$this->assertSame([1, 2, 3], lazy\chunk(3)(self::naturals())->current());
}
public function testChunkOfNothingYieldsNothing()
{
$this->assertSame([], iterator_to_array(lazy\chunk(2)([])));
}
public function testChunkRejectsSizeBelowOneWhenBuilt()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('chunk(): size must be >= 1, got 0');
lazy\chunk(0);
}
} }

View File

@ -107,6 +107,45 @@ class TransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSeq([2], c\filter(fn ($v) => $v % 2 === 0)(self::gen([1, 2, 3]))); $this->assertSeq([2], c\filter(fn ($v) => $v % 2 === 0)(self::gen([1, 2, 3])));
} }
public function testFilterWithByReferenceCallbackDoesNotWarn()
{
$warnings = [];
set_error_handler(function (int $level, string $message) use (&$warnings) {
$warnings[] = $message;
return true;
});
try {
$array = c\filter(fn (&$v) => $v > 1)([1, 2, 3]);
$seq = c\filter(fn (&$v) => $v > 1)(new Seq([1, 2, 3]));
$both = c\filter(fn (&$v, $k) => $k > 0)([1, 2, 3]);
} finally {
restore_error_handler();
}
$this->assertSame([], $warnings);
$this->assertSame([1 => 2, 2 => 3], $array);
$this->assertSeq([2, 3], $seq);
$this->assertSame([1 => 2, 2 => 3], $both);
}
public function testByReferenceCallbacksWriteBackOutsideNativeFilter()
{
// Where the callback is called with a variable, its changes to the value are kept, as in 1.0.0.
$trim = function (&$v) {
$v = trim($v);
return $v !== '';
};
$gen = fn () => self::gen([' a ', ' ', 'b ']);
$this->assertSame(['a', 'b'], iterator_to_array(\Kadet\Functional\Collections\Lazy\filter($trim)($gen()), false));
$this->assertSame([1 => ''], c\reject($trim)([' a ', ' ', 'b ']));
$this->assertSame('x', c\find($trim)(new Seq([' x '])));
$this->assertSame('a', c\first($trim)($gen()));
}
public function testFilterWithInternalPredicate() public function testFilterWithInternalPredicate()
{ {
$this->assertSame([1 => 'a'], c\filter(is_string(...))([1, 'a'])); $this->assertSame([1 => 'a'], c\filter(is_string(...))([1, 'a']));
@ -248,6 +287,96 @@ class TransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame(['b' => 1, 'a' => 2], c\sortBy(fn ($v, $k) => $k, true)(['a' => 2, 'b' => 1])); $this->assertSame(['b' => 1, 'a' => 2], c\sortBy(fn ($v, $k) => $k, true)(['a' => 2, 'b' => 1]));
} }
public function testSortByIsStableForTies()
{
$items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd'], ['k' => 1, 'n' => 'e']];
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])($items), 'n'));
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])(new Seq($items))->toArray(), 'n'));
}
public function testSortByDescendingIsStableForTies()
{
$items = [['k' => 0, 'n' => 'a'], ['k' => 1, 'n' => 'b'], ['k' => 0, 'n' => 'c'], ['k' => 1, 'n' => 'd'], ['k' => 0, 'n' => 'e']];
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)($items), 'n'));
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)(new Seq($items))->toArray(), 'n'));
}
public function testSortByWithDateTimeKeys()
{
$at = fn (string $time) => new \DateTimeImmutable("2026-01-01 $time");
$items = ['c' => $at('10:00'), 'a' => $at('08:00'), 'd' => $at('10:00:01'), 'b' => $at('09:30')];
$this->assertSame(['a', 'b', 'c', 'd'], array_keys(c\sortBy(fn ($v) => $v)($items)));
$this->assertSame(['d', 'c', 'b', 'a'], array_keys(c\sortBy(fn ($v) => $v, true)($items)));
}
public function testSortByMixedNumericKeys()
{
$values = [3, 1.5, -2, 2.0, 0, 1, -0.5];
$this->assertSame([-2, -0.5, 0, 1, 1.5, 2.0, 3], c\sortBy(fn ($v) => $v)(new Seq($values))->toArray());
$this->assertSame([3, 2.0, 1.5, 1, 0, -0.5, -2], array_values(c\sortBy(fn ($v) => $v, true)($values)));
}
public function testSortAndSortByCompareLikeSpaceship()
{
$keys = [10, '9', 'abc', null, 2.5, '10', true, 'b', '', 0, [1, 2], [1], '1e1', ' 9', false];
$ascending = $keys;
usort($ascending, fn ($a, $b) => $a <=> $b);
$descending = $keys;
usort($descending, fn ($a, $b) => $b <=> $a);
$this->assertSame($ascending, array_values(c\sortBy(fn ($v) => $v)($keys)));
$this->assertSame($descending, array_values(c\sortBy(fn ($v) => $v, true)($keys)));
$this->assertSame($ascending, array_values(c\sort()($keys)));
}
public function testSortByKeepsObjectKeysOfMap()
{
$a = new \stdClass();
$b = new \stdClass();
$map = new Map();
$map->put($a, 2);
$map->put($b, 1);
$sorted = c\sortBy(fn ($v) => $v)($map);
$this->assertInstanceOf(Map::class, $sorted);
$this->assertSame([$b, $a], $sorted->keys()->toArray());
$this->assertSame([1, 2], $sorted->values()->toArray());
}
public function testSortWithComparatorKeepsObjectKeysOfMap()
{
$a = new \stdClass();
$b = new \stdClass();
$map = new Map();
$map->put($a, 1);
$map->put($b, 2);
$sorted = c\sort(fn ($x, $y) => $y <=> $x)($map);
$this->assertInstanceOf(Map::class, $sorted);
$this->assertSame([$b, $a], $sorted->keys()->toArray());
$this->assertSame([2, 1], $sorted->values()->toArray());
}
public function testSortByDoesNotMutateInput()
{
$array = ['x' => 3, 'y' => 1, 'z' => 2];
$seq = new Seq([3, 1, 2]);
c\sortBy(fn ($v) => $v)($array);
c\sortBy(fn ($v) => $v, true)($seq);
c\sort()($array);
$this->assertSame(['x' => 3, 'y' => 1, 'z' => 2], $array);
$this->assertSame([3, 1, 2], $seq->toArray());
}
public function testSortDoesNotMutateInput() public function testSortDoesNotMutateInput()
{ {
$seq = new Seq([3, 1, 2]); $seq = new Seq([3, 1, 2]);
@ -297,6 +426,23 @@ class TransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSeq([1, 2, 3], c\take(3)($infinite)); $this->assertSeq([1, 2, 3], c\take(3)($infinite));
} }
public function testChunk()
{
$this->assertSame([[1, 2], [3]], c\chunk(2)(['a' => 1, 'b' => 2, 'c' => 3]));
$this->assertSame([['a' => 1, 'b' => 2], ['c' => 3]], c\chunk(2, preserveKeys: true)(['a' => 1, 'b' => 2, 'c' => 3]));
$this->assertSeq([[1, 2], [3]], c\chunk(2)(new Seq([1, 2, 3])));
$this->assertSeq([['a' => 1], ['b' => 2]], c\chunk(1, preserveKeys: true)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([[1, 2], [3]], c\chunk(2)(self::gen([1, 2, 3])));
}
public function testChunkRejectsSizeBelowOne()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('chunk(): size must be >= 1, got 0');
c\chunk(0);
}
public function testSkip() public function testSkip()
{ {
$this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2])); $this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2]));

View File

@ -243,6 +243,23 @@ class CompileTest extends \PHPUnit\Framework\TestCase
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b')); $this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
} }
public function testCompiledClosureOfCurriedKeepsCurrying()
{
$curried = f\curry(fn ($a, $b) => "$a$b");
$this->assertSame('ab', f\compile($curried(...))('a')('b'));
$this->assertSame('ab', f\compile($curried(...))('a', 'b'));
}
public function testCompiledPipelineOfCurriedClosureKeepsCurrying()
{
$curried = f\curry(fn ($a, $b) => "$a$b");
$pipeline = f\pipe(strtoupper(...), $curried(...));
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
$this->assertSame('ab', f\compile(f\pipe($curried(...), fn ($rest) => $rest('b')))('a'));
}
public function testCompileUnwrapsClosureOfCompilable() public function testCompileUnwrapsClosureOfCompilable()
{ {
$spy = new class(strtoupper(...)) extends FunctionPipeline { $spy = new class(strtoupper(...)) extends FunctionPipeline {

View File

@ -67,10 +67,12 @@ function transforms(
// Transforms without a callback keep the element types, but not the container. // Transforms without a callback keep the element types, but not the container.
assertType('iterable<int, Foo>', c\values()($foos)); assertType('iterable<int, Foo>', c\values()($foos));
assertType('iterable<int<0, max>, Foo>', c\take(2)($list)); assertType('iterable<int<0, max>, Foo>', c\take(2)($list));
assertType('iterable<int, array<int<0, max>, Foo>>', c\chunk(2)($list));
// Lazy transforms give generators. // Lazy transforms give generators.
assertType('Generator<mixed, string, mixed, mixed>', lazy\map(fn (Foo $foo): string => $foo->name)($list)); assertType('Generator<mixed, string, mixed, mixed>', lazy\map(fn (Foo $foo): string => $foo->name)($list));
assertType('Generator<mixed, Foo, mixed, mixed>', lazy\filter(fn (Foo $foo): bool => $foo->id > 0)($list)); assertType('Generator<mixed, Foo, mixed, mixed>', lazy\filter(fn (Foo $foo): bool => $foo->id > 0)($list));
assertType('Generator<int, array<int<0, max>, Foo>, mixed, mixed>', lazy\chunk(2)($list));
} }
/** /**