Skip to content

Commit 6a73545

Browse files
authored
peek() returns the first N items (#256)
1 parent 98b1143 commit 6a73545

File tree

4 files changed

+332
-0
lines changed

4 files changed

+332
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ All entry points always return an instance of the pipeline.
136136
| `tap()` | Performs side effects on each element without changing the values in the pipeline. | |
137137
| `skipWhile()` | Skips elements while the predicate returns true, and keeps everything after the predicate return false just once. | |
138138
| `slice()` | Extracts a slice from the inputs. Keys are not discarded intentionally. Supports negative values for both arguments. | `array_slice` |
139+
| `peek()` | Returns the first N items as an iterable. Use `prepend()` to restore items if needed. | `array_splice` |
139140
| `fold()` | Reduces input values to a single value. Defaults to summation. Requires an initial value. | `array_reduce`, `Aggregate`, `Sum` |
140141
| `reduce()` | Alias to `fold()` with a reversed order of arguments. | `array_reduce` |
141142
| `values()` | Keep values only. | `array_values` |

src/Standard.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,64 @@ private static function generatorFromIterable(iterable $input): Generator
787787
yield from $input;
788788
}
789789

790+
/**
791+
* Returns the first N items from the pipeline as an iterable, removing them from the pipeline (destructive).
792+
* Users can call prepend() to restore items if non-destructive behavior is needed.
793+
*
794+
* @param int<0, max> $count Number of items to peek at.
795+
*
796+
* @return iterable<TKey, TValue> Iterator of peeked items with keys preserved (including duplicate keys).
797+
*/
798+
public function peek(int $count): iterable
799+
{
800+
// No-op: empty pipeline or zero count
801+
if ($this->empty() || $count <= 0) {
802+
return [];
803+
}
804+
805+
// Fast-path for arrays
806+
if (is_array($this->pipeline)) {
807+
$peeked = array_slice($this->pipeline, 0, $count, true);
808+
$this->pipeline = array_slice($this->pipeline, $count, null, true);
809+
810+
return $peeked;
811+
}
812+
813+
// Convert to non-rewindable iterator
814+
$generator = self::makeNonRewindable($this->pipeline);
815+
816+
// Collect items eagerly (to update pipeline state before returning)
817+
// And preserve duplicates as tuples
818+
$peeked = iterator_to_array(self::toTuples(self::take($generator, $count)));
819+
820+
// Wrap remaining items in a fresh generator to avoid rewind issues
821+
$this->pipeline = self::resumeGenerator($generator);
822+
823+
824+
// Return generator that yields the collected items
825+
return self::tuplesToGenerator($peeked);
826+
}
827+
828+
/**
829+
* Advances the pointer to counter the optimizations of self::take(), while also deferring the costs.
830+
*/
831+
private static function resumeGenerator(Generator $input): Generator
832+
{
833+
$input->next();
834+
835+
while ($input->valid()) {
836+
yield $input->key() => $input->current();
837+
$input->next();
838+
}
839+
}
840+
841+
private static function tuplesToGenerator(iterable $input): Generator
842+
{
843+
foreach ($input as [$key, $value]) {
844+
yield $key => $value;
845+
}
846+
}
847+
790848
/**
791849
* Extracts a slice from the inputs. Keys are not discarded intentionally.
792850
*
@@ -876,6 +934,7 @@ private static function skip(Iterator $input, int $skip): Iterator
876934
}
877935

878936
/**
937+
* Note: it does not call next() upon stopping - caller's responsibility to do that if they want to reuse the iterator.
879938
* @psalm-param positive-int $take
880939
*/
881940
private static function take(Iterator $input, int $take): Iterator

tests/PeekTest.php

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2017, 2018 Alexey Kopytko <[email protected]>
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace Tests\Pipeline;
22+
23+
use ArrayIterator;
24+
use IteratorIterator;
25+
use PHPUnit\Framework\TestCase;
26+
use Tests\Pipeline\Scenarios\PeekScenario;
27+
28+
use function Pipeline\map;
29+
use function Pipeline\take;
30+
use function iterator_to_array;
31+
use function range;
32+
33+
/**
34+
* @covers \Pipeline\Standard
35+
*
36+
* @internal
37+
*/
38+
final class PeekTest extends TestCase
39+
{
40+
/**
41+
* @return iterable<PeekScenario>
42+
*/
43+
public static function providePeekData(): iterable
44+
{
45+
yield 'empty array' => new PeekScenario();
46+
yield 'empty array, count 3' => new PeekScenario(count: 3, expected_remains: []);
47+
yield 'zero count' => new PeekScenario(count: 0, input: [1, 2, 3], expected_peeked: [], expected_remains: [1, 2, 3]);
48+
49+
yield 'simple peek' => new PeekScenario(count: 3, input: [1, 2, 3, 4, 5], expected_peeked: [1, 2, 3], expected_remains: [3 => 4, 5]);
50+
51+
yield 'preserve keys' => new PeekScenario(count: 2, input: ['a' => 1, 'b' => 2, 'c' => 3], expected_peeked: ['a' => 1, 'b' => 2], expected_remains: ['c' => 3]);
52+
53+
yield 'peek more than available' => new PeekScenario(count: 10, input: [1, 2, 3], expected_peeked: [1, 2, 3], expected_remains: []);
54+
yield 'consume all' => new PeekScenario(count: 3, input: [1, 2, 3], expected_peeked: [1, 2, 3], expected_remains: []);
55+
}
56+
57+
/**
58+
* @return iterable<PeekScenario>
59+
*/
60+
public static function providePeekIterables(): iterable
61+
{
62+
foreach (self::providePeekData() as $name => $item) {
63+
yield $name . ' (array)' => [$item];
64+
yield $name . ' (ArrayIterator)' => [$item->withInput(new ArrayIterator($item->input))];
65+
yield $name . ' (IteratorIterator)' => [$item->withInput(new IteratorIterator(new ArrayIterator($item->input)))];
66+
yield $name . ' (stream)' => [$item->withInput(take($item->input)->stream())];
67+
}
68+
}
69+
70+
/**
71+
* @dataProvider providePeekIterables
72+
*/
73+
public function testPeekWithProvider(PeekScenario $item): void
74+
{
75+
$pipeline = take($item->input);
76+
77+
$peeked = $pipeline->peek($item->count);
78+
79+
$this->assertSame(
80+
take($item->expected_peeked)->tuples()->toList(),
81+
take($peeked)->tuples()->toList(),
82+
);
83+
84+
if (null === $item->expected_remains) {
85+
return;
86+
}
87+
88+
$this->assertSame(
89+
take($item->expected_remains)->tuples()->toList(),
90+
take($pipeline)->tuples()->toList(),
91+
);
92+
}
93+
94+
public function testPeekBasic(): void
95+
{
96+
$pipeline = take([1, 2, 3, 4, 5]);
97+
$peeked = iterator_to_array($pipeline->peek(3));
98+
99+
$this->assertSame([1, 2, 3], $peeked);
100+
$this->assertSame([4, 5], $pipeline->toList());
101+
}
102+
103+
public function testPeekPreservesKeys(): void
104+
{
105+
$pipeline = take(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]);
106+
$peeked = iterator_to_array($pipeline->peek(2));
107+
108+
$this->assertSame(['a' => 1, 'b' => 2], $peeked);
109+
$this->assertSame(['c' => 3, 'd' => 4], $pipeline->toAssoc());
110+
}
111+
112+
public function testPeekWithDuplicateKeys(): void
113+
{
114+
$generator = map(static function () {
115+
yield 'a' => 1;
116+
yield 'a' => 2;
117+
yield 'b' => 3;
118+
});
119+
120+
$pipeline = take($generator);
121+
$peeked = $pipeline->peek(2);
122+
123+
$this->assertSame(
124+
[['a', 1], ['a', 2]],
125+
take($peeked)->tuples()->toList(),
126+
);
127+
128+
$this->assertSame(['b' => 3], $pipeline->toAssoc());
129+
}
130+
131+
public function testPeekFromGenerator(): void
132+
{
133+
$pipeline = take(self::xrange(1, 5));
134+
$peeked = iterator_to_array($pipeline->peek(3), false);
135+
136+
$this->assertSame([1, 2, 3], $peeked);
137+
$this->assertSame([4, 5], $pipeline->toList());
138+
}
139+
140+
public function testPeekMoreThanAvailable(): void
141+
{
142+
$pipeline = take([1, 2, 3]);
143+
$peeked = iterator_to_array($pipeline->peek(10), false);
144+
145+
$this->assertSame([1, 2, 3], $peeked);
146+
$this->assertSame([], $pipeline->toList());
147+
}
148+
149+
public function testPeekFromEmptyPipeline(): void
150+
{
151+
$pipeline = take([]);
152+
$peeked = iterator_to_array($pipeline->peek(5), false);
153+
154+
$this->assertSame([], $peeked);
155+
$this->assertSame([], $pipeline->toList());
156+
}
157+
158+
public function testPeekWithZeroCount(): void
159+
{
160+
$pipeline = take([1, 2, 3]);
161+
$peeked = iterator_to_array($pipeline->peek(0), false);
162+
163+
$this->assertSame([], $peeked);
164+
$this->assertSame([1, 2, 3], $pipeline->toList());
165+
}
166+
167+
public function testPeekWithNegativeCount(): void
168+
{
169+
$pipeline = take([1, 2, 3]);
170+
$peeked = iterator_to_array($pipeline->peek(-5), false);
171+
172+
$this->assertSame([], $peeked);
173+
$this->assertSame([1, 2, 3], $pipeline->toList());
174+
}
175+
176+
public function testPeekNonDestructiveWithPrepend(): void
177+
{
178+
$pipeline = take([1, 2, 3, 4, 5]);
179+
$peeked = iterator_to_array($pipeline->peek(3), true);
180+
181+
// Restore items manually with prepend()
182+
$pipeline->prepend($peeked);
183+
184+
$this->assertSame([1, 2, 3], $peeked);
185+
$this->assertSame([1, 2, 3, 4, 5], $pipeline->toList());
186+
}
187+
188+
/** @dataProvider provideOneToFive */
189+
public function testMultipleSequentialPeeks(iterable $input): void
190+
{
191+
$pipeline = take($input);
192+
193+
$first = iterator_to_array($pipeline->peek(2), false);
194+
$this->assertSame([1, 2], $first);
195+
196+
$second = iterator_to_array($pipeline->peek(2), false);
197+
$this->assertSame([3, 4], $second);
198+
199+
$this->assertSame([5], $pipeline->toList());
200+
}
201+
202+
public static function provideOneToFive(): iterable
203+
{
204+
yield [range(1, 5)];
205+
yield [self::xrange(1, 5)];
206+
}
207+
208+
/** @dataProvider provideOneToFive */
209+
public function testMultipleSequentialPeeksOutOfOrder(iterable $input): void
210+
{
211+
$pipeline = take($input);
212+
213+
$first = $pipeline->peek(2);
214+
$second = $pipeline->peek(2);
215+
216+
$this->assertSame([5], $pipeline->toList());
217+
218+
$this->assertSame([3, 4], iterator_to_array($second, false));
219+
$this->assertSame([1, 2], iterator_to_array($first, false));
220+
}
221+
222+
public function testPeekOneDefersCosts(): void
223+
{
224+
$pipeline = map(function () {
225+
yield 1;
226+
$this->fail('Should not be called');
227+
});
228+
229+
$this->assertSame([1], iterator_to_array($pipeline->peek(1)));
230+
}
231+
232+
private static function xrange(int $start, int $end): iterable
233+
{
234+
for ($i = $start; $i <= $end; $i++) {
235+
yield $i;
236+
}
237+
}
238+
}

tests/Scenarios/PeekScenario.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2017, 2018 Alexey Kopytko <[email protected]>
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace Tests\Pipeline\Scenarios;
20+
21+
class PeekScenario
22+
{
23+
public function __construct(
24+
public readonly int $count = 0,
25+
public readonly iterable $input = [],
26+
public readonly iterable $expected_peeked = [],
27+
public readonly ?iterable $expected_remains = null,
28+
) {}
29+
30+
public function withInput(iterable $input): self
31+
{
32+
return new self($this->count, $input, $this->expected_peeked, $this->expected_remains);
33+
}
34+
}

0 commit comments

Comments
 (0)