Skip to content

[Sequence] Creation: Collection::asSequence() and the constrainOnce flag - #40

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

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

Conversation

@nikophil

@nikophil nikophil commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Two creation APIs from the #23 checklist: Collection::asSequence() (§5) and the constrainOnce flag on sequenceOf() (§4). They travel together because they answer the same question — how a sequence comes into being — and the remaining factories (generateSequence, emptySequence) follow in the next one.

Collection::asSequence()

Eight lines, because the machinery was already there:

public function asSequence(): Sequence
{
    return sequenceOf($this);
}

A Collection is an IteratorAggregate, which GeneratorSequence already treats as a producer asked for an iterable once per pass. Every element store returns a fresh ArrayIterator from getIterator(), so the identity guard of resolveProducedIterable() is satisfied without a special case. No copy, no wrapper class, no new branch in the replayability contract.

What that buys is a live view: each pass re-reads the collection rather than a snapshot taken at call time. asSequence_reads_the_collection_on_each_pass pins it on the mutable side, and its immutable branch pins the counterpart — add() returns a new collection the existing sequence does not see.

The laziness test is phrased as "no read of its own" rather than "no read at all", on purpose: view collections such as MapKeySet or MapEntrySet read their source when they are built, well before asSequence() is called. What is under test is that asSequence() and the operation chained onto it add nothing to that count.

Both live in the shared CollectionConvert / CollectionMutateWrite traits, so they run against every Collection implementation — 14 of them.

constrainOnce

$rows = sequenceOf(fn () => $qb->getQuery()->toIterable(), constrainOnce: true);
$rows->toArray();   // ok
$rows->toArray();   // NonReplayableSourceException

A constructor flag rather than a constrainOnce() method, as agreed on #23: one class, one boolean, no wrapper sequence to explain.

The guard sits before the per-source-kind branches of resolveSourceForThisPass():

if ($this->constrainOnce) {
    if ($this->consumed) {
        throw NonReplayableSourceException::constrainedOnceSequenceAlreadyIterated();
    }

    $this->consumed = true;

    // this sequence has exactly one pass, so the per-kind guards below have nothing
    // left to protect: the source is handed over as it is
    return $source instanceof Closure ? $this->produceFromClosure($source) : $source;
}

That position is the point: the identity check and the WeakReference exist to keep a replay honest, and a constrained sequence has no second pass to keep honest. The closure resolution was duplicated between the two paths, so it comes out as produceFromClosure().

The exception gets its own named constructor. The existing message speaks of a source that is non-replayable, which would be untrue of an array the caller chose to constrain, and a reader chasing that message would look in the wrong place.

A chained operation needs no propagation: the derived sequence does not carry the flag, but its factory re-reads the root on every pass, so the root is what throws — constrained_once_throws_through_a_chained_operation pins that. And like the existing one-shot rule, it throws at getIterator() rather than at the first advance, so a violation surfaces at the start of the offending pass instead of yielding a silent empty result.

Return a lazy Sequence view over any Collection. A Collection is an
IteratorAggregate, so GeneratorSequence already treats it as a replayable
producer asked for a fresh iterator on every pass: nothing is copied, nothing
is read before a terminal operation, and every pass sees the collection's
current elements.

Map needs nothing of its own: its keys, values and entries views are
Collections and inherit the method.

Add runtime coverage across every Collection implementation - order,
emptiness, replay, short-circuiting, laziness and the live view under
mutation - and a type assertion for the returned Sequence.
Force a sequence down to a single pass whatever its source: the second pass
throws NonReplayableSourceException instead of replaying an array or
re-invoking a producer. That is what a caller wants when replaying would
repeat a side effect, a producer closure firing a SQL query being the case
raised on noctud#23.

The guard sits before the per-source-kind branches of
resolveSourceForThisPass(): a constrained sequence has exactly one pass, so
the identity check that protects a replay has nothing left to protect.
Extract produceFromClosure() along the way, now shared by both paths.

Give the exception its own named constructor - the existing message speaks
of a non-replayable source, which would be untrue of a constrained array -
and document the flag on the Sequence contract next to the source kinds.
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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.

Thank you!

@delacry
delacry merged commit 9bdd59c into noctud:0.2.x Sep 26, 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