diff --git a/README.md b/README.md index e0bf45e..874a1bc 100644 --- a/README.md +++ b/README.md @@ -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 inputs it appends. - `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` diff --git a/src/Collections/Internal/containers.php b/src/Collections/Internal/containers.php index bf1d746..fac6140 100644 --- a/src/Collections/Internal/containers.php +++ b/src/Collections/Internal/containers.php @@ -147,6 +147,28 @@ function entries(iterable $iterable): array return $entries; } +/** + * Builds a `Ds\Map` from entries given by position, in the order of the keys of $order (positions). + * + * @internal + * + * @param list $keys + * @param array $values + * @param array $order + * + * @return \Ds\Map + */ +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. * diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index d374036..d536e99 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -27,6 +27,7 @@ use function Kadet\Functional\Collections\Internal\iterated; use function Kadet\Functional\Collections\Internal\keyedLike; use function Kadet\Functional\Collections\Internal\seen; 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\unpairs; use function Kadet\Functional\Collections\Internal\valuesOf; @@ -314,14 +315,51 @@ function uniqueBy(callable $selector): \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. - $comparator = $comparator === null ? static fn ($a, $b) => $a <=> $b : compile($comparator); + $comparator = compile($comparator); return static function (iterable $input) use ($comparator): iterable { - $entries = entries($input); - usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1])); + if (is_array($input)) { + 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); }; } @@ -348,16 +386,47 @@ function sortBy(callable $selector, bool $descending = false): \Closure { $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 { - $entries = []; - foreach ($input as $key => $value) { - $entries[] = [$key, $value, $selector($value, $key)]; + if (is_array($input)) { + $order = []; + 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; - usort($entries, static fn ($a, $b) => $direction * ($a[2] <=> $b[2])); + // 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); + } - 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); }; } diff --git a/tests/Collections/TransformsTest.php b/tests/Collections/TransformsTest.php index 2b77568..64e56c3 100644 --- a/tests/Collections/TransformsTest.php +++ b/tests/Collections/TransformsTest.php @@ -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])); } + 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() { $seq = new Seq([3, 1, 2]);