sort natively in sortBy and sort

This commit is contained in:
Kacper Donat 2026-10-28 09:56:11 +01:00
parent dc1b684978
commit a4a51e32a1
4 changed files with 179 additions and 10 deletions

View File

@ -268,6 +268,9 @@ 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,
which `<=>` can't order: they are grouped by case and placed after the other values (before them in descending
order).
### Finding things: `first`, `find` and `required` ### Finding things: `first`, `find` and `required`

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

@ -27,6 +27,7 @@ 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;
@ -314,14 +315,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 {
if (is_array($input)) {
uasort($input, $comparator);
return $input;
}
if ($input instanceof \Ds\Map) {
$entries = entries($input); $entries = entries($input);
usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1])); usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1]));
return keyedLike($input, unpairs($entries)); return keyedLike($input, unpairs($entries));
}
$values = valuesOf($input);
usort($values, $comparator);
return listLike($input, $values);
}; };
} }
@ -348,16 +386,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)) {
$order = [];
foreach ($input as $key => $value) { foreach ($input as $key => $value) {
$entries[] = [$key, $value, $selector($value, $key)]; $order[$key] = $selector($value, $key);
} }
$direction = $descending ? -1 : 1; $descending ? arsort($order) : asort($order);
usort($entries, static fn ($a, $b) => $direction * ($a[2] <=> $b[2]));
return keyedLike($input, unpairs($entries)); $result = [];
foreach ($order as $key => $_) {
$result[$key] = $input[$key];
}
return $result;
}
// Keys of `Ds\Map` can be objects, so the entries are sorted by position.
$keys = [];
$values = [];
$order = [];
foreach (iterated($input) as $key => $value) {
$keys[] = $key;
$values[] = $value;
$order[] = $selector($value, $key);
}
$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);
}; };
} }

View File

@ -248,6 +248,81 @@ 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 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]);