-
Notifications
You must be signed in to change notification settings - Fork 2
feat: introduce Sequence type #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ae2f913
feat: introduce Sequence type
nikophil 856e1b7
test: document that a cycling producer slips past the one-slot guard
nikophil 59c63ca
add test producer_cycling_between_iterators_slips_past_the_guard_and_…
nikophil 8a6ba20
Treat IteratorAggregate sequence sources as producers
nikophil baae8ab
add missing #[NoDiscard] in Sequence
nikophil f04cec2
Hold the sequence identity guard slot weakly
nikophil File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)), | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.', | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| })(); | ||
| } | ||
|
|
||
| /** {@inheritDoc} */ | ||
| #[NoDiscard] | ||
| public function filter(Closure $predicate): Sequence | ||
|
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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.