From 46f3c966652e724f7511e2c3558ff89c9540bd78 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Mon, 24 Aug 2026 13:08:00 +0200 Subject: [PATCH 1/2] Add sequence aggregation terminals Third of four PRs bringing the terminal vocabulary of decision 5 to Sequence: fold, reduce/reduceOrNull, sum, avg/avgOrNull, min/max (+ OrNull), minOf/maxOf (+ OrNull) and joinToString. Every body in this batch walks $this once and reads nothing else, so all of them move out of CollectionLogic into the shared IterableTerminalsLogic rather than being copied into SequenceLogic - the line #29 drew and #31 followed. SequenceLogic gains nothing at all: both sides already use the trait, so the sequence picks these up by declaring them on the interface. Collection behaviour is unchanged, and its existing suite is what proves it. The OrNull variants stop being try-catch wrappers, which is the one behavioural change here. reduceOrNull() around reduce() swallowed an UnsupportedOperationException raised by the caller's own operation, and minOrNull()/maxOrNull()/minOfOrNull()/maxOfOrNull() swallowed a NoSuchElementException raised by the caller's selector - answering "empty" about a subject that was not empty. Each now walks on its own and returns null when nothing was found; two parity tests on the eager side pin the propagation. The empty-subject messages move to named constructors on UnsupportedOperationException (cannotReduceEmptySubject, cannotAverageEmptySubject) next to NoSuchElementException::emptySubject, so a sequence is told a "sequence" is empty. lcfirst leaves avg()'s eager wording byte-identical; reduce() gains the message it never carried. None of these take #[NoDiscard]: they hand back a scalar or an element rather than a new container, which is where #29 put the attribute. Claude-Session: https://claude.ai/code/session_01JCXbhGog8jh6JAeYSCjy9y --- src/CollectionLogic.php | 232 -------------- .../UnsupportedOperationException.php | 20 ++ src/IterableTerminalsLogic.php | 301 ++++++++++++++++++ src/Sequence/Sequence.php | 184 +++++++++++ tests/Collection/CollectionAggregate.php | 30 ++ tests/Sequence/SequenceAggregateTest.php | 290 +++++++++++++++++ tests/Type/Sequence/AggregationTypeTest.php | 47 +++ 7 files changed, 872 insertions(+), 232 deletions(-) create mode 100644 tests/Sequence/SequenceAggregateTest.php create mode 100644 tests/Type/Sequence/AggregationTypeTest.php diff --git a/src/CollectionLogic.php b/src/CollectionLogic.php index 896af0c..e6da9f1 100644 --- a/src/CollectionLogic.php +++ b/src/CollectionLogic.php @@ -10,10 +10,8 @@ namespace Noctud\Collection; use Closure; -use Noctud\Collection\Exception\ConversionException; use Noctud\Collection\Exception\NoSuchElementException; use Noctud\Collection\Exception\UnsupportedOperationException; -use Stringable; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\List\MutableList; use Noctud\Collection\Map\ImmutableMap; @@ -159,236 +157,6 @@ public function count(): int // --- Aggregation --- - /** {@inheritDoc} */ - public function fold(mixed $initial, Closure $operation): mixed - { - $acc = $initial; - foreach ($this as $v) { - $acc = $operation($acc, $v); - } - return $acc; - } - - /** {@inheritDoc} */ - public function reduce(Closure $operation): mixed - { - $first = true; - $acc = null; - - foreach ($this as $v) { - if ($first) { - $acc = $v; - $first = false; - } else { - $acc = $operation($acc, $v); - } - } - - if ($first) { - throw new UnsupportedOperationException(); - } - - return $acc; - } - - /** {@inheritDoc} */ - public function reduceOrNull(Closure $operation): mixed - { - try { - return $this->reduce($operation); - } catch (UnsupportedOperationException) { - return null; - } - } - - /** {@inheritDoc} */ - // @phpstan-ignore conditionalType.subjectNotFound (E is concrete, not a template, in extending fixtures) - public function sum(?Closure $selector = null): int|float - { - $sum = 0; - foreach ($this as $i => $v) { - $sum += $selector !== null ? $selector($v, $i) : $v; // @phpstan-ignore assignOp.invalid - } - - return $sum; - } - - /** {@inheritDoc} */ - public function avg(?Closure $selector = null): float - { - return $this->avgOrNull($selector) ?? throw new UnsupportedOperationException('Cannot compute average of empty collection'); - } - - /** {@inheritDoc} */ - public function avgOrNull(?Closure $selector = null): float|null - { - $sum = 0; - $count = 0; - foreach ($this as $i => $v) { - $sum += $selector !== null ? $selector($v, $i) : $v; // @phpstan-ignore assignOp.invalid - $count++; - } - - return $count > 0 ? $sum / $count : null; - } - - /** {@inheritDoc} */ - public function min(?Closure $selector = null): mixed - { - $minValue = null; - $minElement = null; - $found = false; - - foreach ($this as $i => $v) { - $value = $selector !== null ? $selector($v, $i) : $v; - if (!$found || $value < $minValue) { - $minValue = $value; - $minElement = $v; - $found = true; - } - } - - if (!$found) { - throw new NoSuchElementException('Collection is empty'); - } - - return $minElement; - } - - /** {@inheritDoc} */ - public function minOrNull(?Closure $selector = null): mixed - { - try { - return $this->min($selector); - } catch (NoSuchElementException) { - return null; - } - } - - /** {@inheritDoc} */ - public function max(?Closure $selector = null): mixed - { - $maxValue = null; - $maxElement = null; - $found = false; - - foreach ($this as $i => $v) { - $value = $selector !== null ? $selector($v, $i) : $v; - if (!$found || $value > $maxValue) { - $maxValue = $value; - $maxElement = $v; - $found = true; - } - } - - if (!$found) { - throw new NoSuchElementException('Collection is empty'); - } - - return $maxElement; - } - - /** {@inheritDoc} */ - public function maxOrNull(?Closure $selector = null): mixed - { - try { - return $this->max($selector); - } catch (NoSuchElementException) { - return null; - } - } - - /** {@inheritDoc} */ - public function minOf(Closure $selector): mixed - { - $minValue = null; - $found = false; - - foreach ($this as $i => $v) { - $value = $selector($v, $i); - if (!$found || $value < $minValue) { - $minValue = $value; - $found = true; - } - } - - if (!$found) { - throw new NoSuchElementException('Collection is empty'); - } - - return $minValue; - } - - /** {@inheritDoc} */ - public function minOfOrNull(Closure $selector): mixed - { - try { - return $this->minOf($selector); - } catch (NoSuchElementException) { - return null; - } - } - - /** {@inheritDoc} */ - public function maxOf(Closure $selector): mixed - { - $maxValue = null; - $found = false; - - foreach ($this as $i => $v) { - $value = $selector($v, $i); - if (!$found || $value > $maxValue) { - $maxValue = $value; - $found = true; - } - } - - if (!$found) { - throw new NoSuchElementException('Collection is empty'); - } - - return $maxValue; - } - - /** {@inheritDoc} */ - public function maxOfOrNull(Closure $selector): mixed - { - try { - return $this->maxOf($selector); - } catch (NoSuchElementException) { - return null; - } - } - - /** {@inheritDoc} */ - public function joinToString(string $separator = ', ', string $prefix = '', string $postfix = '', int $limit = -1, string $truncated = '...', ?Closure $transform = null): string - { - $parts = []; - $i = 0; - foreach ($this as $v) { - if ($limit >= 0 && $i >= $limit) { - $parts[] = $truncated; - break; - } - - if ($transform !== null) { - $parts[] = $transform($v, $i); - } elseif (is_scalar($v) || $v === null || $v instanceof Stringable) { - $parts[] = (string) $v; - } else { - throw new ConversionException(sprintf( - 'Value of type "%s" at index %d cannot be converted to string. Provide a $transform closure to resolve.', - get_debug_type($v), - $i - )); - } - - $i++; - } - - return $prefix . implode($separator, $parts) . $postfix; - } - /** {@inheritDoc} */ #[NoDiscard] public function countBy(Closure $keySelector): ImmutableMap diff --git a/src/Exception/UnsupportedOperationException.php b/src/Exception/UnsupportedOperationException.php index a9a6d2d..354412b 100644 --- a/src/Exception/UnsupportedOperationException.php +++ b/src/Exception/UnsupportedOperationException.php @@ -10,6 +10,8 @@ namespace Noctud\Collection\Exception; use LogicException; +use Noctud\Collection\Collection; +use Noctud\Collection\Sequence\Sequence; /** * Thrown when a mutating method is called on an immutable collection/map or when a @@ -17,4 +19,22 @@ */ class UnsupportedOperationException extends LogicException { + use NamesItsSubject; + + /** + * @param Collection|Sequence $subject + */ + public static function cannotReduceEmptySubject(Collection|Sequence $subject): self + { + return new self(sprintf('Cannot reduce empty %s', lcfirst(self::subjectName($subject)))); + } + + /** + * @param Collection|Sequence $subject + */ + public static function cannotAverageEmptySubject(Collection|Sequence $subject): self + { + // Mid-sentence, so the noun is lowercased - which leaves the eager message untouched. + return new self(sprintf('Cannot compute average of empty %s', lcfirst(self::subjectName($subject)))); + } } diff --git a/src/IterableTerminalsLogic.php b/src/IterableTerminalsLogic.php index a0f5319..0b5b062 100644 --- a/src/IterableTerminalsLogic.php +++ b/src/IterableTerminalsLogic.php @@ -10,7 +10,10 @@ namespace Noctud\Collection; use Closure; +use Noctud\Collection\Exception\ConversionException; use Noctud\Collection\Exception\NoSuchElementException; +use Noctud\Collection\Exception\UnsupportedOperationException; +use Stringable; /** * Terminal operations implemented by walking $this and nothing else, shared by the eager @@ -152,4 +155,302 @@ public function countWhere(Closure $predicate): int return $count; } + + // --- Aggregation --- + + /** {@inheritDoc} */ + public function fold(mixed $initial, Closure $operation): mixed + { + $acc = $initial; + foreach ($this as $v) { + $acc = $operation($acc, $v); + } + return $acc; + } + + /** {@inheritDoc} */ + public function reduce(Closure $operation): mixed + { + $first = true; + $acc = null; + + foreach ($this as $v) { + if ($first) { + $acc = $v; + $first = false; + } else { + $acc = $operation($acc, $v); + } + } + + if ($first) { + throw UnsupportedOperationException::cannotReduceEmptySubject($this); + } + + return $acc; + } + + /** + * {@inheritDoc} + * + * Not a try-catch around reduce(): the operation is user code, and an + * UnsupportedOperationException raised inside it is a real error, not an answer to this + * question. An empty subject needs no flag here - the accumulator simply stays null. + */ + public function reduceOrNull(Closure $operation): mixed + { + $first = true; + $acc = null; + + foreach ($this as $v) { + if ($first) { + $acc = $v; + $first = false; + } else { + $acc = $operation($acc, $v); + } + } + + return $acc; + } + + /** {@inheritDoc} */ + // @phpstan-ignore conditionalType.subjectNotFound, conditionalType.alwaysFalse (E is concrete in the extending fixtures and a MapEntry in MapEntrySet: either way the conditional is already decided) + public function sum(?Closure $selector = null): int|float + { + $sum = 0; + foreach ($this as $i => $v) { + $sum += $selector !== null ? $selector($v, $i) : $v; // @phpstan-ignore assignOp.invalid + } + + return $sum; + } + + /** {@inheritDoc} */ + public function avg(?Closure $selector = null): float + { + return $this->avgOrNull($selector) ?? throw UnsupportedOperationException::cannotAverageEmptySubject($this); + } + + /** {@inheritDoc} */ + public function avgOrNull(?Closure $selector = null): float|null + { + $sum = 0; + $count = 0; + foreach ($this as $i => $v) { + $sum += $selector !== null ? $selector($v, $i) : $v; // @phpstan-ignore assignOp.invalid + $count++; + } + + return $count > 0 ? $sum / $count : null; + } + + /** {@inheritDoc} */ + public function min(?Closure $selector = null): mixed + { + $minValue = null; + $minElement = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector !== null ? $selector($v, $i) : $v; + if (!$found || $value < $minValue) { + $minValue = $value; + $minElement = $v; + $found = true; + } + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $minElement; + } + + /** + * {@inheritDoc} + * + * Not a try-catch around min(): the selector is user code, and a NoSuchElementException + * raised inside it is a real error, not an answer to this question. + */ + public function minOrNull(?Closure $selector = null): mixed + { + $minValue = null; + $minElement = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector !== null ? $selector($v, $i) : $v; + if (!$found || $value < $minValue) { + $minValue = $value; + $minElement = $v; + $found = true; + } + } + + return $minElement; + } + + /** {@inheritDoc} */ + public function max(?Closure $selector = null): mixed + { + $maxValue = null; + $maxElement = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector !== null ? $selector($v, $i) : $v; + if (!$found || $value > $maxValue) { + $maxValue = $value; + $maxElement = $v; + $found = true; + } + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $maxElement; + } + + /** + * {@inheritDoc} + * + * Not a try-catch around max(): the selector is user code, and a NoSuchElementException + * raised inside it is a real error, not an answer to this question. + */ + public function maxOrNull(?Closure $selector = null): mixed + { + $maxValue = null; + $maxElement = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector !== null ? $selector($v, $i) : $v; + if (!$found || $value > $maxValue) { + $maxValue = $value; + $maxElement = $v; + $found = true; + } + } + + return $maxElement; + } + + /** {@inheritDoc} */ + public function minOf(Closure $selector): mixed + { + $minValue = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector($v, $i); + if (!$found || $value < $minValue) { + $minValue = $value; + $found = true; + } + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $minValue; + } + + /** + * {@inheritDoc} + * + * Not a try-catch around minOf(): the selector is user code, and a NoSuchElementException + * raised inside it is a real error, not an answer to this question. + */ + public function minOfOrNull(Closure $selector): mixed + { + $best = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector($v, $i); + if (!$found || $value < $best) { + $best = $value; + $found = true; + } + } + + return $best; + } + + /** {@inheritDoc} */ + public function maxOf(Closure $selector): mixed + { + $maxValue = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector($v, $i); + if (!$found || $value > $maxValue) { + $maxValue = $value; + $found = true; + } + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $maxValue; + } + + /** + * {@inheritDoc} + * + * Not a try-catch around maxOf(): the selector is user code, and a NoSuchElementException + * raised inside it is a real error, not an answer to this question. + */ + public function maxOfOrNull(Closure $selector): mixed + { + $best = null; + $found = false; + + foreach ($this as $i => $v) { + $value = $selector($v, $i); + if (!$found || $value > $best) { + $best = $value; + $found = true; + } + } + + return $best; + } + + /** {@inheritDoc} */ + public function joinToString(string $separator = ', ', string $prefix = '', string $postfix = '', int $limit = -1, string $truncated = '...', ?Closure $transform = null): string + { + $parts = []; + $i = 0; + foreach ($this as $v) { + if ($limit >= 0 && $i >= $limit) { + $parts[] = $truncated; + break; + } + + if ($transform !== null) { + $parts[] = $transform($v, $i); + } elseif (is_scalar($v) || $v === null || $v instanceof Stringable) { + $parts[] = (string) $v; + } else { + throw new ConversionException(sprintf( + 'Value of type "%s" at index %d cannot be converted to string. Provide a $transform closure to resolve.', + get_debug_type($v), + $i + )); + } + + $i++; + } + + return $prefix . implode($separator, $parts) . $postfix; + } } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index dbb9c6d..a11c693 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -11,10 +11,12 @@ use Closure; use IteratorAggregate; +use Noctud\Collection\Exception\ConversionException; use Noctud\Collection\Exception\IndexOutOfBoundsException; use Noctud\Collection\Exception\InvalidSequenceSourceException; use Noctud\Collection\Exception\NonReplayableSourceException; use Noctud\Collection\Exception\NoSuchElementException; +use Noctud\Collection\Exception\UnsupportedOperationException; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\Set\ImmutableSet; use NoDiscard; @@ -418,6 +420,188 @@ public function count(): int; */ public function countWhere(Closure $predicate): int; + // --- Aggregation --- + + /** + * Left fold. Accumulates a result starting from the initial value by applying the operation + * to each element sequentially. Drains the sequence. + * + * @template R + * @param R $initial + * @param Closure(R, E):R $operation + * @return R + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function fold(mixed $initial, Closure $operation): mixed; + + /** + * Reduce with a binary operation, draining the sequence. + * + * @param Closure(E, E):E $operation + * @return E + * @throws UnsupportedOperationException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function reduce(Closure $operation); + + /** + * Reduces the sequence using a binary operation, or returns null if it is empty. + * Drains the sequence. + * + * @param Closure(E, E):E $operation + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function reduceOrNull(Closure $operation): mixed; + + /** + * Returns the sum of all elements or values returned by the selector. Drains the sequence. + * + * @template TSum + * @param (Closure(E, int):TSum)|null $selector + * @return ($selector is null ? (E is int ? int : int|float) : (TSum is int ? int : int|float)) + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function sum(?Closure $selector = null): int|float; + + /** + * Returns the average of all elements or values returned by the selector, draining the + * sequence. Throws if it is empty. + * + * @param Closure(E, int):(int|float)|null $selector + * @throws UnsupportedOperationException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function avg(?Closure $selector = null): float; + + /** + * Returns the average of all elements or values returned by the selector, or null if the + * sequence is empty. Drains the sequence. + * + * @param Closure(E, int):(int|float)|null $selector + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function avgOrNull(?Closure $selector = null): float|null; + + /** + * Returns the element with the minimum value, draining the sequence. + * When a selector is given, returns the element whose selector value is minimum. + * + * @param Closure(E, int):mixed|null $selector + * @return E + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function min(?Closure $selector = null): mixed; + + /** + * Returns the element with the minimum value, or null if the sequence is empty. + * Drains the sequence. + * + * @param Closure(E, int):mixed|null $selector + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function minOrNull(?Closure $selector = null): mixed; + + /** + * Returns the element with the maximum value, draining the sequence. + * When a selector is given, returns the element whose selector value is maximum. + * + * @param Closure(E, int):mixed|null $selector + * @return E + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function max(?Closure $selector = null): mixed; + + /** + * Returns the element with the maximum value, or null if the sequence is empty. + * Drains the sequence. + * + * @param Closure(E, int):mixed|null $selector + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function maxOrNull(?Closure $selector = null): mixed; + + /** + * Returns the minimum value produced by the selector, draining the sequence. + * + * @template R of mixed + * @param Closure(E, int):R $selector + * @return R + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function minOf(Closure $selector): mixed; + + /** + * Returns the minimum value produced by the selector, or null if the sequence is empty. + * Drains the sequence. + * + * @template R of mixed + * @param Closure(E, int):R $selector + * @return R|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function minOfOrNull(Closure $selector): mixed; + + /** + * Returns the maximum value produced by the selector, draining the sequence. + * + * @template R of mixed + * @param Closure(E, int):R $selector + * @return R + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function maxOf(Closure $selector): mixed; + + /** + * Returns the maximum value produced by the selector, or null if the sequence is empty. + * Drains the sequence. + * + * @template R of mixed + * @param Closure(E, int):R $selector + * @return R|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function maxOfOrNull(Closure $selector): mixed; + + /** + * Joins elements into a string with the given separator, prefix, postfix, and optional + * transform. + * + * The one aggregation that can stop early: a non-negative $limit stops pulling once that many + * elements have been joined, so joining the head of a long stream costs only that head. + * Without a limit it drains. + * + * When no transform is provided, elements are converted to strings using (string) cast. + * Scalars, null, and Stringable objects are supported. Non-stringable objects and arrays + * will throw a ConversionException. + * + * @param Closure(E, int):string|null $transform Optional transform to apply to each element + * @throws ConversionException When an element cannot be converted to string and no transform is provided + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function joinToString(string $separator = ', ', string $prefix = '', string $postfix = '', int $limit = -1, string $truncated = '...', ?Closure $transform = null): string; + // --- Conversion --- /** diff --git a/tests/Collection/CollectionAggregate.php b/tests/Collection/CollectionAggregate.php index 2f54c3c..3f61cde 100644 --- a/tests/Collection/CollectionAggregate.php +++ b/tests/Collection/CollectionAggregate.php @@ -16,6 +16,36 @@ trait CollectionAggregate { + #[Test] + public function minOrNull_propagates_a_NoSuchElementException_thrown_by_the_selector(): void + { + // The selector is user code - one reaching into an empty collection of its own raises this. + // Catching it here would report the collection as empty when it is not. + $collection = $this->collectionOf([1, 2]); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('from the selector'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = $collection->minOrNull(static function (): int { + throw new NoSuchElementException('from the selector'); + }); + } + + #[Test] + public function reduceOrNull_propagates_an_UnsupportedOperationException_thrown_by_the_operation(): void + { + $collection = $this->collectionOf([1, 2]); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('from the operation'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = $collection->reduceOrNull(static function (): int { + throw new UnsupportedOperationException('from the operation'); + }); + } + #[Test] public function sum_returns_zero_for_empty_collection(): void { diff --git a/tests/Sequence/SequenceAggregateTest.php b/tests/Sequence/SequenceAggregateTest.php new file mode 100644 index 0000000..ce3132b --- /dev/null +++ b/tests/Sequence/SequenceAggregateTest.php @@ -0,0 +1,290 @@ +assertSame(10, sequenceOf([1, 2, 3, 4])->fold(0, static fn (int $acc, int $v): int => $acc + $v)); + $this->assertSame('start', sequenceOf([])->fold('start', static fn (string $acc): string => $acc . '!')); + } + + #[Test] + public function reduce_folds_from_the_first_element(): void + { + $this->assertSame(24, sequenceOf([1, 2, 3, 4])->reduce(static fn (int $a, int $b): int => $a * $b)); + } + + #[Test] + public function reduce_throws_on_an_empty_sequence(): void + { + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('Cannot reduce empty sequence'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([])->reduce(static fn (int $a, int $b): int => $a + $b); + } + + #[Test] + public function reduceOrNull_returns_null_on_an_empty_sequence(): void + { + $this->assertSame(10, sequenceOf([1, 2, 3, 4])->reduceOrNull(static fn (int $a, int $b): int => $a + $b)); + + // Emptied by a filter rather than empty at the source: the realistic way a pipeline ends up + // with nothing, and it keeps a real element type instead of never. + $this->assertNull($this->emptied()->reduceOrNull(static fn (int $a, int $b): int => $a + $b)); + } + + #[Test] + public function reduceOrNull_propagates_an_UnsupportedOperationException_thrown_by_the_operation(): void + { + // The operation is user code: the exception it raises is a real error, not this method's + // answer for an empty sequence. + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('from the operation'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([1, 2])->reduceOrNull(static function (): int { + throw new UnsupportedOperationException('from the operation'); + }); + } + + #[Test] + public function sum_adds_elements_or_selector_values(): void + { + $this->assertSame(6, sequenceOf([1, 2, 3])->sum()); + $this->assertSame(0, sequenceOf([])->sum()); + $this->assertSame(12, sequenceOf([1, 2, 3])->sum(static fn (int $v): int => $v * 2)); + $this->assertEqualsWithDelta(4.5, sequenceOf([1.5, 3.0])->sum(), 0.0001); + } + + #[Test] + public function avg_and_avgOrNull_average_the_sequence(): void + { + $this->assertEqualsWithDelta(2.5, sequenceOf([1, 2, 3, 4])->avg(), 0.0001); + $this->assertEqualsWithDelta(5.0, sequenceOf([1, 2, 3, 4])->avg(static fn (int $v): int => $v * 2), 0.0001); + $this->assertEqualsWithDelta(2.5, sequenceOf([1, 2, 3, 4])->avgOrNull(), 0.0001); + $this->assertNull(sequenceOf([])->avgOrNull()); + } + + #[Test] + public function avg_throws_on_an_empty_sequence(): void + { + // The noun is derived from the subject, so a sequence is not told a "collection" is empty. + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('Cannot compute average of empty sequence'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([])->avg(); + } + + #[Test] + public function min_and_max_return_the_extreme_element(): void + { + $data = [3, 1, 4, 1, 5]; + + $this->assertSame(1, sequenceOf($data)->min()); + $this->assertSame(5, sequenceOf($data)->max()); + $this->assertSame(1, sequenceOf($data)->minOrNull()); + $this->assertSame(5, sequenceOf($data)->maxOrNull()); + } + + #[Test] + public function min_and_max_use_the_selector_to_pick_the_element(): void + { + $words = ['bbb', 'a', 'cc']; + $length = static fn (string $v): int => strlen($v); + + // The element comes back, not its selector value - that is what minOf is for. + $this->assertSame('a', sequenceOf($words)->min($length)); + $this->assertSame('bbb', sequenceOf($words)->max($length)); + $this->assertSame(1, sequenceOf($words)->minOf($length)); + $this->assertSame(3, sequenceOf($words)->maxOf($length)); + } + + #[Test] + public function the_extremes_throw_on_an_empty_sequence(): void + { + foreach (['min', 'max'] as $method) { + try { + $this->emptied()->{$method}(); + $this->fail("{$method}() should have thrown"); + } catch (NoSuchElementException $e) { + $this->assertStringContainsString('Sequence is empty', $e->getMessage()); + } + } + + foreach (['minOf', 'maxOf'] as $method) { + try { + $this->emptied()->{$method}(static fn (int $v): int => $v); + $this->fail("{$method}() should have thrown"); + } catch (NoSuchElementException $e) { + $this->assertStringContainsString('Sequence is empty', $e->getMessage()); + } + } + } + + #[Test] + public function the_OrNull_extremes_return_null_on_an_empty_sequence(): void + { + $this->assertNull($this->emptied()->minOrNull()); + $this->assertNull($this->emptied()->maxOrNull()); + $this->assertNull($this->emptied()->minOfOrNull(static fn (int $v): int => $v)); + $this->assertNull($this->emptied()->maxOfOrNull(static fn (int $v): int => $v)); + } + + #[Test] + public function the_OrNull_extremes_propagate_a_NoSuchElementException_thrown_by_the_selector(): void + { + // A selector reaching into an empty collection of its own raises this; swallowing it would + // report the sequence as empty when it is not. + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('from the selector'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([1, 2])->minOrNull(static function (): int { + throw new NoSuchElementException('from the selector'); + }); + } + + #[Test] + public function joinToString_joins_with_separator_prefix_and_postfix(): void + { + $this->assertSame('1, 2, 3', sequenceOf([1, 2, 3])->joinToString()); + $this->assertSame('[1-2-3]', sequenceOf([1, 2, 3])->joinToString('-', '[', ']')); + $this->assertSame('', sequenceOf([])->joinToString()); + } + + #[Test] + public function joinToString_applies_the_transform_and_the_limit(): void + { + $this->assertSame('a1, b2', sequenceOf(['a', 'b'])->joinToString(transform: static fn (string $v, int $i): string => $v . ($i + 1))); + $this->assertSame('1, 2, …', sequenceOf([1, 2, 3, 4])->joinToString(limit: 2, truncated: '…')); + } + + #[Test] + public function joinToString_throws_on_an_unconvertible_element(): void + { + $this->expectException(ConversionException::class); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([new stdClass()])->joinToString(); + } + + #[Test] + public function aggregation_matches_its_collection_counterpart(): void + { + $data = [3, 1, 4, 1, 5]; + $double = static fn (int $v): int => $v * 2; + + $this->assertSame(listOf($data)->fold(0, static fn (int $a, int $b): int => $a + $b), sequenceOf($data)->fold(0, static fn (int $a, int $b): int => $a + $b)); + $this->assertSame(listOf($data)->reduce(static fn (int $a, int $b): int => $a + $b), sequenceOf($data)->reduce(static fn (int $a, int $b): int => $a + $b)); + $this->assertSame(listOf($data)->sum(), sequenceOf($data)->sum()); + $this->assertSame(listOf($data)->sum($double), sequenceOf($data)->sum($double)); + $this->assertSame(listOf($data)->avg(), sequenceOf($data)->avg()); + $this->assertSame(listOf($data)->min(), sequenceOf($data)->min()); + $this->assertSame(listOf($data)->max(), sequenceOf($data)->max()); + $this->assertSame(listOf($data)->minOf($double), sequenceOf($data)->minOf($double)); + $this->assertSame(listOf($data)->maxOf($double), sequenceOf($data)->maxOf($double)); + $this->assertSame(listOf($data)->joinToString('|'), sequenceOf($data)->joinToString('|')); + } + + #[Test] + public function joinToString_stops_pulling_at_the_limit(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertSame('1, 2, ...', $sequence->joinToString(limit: 2)); + + // The third element is what proves the limit was reached; nothing beyond it is pulled. + $this->assertSame([1, 2, 3], $pulled); + } + + #[Test] + public function the_other_aggregations_drain_the_source(): void + { + foreach (['sum', 'min', 'max'] as $method) { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $sequence->{$method}(); + + $this->assertSame([1, 2, 3, 4], $pulled, "{$method}() should drain"); + } + } + + #[Test] + public function an_aggregation_consumes_a_pass_of_a_one_shot_source(): void + { + $sequence = sequenceOf((static function (): Generator { + yield 1; + yield 2; + })()); + + $this->assertSame(3, $sequence->sum()); + + $this->expectException(NonReplayableSourceException::class); + + $sequence->max(); + } + + #[Test] + public function aggregation_replays_over_a_replayable_source(): void + { + $sequence = sequenceOf([1, 2, 3]); + + $this->assertSame(6, $sequence->sum()); + $this->assertSame(3, $sequence->max()); + $this->assertSame(6, $sequence->sum()); + } + + /** + * A sequence emptied by a filter rather than at the source, which keeps its element type int + * instead of never - so that a terminal's result stays genuinely uncertain to the analyser. + * + * @return Sequence + */ + private function emptied(): Sequence + { + return sequenceOf([1, 2])->filter(static fn (int $v): bool => $v > 9); + } + + /** + * A one-shot source logging what the terminal pulls out of it. + * + * @param list $pulled + * @return Sequence + */ + private function loggingSequence(array &$pulled): Sequence + { + return sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + } +} diff --git a/tests/Type/Sequence/AggregationTypeTest.php b/tests/Type/Sequence/AggregationTypeTest.php new file mode 100644 index 0000000..e5bbfbe --- /dev/null +++ b/tests/Type/Sequence/AggregationTypeTest.php @@ -0,0 +1,47 @@ + $s */ +$s = sequenceOf([1, 2, 3]); + +// fold: the return type follows the initial accumulator type R. +assertType('int', $s->fold(0, fn (int $acc, int $x): int => $acc + $x)); +assertType('string', $s->fold('', fn (string $acc, int $x): string => $acc . $x)); + +assertType('int', $s->reduce(fn (int $acc, int $x): int => $acc + $x)); +assertType('int|null', $s->reduceOrNull(fn (int $acc, int $x): int => $acc + $x)); + +// min/max hand back the element type E, declared `: mixed`, not a fixed scalar. +assertType('int', $s->min()); +assertType('int|null', $s->minOrNull()); +assertType('int', $s->max()); +assertType('int|null', $s->maxOrNull()); + +// minOf/maxOf hand back the selector's type R instead of mixed. +$strings = sequenceOf(['a', 'bb']); +assertType('int', $strings->minOf(fn (string $x): int => (int) $x)); +assertType('float', $strings->maxOf(fn (string $x): float => (float) $x)); +assertType('int|null', $strings->minOfOrNull(fn (string $x): int => (int) $x)); +assertType('int|null', $strings->maxOfOrNull(fn (string $x): int => (int) $x)); + +// sum: the conditional return type narrows to int for a sequence of ints, and widens as soon as +// a float is involved - on either side of the condition. +assertType('int', sequenceOf([1, 2, 3])->sum()); +assertType('float|int', sequenceOf([1.0, 2.0])->sum()); +assertType('int', sequenceOf([1, 2, 3])->sum(fn (int $x): int => $x * 2)); +assertType('float|int', sequenceOf([1, 2, 3])->sum(fn (int $x): float => $x / 2)); + +// The element type follows the pipeline rather than the source. +assertType('float|int', sequenceOf([1, 2, 3])->map(fn (int $x): float => $x * 1.5)->sum()); From 5aa3aa51d7a8ab4084728b7e5336dbf51f9b1b45 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Wed, 16 Sep 2026 22:25:24 +0200 Subject: [PATCH 2/2] Address review on sequence aggregation terminals joinToString() now appends to a string as it walks instead of collecting every part and imploding at the end: on a sequence over a large file the parts array was a second copy of the whole input. Its docblock also says what the limit really costs - $limit + 1 pulls, the extra element being what decides whether $truncated applies. Tests: the OrNull extremes propagation test covers all four methods, the drain test covers every aggregation rather than three, and the reduceOrNull propagation parity test lives with the other reduce tests. --- src/IterableTerminalsLogic.php | 14 +++++--- src/Sequence/Sequence.php | 6 ++-- tests/Collection/CollectionAggregate.php | 14 -------- tests/Collection/CollectionReduce.php | 14 ++++++++ tests/Sequence/SequenceAggregateTest.php | 44 ++++++++++++++++++------ 5 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/IterableTerminalsLogic.php b/src/IterableTerminalsLogic.php index 0b5b062..28d31fe 100644 --- a/src/IterableTerminalsLogic.php +++ b/src/IterableTerminalsLogic.php @@ -428,18 +428,22 @@ public function maxOfOrNull(Closure $selector): mixed /** {@inheritDoc} */ public function joinToString(string $separator = ', ', string $prefix = '', string $postfix = '', int $limit = -1, string $truncated = '...', ?Closure $transform = null): string { - $parts = []; + $result = $prefix; $i = 0; foreach ($this as $v) { + if ($i > 0) { + $result .= $separator; + } + if ($limit >= 0 && $i >= $limit) { - $parts[] = $truncated; + $result .= $truncated; break; } if ($transform !== null) { - $parts[] = $transform($v, $i); + $result .= $transform($v, $i); } elseif (is_scalar($v) || $v === null || $v instanceof Stringable) { - $parts[] = (string) $v; + $result .= (string) $v; } else { throw new ConversionException(sprintf( 'Value of type "%s" at index %d cannot be converted to string. Provide a $transform closure to resolve.', @@ -451,6 +455,6 @@ public function joinToString(string $separator = ', ', string $prefix = '', stri $i++; } - return $prefix . implode($separator, $parts) . $postfix; + return $result . $postfix; } } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index a11c693..f0d868b 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -587,9 +587,9 @@ public function maxOfOrNull(Closure $selector): mixed; * Joins elements into a string with the given separator, prefix, postfix, and optional * transform. * - * The one aggregation that can stop early: a non-negative $limit stops pulling once that many - * elements have been joined, so joining the head of a long stream costs only that head. - * Without a limit it drains. + * The one aggregation that can stop early: a non-negative $limit stops pulling after + * $limit + 1 elements - the extra one tells whether $truncated applies - so joining the head + * of a long stream costs only that head. Without a limit it drains. * * When no transform is provided, elements are converted to strings using (string) cast. * Scalars, null, and Stringable objects are supported. Non-stringable objects and arrays diff --git a/tests/Collection/CollectionAggregate.php b/tests/Collection/CollectionAggregate.php index 3f61cde..7befee3 100644 --- a/tests/Collection/CollectionAggregate.php +++ b/tests/Collection/CollectionAggregate.php @@ -32,20 +32,6 @@ public function minOrNull_propagates_a_NoSuchElementException_thrown_by_the_sele }); } - #[Test] - public function reduceOrNull_propagates_an_UnsupportedOperationException_thrown_by_the_operation(): void - { - $collection = $this->collectionOf([1, 2]); - - $this->expectException(UnsupportedOperationException::class); - $this->expectExceptionMessageIsOrContains('from the operation'); - - // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable - $_ = $collection->reduceOrNull(static function (): int { - throw new UnsupportedOperationException('from the operation'); - }); - } - #[Test] public function sum_returns_zero_for_empty_collection(): void { diff --git a/tests/Collection/CollectionReduce.php b/tests/Collection/CollectionReduce.php index 957e5da..5d77fb8 100644 --- a/tests/Collection/CollectionReduce.php +++ b/tests/Collection/CollectionReduce.php @@ -84,4 +84,18 @@ public function reduceOrNull_returns_result(): void $collection = $this->collectionOf([1, 2, 3]); $this->assertSame(6, $collection->reduceOrNull(fn ($acc, $v) => $acc + $v)); } + + #[Test] + public function reduceOrNull_propagates_an_UnsupportedOperationException_thrown_by_the_operation(): void + { + $collection = $this->collectionOf([1, 2]); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('from the operation'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = $collection->reduceOrNull(static function (): int { + throw new UnsupportedOperationException('from the operation'); + }); + } } diff --git a/tests/Sequence/SequenceAggregateTest.php b/tests/Sequence/SequenceAggregateTest.php index ce3132b..d1cd4f2 100644 --- a/tests/Sequence/SequenceAggregateTest.php +++ b/tests/Sequence/SequenceAggregateTest.php @@ -159,13 +159,18 @@ public function the_OrNull_extremes_propagate_a_NoSuchElementException_thrown_by { // A selector reaching into an empty collection of its own raises this; swallowing it would // report the sequence as empty when it is not. - $this->expectException(NoSuchElementException::class); - $this->expectExceptionMessageIsOrContains('from the selector'); - - // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable - $_ = sequenceOf([1, 2])->minOrNull(static function (): int { + $selector = static function (): int { throw new NoSuchElementException('from the selector'); - }); + }; + + foreach (['minOrNull', 'maxOrNull', 'minOfOrNull', 'maxOfOrNull'] as $method) { + try { + sequenceOf([1, 2])->{$method}($selector); + $this->fail("{$method}() should have propagated the exception"); + } catch (NoSuchElementException $e) { + $this->assertStringContainsString('from the selector', $e->getMessage()); + } + } } #[Test] @@ -225,11 +230,30 @@ public function joinToString_stops_pulling_at_the_limit(): void #[Test] public function the_other_aggregations_drain_the_source(): void { - foreach (['sum', 'min', 'max'] as $method) { - $pulled = []; - $sequence = $this->loggingSequence($pulled); + $add = static fn (int $a, int $b): int => $a + $b; + $double = static fn (int $v): int => $v * 2; - $sequence->{$method}(); + $aggregations = [ + 'fold' => [0, $add], + 'reduce' => [$add], + 'reduceOrNull' => [$add], + 'sum' => [], + 'avg' => [], + 'avgOrNull' => [], + 'min' => [], + 'max' => [], + 'minOrNull' => [], + 'maxOrNull' => [], + 'minOf' => [$double], + 'maxOf' => [$double], + 'minOfOrNull' => [$double], + 'maxOfOrNull' => [$double], + 'joinToString' => [], + ]; + + foreach ($aggregations as $method => $arguments) { + $pulled = []; + $this->loggingSequence($pulled)->{$method}(...$arguments); $this->assertSame([1, 2, 3, 4], $pulled, "{$method}() should drain"); }