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..28d31fe 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,306 @@ 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 + { + $result = $prefix; + $i = 0; + foreach ($this as $v) { + if ($i > 0) { + $result .= $separator; + } + + if ($limit >= 0 && $i >= $limit) { + $result .= $truncated; + break; + } + + if ($transform !== null) { + $result .= $transform($v, $i); + } elseif (is_scalar($v) || $v === null || $v instanceof Stringable) { + $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.', + get_debug_type($v), + $i + )); + } + + $i++; + } + + return $result . $postfix; + } } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index dbb9c6d..f0d868b 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 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 + * 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..7befee3 100644 --- a/tests/Collection/CollectionAggregate.php +++ b/tests/Collection/CollectionAggregate.php @@ -16,6 +16,22 @@ 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 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 new file mode 100644 index 0000000..d1cd4f2 --- /dev/null +++ b/tests/Sequence/SequenceAggregateTest.php @@ -0,0 +1,314 @@ +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. + $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] + 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 + { + $add = static fn (int $a, int $b): int => $a + $b; + $double = static fn (int $v): int => $v * 2; + + $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"); + } + } + + #[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());