Skip to content

[Sequence] Add "terminal" methods: aggregation - #32

Merged
delacry merged 2 commits into
noctud:0.2.xfrom
nikophil:feature/sequence-aggregation
Sep 17, 2026
Merged

delacry merged 2 commits into
noctud:0.2.xfrom
nikophil:feature/sequence-aggregation

Conversation

@nikophil

@nikophil nikophil commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Third of four PRs bringing the terminal vocabulary to Sequence — element access
landed in #29, querying in #31, aggregation here, then forEach/toMap. This adds
fold, reduce/reduceOrNull, sum, avg/avgOrNull, min/max (+ OrNull),
minOf/maxOf (+ OrNull) and joinToString.

The whole batch is shared

#29 settled that a body identical on both sides lives in IterableTerminalsLogic
rather than being copied, and #31 split five methods off that way. This time
nothing splits: 232 lines leave CollectionLogic for the trait and
SequenceLogic gains not a single line — both sides already use the trait, so
the sequence picks these up by declaring them on the interface.

That is not a coincidence. What kept isEmpty, contains and count per side in
#31 was the store: the eager side answers them in O(1) where a sequence has to
pull. An aggregation has no such shortcut — sum, min, avg and friends have to
visit every element whatever they are backed by, so the eager body is the lazy
body. Collection behaviour is unchanged, and its existing suite is what proves
it.

The OrNull variants stop swallowing the caller's exceptions

This is the one behavioural change, and it touches Collection too. The eager
OrNull methods were try-catch wrappers:

public function reduceOrNull(Closure $operation): mixed
{
    try {
        return $this->reduce($operation);
    } catch (UnsupportedOperationException) {
        return null;
    }
}

The catch cannot tell its own empty-subject throw from one raised inside
$operation, which is user code. So reduceOrNull() reported null — "the
subject was empty" — for a subject that was not empty at all, and the same held for
minOrNull/maxOrNull/minOfOrNull/maxOfOrNull against a selector raising
NoSuchElementException. Rare, but it turns a real error into a plausible answer,
which is the failure mode worth spending code on.

Each OrNull now walks on its own and returns null when nothing was found — no
flag needed, the accumulator simply stays null. Two parity tests on the eager
side pin the propagation
(reduceOrNull_propagates_an_UnsupportedOperationException_thrown_by_the_operation,
minOrNull_propagates_a_NoSuchElementException_thrown_by_the_selector), and
the_OrNull_extremes_propagate_a_NoSuchElementException_thrown_by_the_selector
does the same on the lazy side. The duplication that buys is four short loops.

Empty-subject messages

UnsupportedOperationException gets cannotReduceEmptySubject() and
cannotAverageEmptySubject(), joining NoSuchElementException::emptySubject() on
the NamesItsSubject trait — a sequence must not be told a "Collection" is empty.
lcfirst leaves avg()'s eager wording byte-identical; reduce() gains the
message it never carried (it threw a bare UnsupportedOperationException).

Laziness, pinned

Aggregations drain — that is what they are — but the tests say so out loud rather
than leaving it implied: the_other_aggregations_drain_the_source counts what the
source handed out, an_aggregation_consumes_a_pass_of_a_one_shot_source and
aggregation_replays_over_a_replayable_source hold the replayability contract, and
joinToString_stops_pulling_at_the_limit shows the one method here that
short-circuits: past $limit it appends $truncated and stops asking.
aggregation_matches_its_collection_counterpart asserts the eager and lazy answers
are the same values.

Types

tests/Type/Sequence/AggregationTypeTest.php covers what the PHPDoc promises:
fold returns the accumulator type R rather than E, min/max hand back E
(declared : mixed) while minOf/maxOf hand back the selector's R, and sum's
conditional return type narrows to int for a sequence of ints and widens to
float|int as soon as a float appears on either side — including when it appears
through a map() upstream rather than at the source.

#[NoDiscard]

None of these take it — they hand back a scalar or an element rather than a new
container, which is where #29 settled the rule.

Not in this batch

toMap() and forEach() are the fourth PR. findLast/expectLast and
random/randomOrNull are still open questions on #23 rather than omissions.

Related to #23, follows #31.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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 noctud#29 drew and noctud#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 noctud#29 put the attribute.

Claude-Session: https://claude.ai/code/session_01JCXbhGog8jh6JAeYSCjy9y
@nikophil
nikophil force-pushed the feature/sequence-aggregation branch from 31f8ca7 to 46f3c96 Compare August 24, 2026 11:16
@nikophil
nikophil marked this pull request as ready for review August 24, 2026 11:26
@delacry

delacry commented Sep 9, 2026

Copy link
Copy Markdown
Member

Hi, sorry for the late response, I was on a vacation, and then I needed another vacation from my vacation 😄 so I'll try to look at it by the end of this week.

@nikophil

Copy link
Copy Markdown
Contributor Author

Haha no rush, hope vacation were good

Comment thread src/IterableTerminalsLogic.php
Comment thread tests/Sequence/SequenceAggregateTest.php
Comment thread tests/Sequence/SequenceAggregateTest.php Outdated
Comment thread src/Sequence/Sequence.php Outdated
Comment thread src/Sequence/Sequence.php
Comment thread tests/Collection/CollectionAggregate.php Outdated
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.
@nikophil
nikophil requested a review from delacry September 16, 2026 20:29

@delacry delacry left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@delacry
delacry merged commit 9917f6d into noctud:0.2.x Sep 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants