Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Exception/InvalidSequenceSourceException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Exception;

use LogicException;

/**
* Thrown when a sequence source closure returns a non-iterable value.
*/
final class InvalidSequenceSourceException extends LogicException
{
public static function closureReturnedNonIterable(mixed $produced): self
{
return new self(
sprintf('The sequence\'s source closure must return an iterable, got %s.', get_debug_type($produced)),
);
}
}
32 changes: 32 additions & 0 deletions src/Exception/SequenceAlreadyIteratedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Exception;

/**
* Thrown when a sequence backed by a non-replayable source is iterated a second time,
* or when a sequence source - a closure or an IteratorAggregate - hands back the same
* iterator instance twice.
*/
final class SequenceAlreadyIteratedException extends UnsupportedOperationException
{
public static function nonReplayableSourceAlreadyIterated(): self
{
return new self(
'This sequence is backed by a non-replayable source and has already been iterated. Create a new sequence from a fresh source to iterate again.',
);
}

public static function sourceReturnedSameIterator(): self
{
return new self(
'The sequence\'s source returned the same iterator instance again - a source closure or an IteratorAggregate must produce a fresh iterator on each pass.',
);
}
}
38 changes: 38 additions & 0 deletions src/Sequence/GeneratorSequence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Sequence;

use Closure;

/**
* Lazy sequence iterating through generators, whatever the source kind.
* If the given source is a Closure, it is a producer: invoked once per pass and expected
* to return a fresh iterable on each call.
*
* The class is final; if you want your own Sequence, use the SequenceLogic trait in your
* own class; this way you are not tied to our class hierarchy (you can extend your own
* base class).
*
* @template E
* @implements Sequence<E>
*/
final class GeneratorSequence implements Sequence
{
/** @use SequenceLogic<E> */
use SequenceLogic;

/**
* @param iterable<E>|Closure():iterable<E> $source
*/
public function __construct(iterable|Closure $source = [])
{
$this->source = $source;
}
}
94 changes: 94 additions & 0 deletions src/Sequence/Sequence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Sequence;

use Closure;
use IteratorAggregate;
use Noctud\Collection\List\ImmutableList;
use Noctud\Collection\Set\ImmutableSet;
use NoDiscard;

/**
* Lazily evaluated stream of values.
*
* Nothing is pulled from the source before the sequence is iterated (a foreach or a
* terminal operation). A sequence guarantees at least one pass; whether it can be
* iterated again depends on its source:
*
* - an array: the sequence replays, pulling fresh elements every pass;
* - a Closure or an IteratorAggregate (e.g. a Collection): a producer, asked for an
* iterable on every pass. Whatever it does to build that iterable runs again each
* time - if it fires a SQL query, that query is re-executed on every iteration of
* the sequence. It must hand back a fresh iterator on each call; handing back the
* iterator of the previous pass (a getIterator() returning a Generator it keeps
* around, for instance) throws SequenceAlreadyIteratedException;
* - a raw Iterator/Generator: the sequence is single-pass and any further iteration
* throws SequenceAlreadyIteratedException (a partial pass counts as consumed).
*
* Keys are positional: every pass yields fresh 0..n keys, whatever the source yields.
*
* Deliberately neither Countable (counting would silently consume a pass; native
* count($seq) is a TypeError by design) nor JsonSerializable
* (json_encode would be a hidden materialization) - materialize explicitly with
* toList() or toArray() instead.
*
* @template E
* @extends IteratorAggregate<int, E>
*/
interface Sequence extends IteratorAggregate
{
// --- Transformation ---

/**
* Filter elements by predicate.
*
* @param Closure(E, int):bool $predicate
* @return Sequence<E>
*/
#[NoDiscard]
public function filter(Closure $predicate): Sequence;

/**
* Map elements to a new sequence.
* The transform receives the element and optionally the index.
*
* @template R
* @param Closure(E, int):R $transform
* @return Sequence<R>
*/
#[NoDiscard]
public function map(Closure $transform): Sequence;

// --- Conversion ---

/**
* Convert to an immutable list, consuming one pass of the sequence.
*
* @return ImmutableList<E>
*/
#[NoDiscard]
public function toList(): ImmutableList;

/**
* Convert to an immutable set (duplicates removed), consuming one pass of the sequence.
*
* @return ImmutableSet<E>
*/
#[NoDiscard]
public function toSet(): ImmutableSet;

/**
* Convert to a primitive PHP array, consuming one pass of the sequence.
*
* @return list<E>
*/
#[NoDiscard]
public function toArray(): array;
}
183 changes: 183 additions & 0 deletions src/Sequence/SequenceLogic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Sequence;

use Closure;
use Generator;
use IteratorAggregate;
use Noctud\Collection\Exception\InvalidSequenceSourceException;
use Noctud\Collection\Exception\SequenceAlreadyIteratedException;
use Noctud\Collection\List\ImmutableList;
use Noctud\Collection\Operation\FilterOperation;
use Noctud\Collection\Operation\MapKeyValueOperation;
use Noctud\Collection\Set\ImmutableSet;
use NoDiscard;
use Traversable;
use WeakReference;
use function Noctud\Collection\listOf;
use function Noctud\Collection\setOf;

/**
* @template E
* @mixin Sequence<E>
*/
trait SequenceLogic
{
/** @var iterable<E>|Closure():iterable<E> */
private iterable|Closure $source;

/** Whether a single-pass source has already been handed out. */
private bool $consumed = false;

/**
* Iterator the source produced on the previous pass, compared by identity in
* resolveProducedIterable(). Held weakly so a consumed iterator - and the file handle or
* cursor behind it - is freed as soon as nothing else uses it: a collected one leaves
* get() returning null, and an iterator nobody holds cannot be handed back to us.
*
* @var WeakReference<Traversable>|null
*/
private ?WeakReference $lastProduced = null;

/**
* @return Generator<int, E>
*/
public function getIterator(): Generator
{
$iterable = $this->resolveSourceForThisPass();

return (static function () use ($iterable): Generator {
$i = 0;
foreach ($iterable as $value) {
yield $i++ => $value;
Comment thread
nikophil marked this conversation as resolved.
}
})();
}

/** {@inheritDoc} */
#[NoDiscard]
public function filter(Closure $predicate): Sequence
Comment thread
nikophil marked this conversation as resolved.
{
return $this->newSequenceOf(fn (): iterable => new FilterOperation($this)->byPredicate($predicate));
}

/** {@inheritDoc} */
#[NoDiscard]
public function map(Closure $transform): Sequence
{
return $this->newSequenceOf(fn (): iterable => new MapKeyValueOperation($this)->items($transform));
}

/** {@inheritDoc} */
#[NoDiscard]
public function toList(): ImmutableList
{
return listOf($this);
}

/** {@inheritDoc} */
#[NoDiscard]
public function toSet(): ImmutableSet
{
return setOf($this);
}

/** {@inheritDoc} */
#[NoDiscard]
public function toArray(): array
{
return iterator_to_array($this, false);
}

/**
* Chains a lazy stage. The factory is invoked once per pass (never at chain-build
* time), so every pass runs a fresh Operation generator and replayability follows
* the root source.
*
* @template R
* @param Closure():iterable<R> $factory
* @return Sequence<R>
*/
protected function newSequenceOf(Closure $factory): Sequence
{
return new GeneratorSequence($factory);
}

/**
* Resolves the source for one pass, enforcing the replayability contract: an array
* replays freely, a Closure and an IteratorAggregate are both producers asked for an
* iterable once per pass (and must hand back a fresh one each time), and any other
* Traversable is single-pass - even when technically rewindable, matching Kotlin's
* Iterator.asSequence(). The guard runs here, at getIterator() call time, so a
* violation throws at the start of the offending pass instead of silently yielding
* nothing.
*
* @return iterable<E>
*/
private function resolveSourceForThisPass(): iterable
{
$source = $this->source;

if ($source instanceof Closure) {
$produced = $source();

// @phpstan-ignore function.alreadyNarrowedType (the closure's return type is a PHPDoc promise PHP cannot enforce)
if (!is_iterable($produced)) {
throw InvalidSequenceSourceException::closureReturnedNonIterable($produced);
}

return $this->resolveProducedIterable($produced);
}

if (is_array($source)) {
return $source;
}

if ($source instanceof IteratorAggregate) {
return $this->resolveProducedIterable($source);
}

if ($this->consumed) {
throw SequenceAlreadyIteratedException::nonReplayableSourceAlreadyIterated();
}

$this->consumed = true;

return $source;
}

/**
* Rejects a producer - a source Closure or an IteratorAggregate - handing back the
* cursor of the previous pass: implementing IteratorAggregate promises nothing about
* replayability, getIterator() is free to return a Generator the object keeps around,
* and traversing that one again would yield nothing (or leak a raw PHP error).
*
* @param iterable<E> $produced
* @return iterable<E>
*/
private function resolveProducedIterable(iterable $produced): iterable
{
if ($produced instanceof IteratorAggregate) {
$produced = $produced->getIterator();
}

// Cursors only: an array is a value, always replayable, and a nested aggregate is itself
// a producer, re-invoked per pass by the foreach that unwraps it.
if ($produced instanceof Traversable && !$produced instanceof IteratorAggregate) {
if ($this->lastProduced?->get() === $produced) {
throw SequenceAlreadyIteratedException::sourceReturnedSameIterator();
}

$this->lastProduced = WeakReference::create($produced);
}

return $produced;
}
}
Loading
Loading