From 362be24d684f6c57122c6c575e00fa553d58f12f Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Mon, 27 Jul 2026 16:30:48 +0200 Subject: [PATCH] Add sequence intermediate methods --- src/CollectionLogic.php | 10 +- src/List/ListLogic.php | 10 +- src/Operation/DropOperation.php | 6 +- src/Operation/TakeOperation.php | 11 +- src/Operation/ZipOperation.php | 51 ++- src/Sequence/Sequence.php | 144 ++++++++ src/Sequence/SequenceLogic.php | 130 +++++++ src/Set/SetLogic.php | 10 +- tests/Collection/CollectionGroupAndZip.php | 22 ++ tests/Sequence/SequenceIterationTest.php | 79 +++++ tests/Sequence/SequenceLazinessTest.php | 90 +++++ tests/Sequence/SequenceTransformTest.php | 383 +++++++++++++++++++++ tests/Type/SequenceTransformTypeTest.php | 44 +++ 13 files changed, 950 insertions(+), 40 deletions(-) diff --git a/src/CollectionLogic.php b/src/CollectionLogic.php index b83ffe3..8f28094 100644 --- a/src/CollectionLogic.php +++ b/src/CollectionLogic.php @@ -598,20 +598,14 @@ public function dropLast(int $n = 1): ImmutableCollection #[NoDiscard] public function takeWhile(Closure $predicate): ImmutableCollection { - $i = 0; - return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate($predicate)); } /** {@inheritDoc} */ #[NoDiscard] public function dropWhile(Closure $predicate): ImmutableCollection { - $i = 0; - return $this->newCollectionOf(new DropOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new DropOperation($this->store)->byPredicate($predicate)); } /** {@inheritDoc} */ diff --git a/src/List/ListLogic.php b/src/List/ListLogic.php index db5082c..f6a70e4 100644 --- a/src/List/ListLogic.php +++ b/src/List/ListLogic.php @@ -299,10 +299,7 @@ public function dropLast(int $n = 1): ImmutableList #[NoDiscard] public function takeWhile(Closure $predicate): ImmutableList { - $i = 0; - return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate($predicate)); } /** @@ -313,10 +310,7 @@ public function takeWhile(Closure $predicate): ImmutableList #[NoDiscard] public function dropWhile(Closure $predicate): ImmutableList { - $i = 0; - return $this->newCollectionOf(new DropOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new DropOperation($this->store)->byPredicate($predicate)); } /** diff --git a/src/Operation/DropOperation.php b/src/Operation/DropOperation.php index 362afd7..cf9a407 100644 --- a/src/Operation/DropOperation.php +++ b/src/Operation/DropOperation.php @@ -50,14 +50,14 @@ public function last(int $n): array } /** - * @param callable(V):bool $predicate + * @param callable(V, int):bool $predicate * @return Generator */ public function byPredicate(callable $predicate): Generator { $dropping = true; - foreach ($this->data as $v) { - if ($dropping && $predicate($v)) { + foreach ($this->data as $i => $v) { + if ($dropping && $predicate($v, $i)) { continue; } $dropping = false; diff --git a/src/Operation/TakeOperation.php b/src/Operation/TakeOperation.php index a115a31..2868e13 100644 --- a/src/Operation/TakeOperation.php +++ b/src/Operation/TakeOperation.php @@ -30,10 +30,11 @@ public function first(int $n): Generator $i = 0; foreach ($this->data as $v) { - if ($i++ >= $n) { + yield $v; + + if (++$i >= $n) { break; } - yield $v; } } @@ -53,13 +54,13 @@ public function last(int $n): array } /** - * @param callable(V):bool $predicate + * @param callable(V, int):bool $predicate * @return Generator */ public function byPredicate(callable $predicate): Generator { - foreach ($this->data as $v) { - if (!$predicate($v)) { + foreach ($this->data as $i => $v) { + if (!$predicate($v, $i)) { break; } yield $v; diff --git a/src/Operation/ZipOperation.php b/src/Operation/ZipOperation.php index 9999497..fcbf506 100644 --- a/src/Operation/ZipOperation.php +++ b/src/Operation/ZipOperation.php @@ -9,8 +9,10 @@ namespace Noctud\Collection\Operation; +use ArrayIterator; use Generator; -use Traversable; +use Iterator; +use IteratorIterator; /** * @internal @@ -26,17 +28,50 @@ final class ZipOperation extends AbstractOperation */ public function with(iterable $other): Generator { - $otherArray = $other instanceof Traversable ? iterator_to_array($other, false) : array_values($other); - $otherCount = count($otherArray); - $i = 0; + $left = self::cursor($this->data); + $right = self::cursor($other); - foreach ($this->data as $v) { - if ($i >= $otherCount) { + while ($left->valid() && $right->valid()) { + yield [$left->current(), $right->current()]; + + // Advancing the other side only once this one still has an element spares it a + // pull that would be thrown away whenever this side is the shorter one. + $left->next(); + + if (!$left->valid()) { break; } - yield [$v, $otherArray[$i]]; - $i++; + $right->next(); + } + } + + /** + * Positioned cursor over any iterable, so both sides can be walked in lockstep without + * either being buffered. + * + * An Iterator is used as is and never rewound: it may be a cursor that has already + * started, and rewinding a running Generator throws. + * + * @template T + * @param iterable $iterable + * @return Iterator + */ + private static function cursor(iterable $iterable): Iterator + { + if (is_array($iterable)) { + return new ArrayIterator($iterable); + } + + if ($iterable instanceof Iterator) { + return $iterable; } + + // An IteratorAggregate only hands out its iterator on rewind(): before that the + // wrapper has no position at all, and valid() answers false. + $cursor = new IteratorIterator($iterable); + $cursor->rewind(); + + return $cursor; } } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index 26ed7ea..eaa638b 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -55,6 +55,24 @@ interface Sequence extends IteratorAggregate #[NoDiscard] public function filter(Closure $predicate): Sequence; + /** + * Filter non-null elements. + * + * @return Sequence<(E is null ? never : E)> + */ + #[NoDiscard] + public function filterNotNull(): Sequence; + + /** + * Filter elements that are instances of the given class or interface. + * + * @template T + * @param class-string $type + * @return Sequence + */ + #[NoDiscard] + public function filterInstanceOf(string $type): Sequence; + /** * Map elements to a new sequence. * The transform receives the element and optionally the index. @@ -66,6 +84,132 @@ public function filter(Closure $predicate): Sequence; #[NoDiscard] public function map(Closure $transform): Sequence; + /** + * Transforms each element using the given function and excludes null results. + * Combines map and filterNotNull in a single operation. + * + * @template R + * @param Closure(E, int):(R|null) $transform + * @return Sequence + */ + #[NoDiscard] + public function mapNotNull(Closure $transform): Sequence; + + /** + * Flat map elements to a new sequence. + * Each iterable returned by the transform is consumed lazily, element by element. + * + * @template R + * @param Closure(E, int):iterable $transform + * @return Sequence + */ + #[NoDiscard] + public function flatMap(Closure $transform): Sequence; + + /** + * Flatten a sequence of iterables into a single sequence. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty sequences). + * + * @return Sequence<(E is iterable ? value-of : E)> + */ + #[NoDiscard] + public function flatten(): Sequence; + + /** + * Take the first N elements. + * The source is not pulled any further once N elements have been yielded. + * + * @param non-negative-int $n + * @return Sequence + */ + #[NoDiscard] + public function takeFirst(int $n = 1): Sequence; + + /** + * Drops the first N elements. + * + * @param non-negative-int $n + * @return Sequence + */ + #[NoDiscard] + public function dropFirst(int $n = 1): Sequence; + + /** + * Takes elements while the predicate is true. + * The source is not pulled any further once the predicate has returned false. + * + * @param Closure(E, int):bool $predicate + * @return Sequence + */ + #[NoDiscard] + public function takeWhile(Closure $predicate): Sequence; + + /** + * Drops elements while the predicate is true, then returns the rest. + * + * @param Closure(E, int):bool $predicate + * @return Sequence + */ + #[NoDiscard] + public function dropWhile(Closure $predicate): Sequence; + + /** + * Distinct elements by identity. + * Elements already seen during the current pass are skipped, so the memory held grows + * with the number of distinct elements. + * + * @return Sequence + */ + #[NoDiscard] + public function distinct(): Sequence; + + /** + * Distinct elements by selector. + * + * @template K + * @param Closure(E, int):K $selector + * @return Sequence + */ + #[NoDiscard] + public function distinctBy(Closure $selector): Sequence; + + /** + * Combines this sequence with another iterable by pairing elements at the same position. + * The resulting sequence has the length of the shorter input. + * + * @template U + * @param iterable $other + * @return Sequence + */ + #[NoDiscard] + public function zip(iterable $other): Sequence; + + /** + * Returns a sequence of pairs of each two adjacent elements in this sequence. + * If the sequence has fewer than two elements, yields nothing. + * + * @return Sequence + */ + #[NoDiscard] + public function zipWithNext(): Sequence; + + // --- Iteration --- + + /** + * Returns a sequence applying the given action to each element as it goes through, + * then yielding the element unchanged. + * + * The lazy, chainable counterpart of forEach: the action runs once per element and per + * pass, while the element flows through the pipeline - nothing happens before a + * terminal operation pulls. + * + * @param Closure(E, int):void $action + * @return Sequence + */ + #[NoDiscard] + public function onEach(Closure $action): Sequence; + // --- Conversion --- /** diff --git a/src/Sequence/SequenceLogic.php b/src/Sequence/SequenceLogic.php index 047c413..eebd1ac 100644 --- a/src/Sequence/SequenceLogic.php +++ b/src/Sequence/SequenceLogic.php @@ -15,8 +15,15 @@ use Noctud\Collection\Exception\InvalidSequenceSourceException; use Noctud\Collection\Exception\SequenceAlreadyIteratedException; use Noctud\Collection\List\ImmutableList; +use Noctud\Collection\Operation\DistinctOperation; +use Noctud\Collection\Operation\DropOperation; use Noctud\Collection\Operation\FilterOperation; +use Noctud\Collection\Operation\FlatMapKeyValueOperation; +use Noctud\Collection\Operation\FlattenOperation; use Noctud\Collection\Operation\MapKeyValueOperation; +use Noctud\Collection\Operation\TakeOperation; +use Noctud\Collection\Operation\ZipOperation; +use Noctud\Collection\Operation\ZipWithNextOperation; use Noctud\Collection\Set\ImmutableSet; use NoDiscard; use Traversable; @@ -61,6 +68,8 @@ public function getIterator(): Generator })(); } + // --- Transformation --- + /** {@inheritDoc} */ #[NoDiscard] public function filter(Closure $predicate): Sequence @@ -68,6 +77,24 @@ public function filter(Closure $predicate): Sequence return $this->newSequenceOf(fn (): iterable => new FilterOperation($this)->byPredicate($predicate)); } + /** + * {@inheritDoc} + * + * @return Sequence<(E is null ? never : E)> + */ + #[NoDiscard] // @phpstan-ignore conditionalType.subjectNotFound (in classes with a concrete E the conditional subject is already substituted) + public function filterNotNull(): Sequence + { + return $this->newSequenceOf(fn (): iterable => new FilterOperation($this)->notNullValues()); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function filterInstanceOf(string $type): Sequence + { + return $this->newSequenceOf(fn (): iterable => new FilterOperation($this)->byValue(fn ($v) => $v instanceof $type)); // @phpstan-ignore return.type + } + /** {@inheritDoc} */ #[NoDiscard] public function map(Closure $transform): Sequence @@ -75,6 +102,109 @@ public function map(Closure $transform): Sequence return $this->newSequenceOf(fn (): iterable => new MapKeyValueOperation($this)->items($transform)); } + /** {@inheritDoc} */ + #[NoDiscard] + public function mapNotNull(Closure $transform): Sequence + { + return $this->newSequenceOf(fn (): iterable => new MapKeyValueOperation($this)->itemsNotNull($transform)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function flatMap(Closure $transform): Sequence + { + return $this->newSequenceOf(fn (): iterable => new FlatMapKeyValueOperation($this)->items($transform)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function flatten(): Sequence + { + return $this->newSequenceOf(fn (): iterable => new FlattenOperation($this)->items()); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function takeFirst(int $n = 1): Sequence + { + return $this->newSequenceOf(fn (): iterable => new TakeOperation($this)->first($n)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function dropFirst(int $n = 1): Sequence + { + return $this->newSequenceOf(fn (): iterable => new DropOperation($this)->first($n)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function takeWhile(Closure $predicate): Sequence + { + return $this->newSequenceOf(fn (): iterable => new TakeOperation($this)->byPredicate($predicate)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function dropWhile(Closure $predicate): Sequence + { + return $this->newSequenceOf(fn (): iterable => new DropOperation($this)->byPredicate($predicate)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function distinct(): Sequence + { + return $this->newSequenceOf(fn (): iterable => new DistinctOperation($this)->items()); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function distinctBy(Closure $selector): Sequence + { + return $this->newSequenceOf(fn (): iterable => new DistinctOperation($this)->bySelector($selector)); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function zip(iterable $other): Sequence + { + $otherIsOneShot = $other instanceof Traversable && !$other instanceof IteratorAggregate; + $otherConsumed = false; + + return $this->newSequenceOf(function () use ($other, $otherIsOneShot, &$otherConsumed): iterable { + if ($otherIsOneShot && $otherConsumed) { + throw SequenceAlreadyIteratedException::nonReplayableSourceAlreadyIterated(); + } + + $otherConsumed = true; + + return new ZipOperation($this)->with($other); + }); + } + + /** {@inheritDoc} */ + #[NoDiscard] + public function zipWithNext(): Sequence + { + return $this->newSequenceOf(fn (): iterable => new ZipWithNextOperation($this)->pairs()); + } + + // --- Iteration --- + + /** {@inheritDoc} */ + #[NoDiscard] + public function onEach(Closure $action): Sequence + { + return $this->newSequenceOf(fn (): iterable => new MapKeyValueOperation($this)->items(function ($v, $k) use ($action) { + $action($v, $k); + + return $v; + })); + } + + // --- Conversion --- + /** {@inheritDoc} */ #[NoDiscard] public function toList(): ImmutableList diff --git a/src/Set/SetLogic.php b/src/Set/SetLogic.php index 06de026..24e6e24 100644 --- a/src/Set/SetLogic.php +++ b/src/Set/SetLogic.php @@ -178,10 +178,7 @@ public function dropLast(int $n = 1): ImmutableSet #[NoDiscard] public function takeWhile(Closure $predicate): ImmutableSet { - $i = 0; - return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new TakeOperation($this->store)->byPredicate($predicate)); } /** @@ -192,10 +189,7 @@ public function takeWhile(Closure $predicate): ImmutableSet #[NoDiscard] public function dropWhile(Closure $predicate): ImmutableSet { - $i = 0; - return $this->newCollectionOf(new DropOperation($this->store)->byPredicate(function ($v) use ($predicate, &$i) { - return $predicate($v, $i++); - })); + return $this->newCollectionOf(new DropOperation($this->store)->byPredicate($predicate)); } /** diff --git a/tests/Collection/CollectionGroupAndZip.php b/tests/Collection/CollectionGroupAndZip.php index 1120651..2976cd6 100644 --- a/tests/Collection/CollectionGroupAndZip.php +++ b/tests/Collection/CollectionGroupAndZip.php @@ -9,6 +9,7 @@ namespace Noctud\Collection\Tests\Collection; +use Generator; use Noctud\Collection\Exception\UnsupportedOperationException; use Noctud\Collection\Map\ImmutableMap; use PHPUnit\Framework\Attributes\Test; @@ -114,6 +115,27 @@ public function zip_on_empty(): void $this->assertSame([], $collection->zip(['a'])->toArray()); } + #[Test] + public function zip_pulls_the_other_iterable_lazily(): void + { + $pulled = []; + $other = (static function () use (&$pulled): Generator { + foreach (['a', 'b', 'c', 'd'] as $value) { + $pulled[] = $value; + + yield $value; + } + })(); + + $collection = $this->collectionOf([1, 2]); + + $this->assertSame([[1, 'a'], [2, 'b']], $collection->zip($other)->toArray()); + + // The other side is walked in lockstep, never buffered - and not pulled once past the + // shorter side either. + $this->assertSame(['a', 'b'], $pulled); + } + #[Test] public function zipWithNext_pairs_adjacent_elements(): void { diff --git a/tests/Sequence/SequenceIterationTest.php b/tests/Sequence/SequenceIterationTest.php index b9881b5..f8f0580 100644 --- a/tests/Sequence/SequenceIterationTest.php +++ b/tests/Sequence/SequenceIterationTest.php @@ -142,6 +142,85 @@ public function a_consumed_iterator_is_not_retained_by_the_sequence(): void $this->assertSame([1], $sequence->toArray()); } + #[Test] + public function replayed_pass_starts_from_fresh_intermediate_state(): void + { + // distinct's seen-set, dropWhile's dropping flag, dropFirst's counter and + // zipWithNext's previous element are all built inside the factory closure, so the + // second pass rebuilds them instead of resuming where the first one stopped. + $sequence = sequenceOf([1, 1, 2, 3, 3, 4]) + ->distinct() + ->dropWhile(static fn (int $v): bool => $v < 2) + ->dropFirst() + ->zipWithNext(); + + $this->assertSame([[3, 4]], $sequence->toArray()); + $this->assertSame([[3, 4]], $sequence->toArray()); + } + + #[Test] + public function replayed_pass_restarts_the_positional_counters(): void + { + $taking = sequenceOf(['a', 'b', 'c'])->takeWhile(static fn (string $v, int $i): bool => $i < 2); + $dropping = sequenceOf(['a', 'b', 'c'])->dropWhile(static fn (string $v, int $i): bool => $i < 2); + + $this->assertSame(['a', 'b'], $taking->toArray()); + $this->assertSame(['a', 'b'], $taking->toArray()); + + $this->assertSame(['c'], $dropping->toArray()); + $this->assertSame(['c'], $dropping->toArray()); + } + + #[Test] + public function zip_replays_when_both_sides_are_replayable(): void + { + $sequence = sequenceOf([1, 2, 3])->zip(['a', 'b']); + + $this->assertSame([[1, 'a'], [2, 'b']], $sequence->toArray()); + $this->assertSame([[1, 'a'], [2, 'b']], $sequence->toArray()); + } + + #[Test] + public function zip_against_a_raw_iterator_throws_on_second_pass(): void + { + $other = (static function (): Generator { + yield 'a'; + yield 'b'; + yield 'c'; + })(); + $sequence = sequenceOf([1, 2])->zip($other); + + $this->assertSame([[1, 'a'], [2, 'b']], $sequence->toArray()); + + // Without the guard the other side would resume where it stopped and the second pass + // would silently pair 1 with 'c' - the failure mode the contract exists to prevent. + $this->expectException(SequenceAlreadyIteratedException::class); + $this->expectExceptionMessageIsOrContains( + '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.', + ); + + // phpcs:ignore + $_ = $sequence->toArray(); + } + + #[Test] + public function zip_against_a_one_shot_sequence_throws_on_second_pass(): void + { + $generator = (static function (): Generator { + yield 'a'; + yield 'b'; + })(); + $sequence = sequenceOf([1, 2])->zip(sequenceOf($generator)); + + $this->assertSame([[1, 'a'], [2, 'b']], $sequence->toArray()); + + // The other side is a producer here, so its own guard is the one that fires. + $this->expectException(SequenceAlreadyIteratedException::class); + + // phpcs:ignore + $_ = $sequence->toArray(); + } + #[Test] public function replay_sees_live_collection_mutations(): void { diff --git a/tests/Sequence/SequenceLazinessTest.php b/tests/Sequence/SequenceLazinessTest.php index 4a7e16a..485e8f0 100644 --- a/tests/Sequence/SequenceLazinessTest.php +++ b/tests/Sequence/SequenceLazinessTest.php @@ -76,6 +76,96 @@ public function filter_map_fusion_processes_elements_one_by_one(): void ); } + #[Test] + public function onEach_is_lazy_and_observes_the_values_of_its_own_stage(): void + { + $log = []; + $sequence = sequenceOf([1, 2, 3]) + ->onEach(static function (int $v) use (&$log): void { + $log[] = "a:$v"; + }) + ->map(static fn (int $v): int => $v * 10) + ->onEach(static function (int $v) use (&$log): void { + $log[] = "b:$v"; + }); + + $this->assertSame([], $log); + + $this->assertSame([10, 20, 30], $sequence->toArray()); + $this->assertSame(['a:1', 'b:10', 'a:2', 'b:20', 'a:3', 'b:30'], $log); + } + + #[Test] + public function takeFirst_pulls_exactly_n_elements_from_the_source(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4, 5] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + $this->assertSame([1, 2], $sequence->takeFirst(2)->toArray()); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function takeFirst_only_maps_what_it_takes(): void + { + $transformCalls = 0; + + $result = sequenceOf([1, 2, 3, 4, 5]) + ->map(static function (int $v) use (&$transformCalls): int { + $transformCalls++; + + return $v * 10; + }) + ->takeFirst(2) + ->toArray(); + + $this->assertSame([10, 20], $result); + $this->assertSame(2, $transformCalls); + } + + #[Test] + public function takeWhile_stops_pulling_at_the_first_failing_element(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4, 5] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + $this->assertSame([1, 2], $sequence->takeWhile(static fn (int $v): bool => $v < 3)->toArray()); + + // 3 is pulled and tested, then nothing further: the predicate has to see the element + // that ends the run. + $this->assertSame([1, 2, 3], $pulled); + } + + #[Test] + public function zip_pulls_the_other_side_lazily(): void + { + $pulled = []; + $other = (static function () use (&$pulled): Generator { + foreach (['a', 'b', 'c', 'd'] as $value) { + $pulled[] = $value; + + yield $value; + } + })(); + + $this->assertSame([[1, 'a'], [2, 'b']], sequenceOf([1, 2])->zip($other)->toArray()); + + // Never buffered, and not pulled once past the shorter side either. + $this->assertSame(['a', 'b'], $pulled); + } + #[Test] public function chained_pipeline_replays_through_replayable_root(): void { diff --git a/tests/Sequence/SequenceTransformTest.php b/tests/Sequence/SequenceTransformTest.php index a6c144e..9803f5d 100644 --- a/tests/Sequence/SequenceTransformTest.php +++ b/tests/Sequence/SequenceTransformTest.php @@ -9,6 +9,10 @@ namespace Noctud\Collection\Tests\Sequence; +use Generator; +use Noctud\Collection\Tests\Collection\Fixture\Cat; +use Noctud\Collection\Tests\Collection\Fixture\Dog; +use Noctud\Collection\Tests\Collection\Fixture\Walkable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use function Noctud\Collection\listOf; @@ -34,6 +38,43 @@ public function filter_matches_its_collection_counterpart(): void ); } + #[Test] + public function filterNotNull_drops_null_elements(): void + { + $this->assertSame([1, 2], sequenceOf([1, null, 2, null])->filterNotNull()->toArray()); + } + + #[Test] + public function filterNotNull_matches_its_collection_counterpart(): void + { + $data = [null, 1, null, 2]; + + $this->assertSame( + listOf($data)->filterNotNull()->toArray(), + sequenceOf($data)->filterNotNull()->toArray(), + ); + } + + #[Test] + public function filterInstanceOf_keeps_only_instances_of_the_given_type(): void + { + $dog = new Dog('Rex'); + $cat = new Cat('Felix'); + + $this->assertSame([$dog], sequenceOf([$dog, $cat])->filterInstanceOf(Walkable::class)->toArray()); + } + + #[Test] + public function filterInstanceOf_matches_its_collection_counterpart(): void + { + $data = [new Dog('Rex'), new Cat('Felix'), new Dog('Bobby')]; + + $this->assertSame( + listOf($data)->filterInstanceOf(Walkable::class)->toArray(), + sequenceOf($data)->filterInstanceOf(Walkable::class)->toArray(), + ); + } + #[Test] public function map_transforms_each_element(): void { @@ -57,6 +98,276 @@ public function map_matches_its_collection_counterpart(): void ); } + #[Test] + public function mapNotNull_drops_the_elements_the_transform_maps_to_null(): void + { + $this->assertSame( + [20, 40], + sequenceOf([1, 2, 3, 4]) + ->mapNotNull(static fn (int $v): ?int => $v % 2 === 0 ? $v * 10 : null) + ->toArray(), + ); + } + + #[Test] + public function mapNotNull_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3, 4, 5]; + $transform = static fn (int $v): ?string => $v > 2 ? "#$v" : null; + + $this->assertSame( + listOf($data)->mapNotNull($transform)->toArray(), + sequenceOf($data)->mapNotNull($transform)->toArray(), + ); + } + + #[Test] + public function flatMap_concatenates_the_iterables_returned_by_the_transform(): void + { + $this->assertSame( + [1, 10, 2, 20], + sequenceOf([1, 2])->flatMap(static fn (int $v): array => [$v, $v * 10])->toArray(), + ); + } + + #[Test] + public function flatMap_matches_its_collection_counterpart(): void + { + $data = ['ab', 'c']; + $transform = static fn (string $v): array => str_split($v); + + $this->assertSame( + listOf($data)->flatMap($transform)->toArray(), + sequenceOf($data)->flatMap($transform)->toArray(), + ); + } + + #[Test] + public function flatMap_accepts_a_generator_returning_transform(): void + { + $result = sequenceOf([1, 2]) + ->flatMap(static function (int $v): Generator { + yield $v; + yield -$v; + }) + ->toArray(); + + $this->assertSame([1, -1, 2, -2], $result); + } + + #[Test] + public function flatten_unwraps_one_level_and_keeps_non_iterable_elements(): void + { + $this->assertSame([1, 2, 3, 4], sequenceOf([[1, 2], [3], 4])->flatten()->toArray()); + } + + #[Test] + public function flatten_matches_its_collection_counterpart(): void + { + $data = [[1, 2], [], [3], 4]; + + $this->assertSame( + listOf($data)->flatten()->toArray(), + sequenceOf($data)->flatten()->toArray(), + ); + } + + #[Test] + public function takeFirst_keeps_the_first_n_elements(): void + { + $this->assertSame([1, 2], sequenceOf([1, 2, 3, 4])->takeFirst(2)->toArray()); + } + + #[Test] + public function takeFirst_defaults_to_one_element_and_accepts_zero(): void + { + $this->assertSame([1], sequenceOf([1, 2, 3])->takeFirst()->toArray()); + $this->assertSame([], sequenceOf([1, 2, 3])->takeFirst(0)->toArray()); + } + + #[Test] + public function takeFirst_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3]; + + foreach ([0, 1, 2, 3, 10] as $n) { + $this->assertSame( + listOf($data)->takeFirst($n)->toArray(), + sequenceOf($data)->takeFirst($n)->toArray(), + ); + } + } + + #[Test] + public function dropFirst_skips_the_first_n_elements(): void + { + $this->assertSame([3, 4], sequenceOf([1, 2, 3, 4])->dropFirst(2)->toArray()); + } + + #[Test] + public function dropFirst_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3]; + + foreach ([0, 1, 2, 3, 10] as $n) { + $this->assertSame( + listOf($data)->dropFirst($n)->toArray(), + sequenceOf($data)->dropFirst($n)->toArray(), + ); + } + } + + #[Test] + public function takeWhile_stops_at_the_first_element_failing_the_predicate(): void + { + $this->assertSame([1, 2], sequenceOf([1, 2, 3, 1])->takeWhile(static fn (int $v): bool => $v < 3)->toArray()); + } + + #[Test] + public function takeWhile_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3, 1]; + $predicate = static fn (int $v): bool => $v < 3; + + $this->assertSame( + listOf($data)->takeWhile($predicate)->toArray(), + sequenceOf($data)->takeWhile($predicate)->toArray(), + ); + } + + #[Test] + public function dropWhile_yields_everything_from_the_first_element_failing_the_predicate(): void + { + $this->assertSame([3, 1], sequenceOf([1, 2, 3, 1])->dropWhile(static fn (int $v): bool => $v < 3)->toArray()); + } + + #[Test] + public function dropWhile_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3, 1]; + $predicate = static fn (int $v): bool => $v < 3; + + $this->assertSame( + listOf($data)->dropWhile($predicate)->toArray(), + sequenceOf($data)->dropWhile($predicate)->toArray(), + ); + } + + #[Test] + public function distinct_keeps_the_first_occurrence_of_each_element(): void + { + $this->assertSame([1, 2, 3], sequenceOf([1, 2, 1, 3, 2])->distinct()->toArray()); + } + + #[Test] + public function distinct_matches_its_collection_counterpart(): void + { + $data = ['a', 'b', 'a', 'c']; + + $this->assertSame( + listOf($data)->distinct()->toArray(), + sequenceOf($data)->distinct()->toArray(), + ); + } + + #[Test] + public function distinctBy_keeps_the_first_element_of_each_selector_value(): void + { + $this->assertSame( + ['one', 'three'], + sequenceOf(['one', 'two', 'three'])->distinctBy(static fn (string $v): int => strlen($v))->toArray(), + ); + } + + #[Test] + public function distinctBy_matches_its_collection_counterpart(): void + { + $data = ['one', 'two', 'three', 'six']; + $selector = static fn (string $v): int => strlen($v); + + $this->assertSame( + listOf($data)->distinctBy($selector)->toArray(), + sequenceOf($data)->distinctBy($selector)->toArray(), + ); + } + + #[Test] + public function zip_pairs_elements_at_the_same_position(): void + { + $this->assertSame( + [[1, 'a'], [2, 'b']], + sequenceOf([1, 2])->zip(['a', 'b'])->toArray(), + ); + } + + #[Test] + public function zip_stops_at_the_shorter_side(): void + { + $this->assertSame([[1, 'a']], sequenceOf([1, 2, 3])->zip(['a'])->toArray()); + $this->assertSame([[1, 'a']], sequenceOf([1])->zip(['a', 'b', 'c'])->toArray()); + $this->assertSame([], sequenceOf([1, 2])->zip([])->toArray()); + } + + #[Test] + public function zip_accepts_any_iterable_on_the_other_side(): void + { + $other = (static function (): Generator { + yield 'a'; + yield 'b'; + })(); + + $this->assertSame([[1, 'a'], [2, 'b']], sequenceOf([1, 2, 3])->zip($other)->toArray()); + $this->assertSame([[1, 'a']], sequenceOf([1, 2])->zip(sequenceOf(['a']))->toArray()); + } + + #[Test] + public function zip_matches_its_collection_counterpart(): void + { + $data = [1, 2, 3]; + $other = ['a', 'b']; + + $this->assertSame( + listOf($data)->zip($other)->toArray(), + sequenceOf($data)->zip($other)->toArray(), + ); + } + + #[Test] + public function zipWithNext_pairs_adjacent_elements(): void + { + $this->assertSame([[1, 2], [2, 3]], sequenceOf([1, 2, 3])->zipWithNext()->toArray()); + } + + #[Test] + public function zipWithNext_yields_nothing_below_two_elements(): void + { + $this->assertSame([], sequenceOf([1])->zipWithNext()->toArray()); + $this->assertSame([], sequenceOf([])->zipWithNext()->toArray()); + } + + #[Test] + public function zipWithNext_matches_its_collection_counterpart(): void + { + $data = ['a', 'b', 'c']; + + $this->assertSame( + listOf($data)->zipWithNext()->toArray(), + sequenceOf($data)->zipWithNext()->toArray(), + ); + } + + #[Test] + public function onEach_yields_the_elements_unchanged(): void + { + $seen = []; + $action = static function (int $v) use (&$seen): void { + $seen[] = $v; + }; + + $this->assertSame([1, 2, 3], sequenceOf([1, 2, 3])->onEach($action)->toArray()); + $this->assertSame([1, 2, 3], $seen); + } + #[Test] public function predicate_receives_positional_index(): void { @@ -76,4 +387,76 @@ public function map_after_filter_receives_reindexed_positions(): void $this->assertSame(['0:a', '1:c', '2:d'], $result); } + + #[Test] + public function flatMap_after_filter_receives_reindexed_positions(): void + { + $result = sequenceOf(['a', 'b', 'c']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->flatMap(static fn (string $v, int $i): array => ["$i:$v"]) + ->toArray(); + + $this->assertSame(['0:b', '1:c'], $result); + } + + #[Test] + public function mapNotNull_after_filter_receives_reindexed_positions(): void + { + $result = sequenceOf(['a', 'b', 'c']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->mapNotNull(static fn (string $v, int $i): string => "$i:$v") + ->toArray(); + + $this->assertSame(['0:b', '1:c'], $result); + } + + #[Test] + public function takeWhile_after_filter_receives_reindexed_positions(): void + { + $result = sequenceOf(['a', 'b', 'c', 'd']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->takeWhile(static fn (string $v, int $i): bool => $i < 2) + ->toArray(); + + $this->assertSame(['b', 'c'], $result); + } + + #[Test] + public function dropWhile_after_filter_receives_reindexed_positions(): void + { + $result = sequenceOf(['a', 'b', 'c', 'd']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->dropWhile(static fn (string $v, int $i): bool => $i < 2) + ->toArray(); + + $this->assertSame(['d'], $result); + } + + #[Test] + public function distinctBy_after_filter_receives_reindexed_positions(): void + { + $result = sequenceOf(['a', 'b', 'c', 'd', 'e']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->distinctBy(static fn (string $v, int $i): int => intdiv($i, 2)) + ->toArray(); + + $this->assertSame(['b', 'd'], $result); + } + + #[Test] + public function onEach_after_filter_receives_reindexed_positions(): void + { + $seen = []; + $action = static function (string $v, int $i) use (&$seen): void { + $seen[] = "$i:$v"; + }; + + $result = sequenceOf(['a', 'b', 'c']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->onEach($action) + ->toArray(); + + $this->assertSame(['b', 'c'], $result); + $this->assertSame(['0:b', '1:c'], $seen); + } } diff --git a/tests/Type/SequenceTransformTypeTest.php b/tests/Type/SequenceTransformTypeTest.php index b5ef1ce..85ee9c2 100644 --- a/tests/Type/SequenceTransformTypeTest.php +++ b/tests/Type/SequenceTransformTypeTest.php @@ -9,6 +9,8 @@ namespace Noctud\Collection\Tests\Type; +use Noctud\Collection\Sequence\Sequence; +use stdClass; use function Noctud\Collection\sequenceOf; use function PHPStan\Testing\assertType; @@ -23,3 +25,45 @@ 'Noctud\Collection\Sequence\Sequence', sequenceOf([1, 2, 3])->filter(static fn (int $v): bool => $v > 1)->map(static fn (int $v): float => $v * 2.5), ); + +/** @var Sequence $s */ +$s = sequenceOf(['a', 'b', 'c']); + +// Filtering preserves the element type. +assertType('Noctud\Collection\Sequence\Sequence', $s->filterNotNull()); +assertType('Noctud\Collection\Sequence\Sequence', $s->filterInstanceOf(stdClass::class)); + +/** @var Sequence $nullable */ +$nullable = sequenceOf(['a', null]); +assertType('Noctud\Collection\Sequence\Sequence', $nullable->filterNotNull()); + +// Mapping changes the element type. +assertType('Noctud\Collection\Sequence\Sequence', $s->mapNotNull(static fn (string $x): ?int => $x !== '' ? 1 : null)); +assertType('Noctud\Collection\Sequence\Sequence', $s->flatMap(static fn (string $x): array => [(int) $x])); + +// flatten() keeps non-iterable elements as-is. +assertType('Noctud\Collection\Sequence\Sequence', $s->flatten()); + +/** @var Sequence> $arrays */ +$arrays = sequenceOf([[1, 2], [3]]); +assertType('Noctud\Collection\Sequence\Sequence', $arrays->flatten()); + +// ...and the conditional distributes over union element types. +/** @var Sequence|string> $mixed */ +$mixed = sequenceOf([[1], 'a']); +assertType('Noctud\Collection\Sequence\Sequence', $mixed->flatten()); + +// Slicing preserves the element type. +assertType('Noctud\Collection\Sequence\Sequence', $s->takeFirst(2)); +assertType('Noctud\Collection\Sequence\Sequence', $s->dropFirst(2)); +assertType('Noctud\Collection\Sequence\Sequence', $s->takeWhile(static fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Sequence\Sequence', $s->dropWhile(static fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Sequence\Sequence', $s->distinct()); +assertType('Noctud\Collection\Sequence\Sequence', $s->distinctBy(static fn (string $x): int => (int) $x)); + +// Pairing yields tuples, and stays a Sequence (the Collection counterparts return a list). +assertType('Noctud\Collection\Sequence\Sequence', $s->zip([1, 2])); +assertType('Noctud\Collection\Sequence\Sequence', $s->zipWithNext()); + +// onEach() is a pass-through tap. +assertType('Noctud\Collection\Sequence\Sequence', $s->onEach(static fn (string $x): null => null));