Skip to content

Commit f7aeb6e

Browse files
authored
Add cursor() (#266)
1 parent a912d9f commit f7aeb6e

File tree

5 files changed

+336
-1
lines changed

5 files changed

+336
-1
lines changed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ In general, Pipeline instances are mutable, meaning every Pipeline-returning met
237237
/** @var $iterator \Iterator */
238238
```
239239

240-
- Iterating over a pipeline all over again results in undefined behavior. Best to avoid doing this.
240+
- Iterating over a pipeline all over again results in undefined behavior. Best to avoid doing this. If you need to break out of iteration and continue later, see [`cursor()`](#pipeline-cursor).
241241

242242
# Classes and interfaces: overview
243243

@@ -453,6 +453,30 @@ foreach ($pipeline as $value) {
453453

454454
This allows to skip type checks for return values if one has no results to return: instead of `false` or `null` it is safe to return an unprimed pipeline.
455455

456+
## `$pipeline->cursor()`
457+
458+
Returns a forward-only iterator that maintains position across iterations. A cursor allows breaking out of a loop and continuing later:
459+
460+
```php
461+
$pipeline = \Pipeline\fromArray([1, 2, 3, 4, 5]);
462+
$cursor = $pipeline->cursor();
463+
464+
foreach ($cursor as $value) {
465+
echo $value; // 1, 2
466+
if ($value === 2) {
467+
break;
468+
}
469+
}
470+
471+
// Continue with remaining elements
472+
foreach ($cursor as $value) {
473+
echo $value; // 3, 4, 5
474+
}
475+
476+
// Or use take() to re-enter Pipeline world
477+
$remaining = \Pipeline\take($cursor)->count();
478+
```
479+
456480
## `$pipeline->runningVariance()`
457481

458482
Computes online statistics for the sequence: counts, sample mean, sample variance, standard deviation. You can access these numbers on the fly with methods such as `getCount()`, `getMean()`, `getVariance()`, `getStandardDeviation()`.

src/Helper/CursorIterator.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 Pipeline\Helper;
22+
23+
use Iterator;
24+
use NoRewindIterator;
25+
use Override;
26+
27+
/**
28+
* A forward-only iterator that auto-advances when iteration resumes.
29+
*
30+
* Unlike NoRewindIterator which repeats the current element on resume,
31+
* CursorIterator advances past it as a database cursor would.
32+
*
33+
* @template TKey
34+
* @template TValue
35+
* @template TIterator of Iterator<TKey, TValue>
36+
*
37+
* @extends NoRewindIterator<TKey, TValue, TIterator>
38+
*
39+
* @final
40+
*/
41+
class CursorIterator extends NoRewindIterator
42+
{
43+
private bool $started = false;
44+
45+
/**
46+
* @param TIterator $iterator
47+
*/
48+
public function __construct(Iterator $iterator)
49+
{
50+
parent::__construct($iterator);
51+
52+
// Get the inner iterator ready; otherwise valid()/current() won't work
53+
$iterator->rewind();
54+
}
55+
56+
#[Override]
57+
public function rewind(): void
58+
{
59+
if (!$this->started) {
60+
$this->started = true;
61+
62+
return;
63+
}
64+
65+
$this->next();
66+
}
67+
}

src/Standard.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use IteratorAggregate;
3131
use Traversable;
3232
use Override;
33+
use Pipeline\Helper\CursorIterator;
3334

3435
use function array_chunk;
3536
use function array_filter;
@@ -660,6 +661,27 @@ public function getIterator(): Traversable
660661
return new ArrayIterator($this->pipeline);
661662
}
662663

664+
/**
665+
* Returns a forward-only iterator that maintains position across iterations.
666+
* @return Iterator<TKey, TValue>
667+
*/
668+
public function cursor(): Iterator
669+
{
670+
if ($this->empty()) {
671+
return new EmptyIterator();
672+
}
673+
674+
$iterator = $this->getIterator();
675+
676+
// Avoid double wrapping
677+
if ($iterator instanceof CursorIterator) {
678+
return $iterator;
679+
}
680+
681+
/** @var Iterator $iterator */
682+
return new CursorIterator($iterator);
683+
}
684+
663685
/**
664686
* By default, returns all values regardless of keys used, discarding all keys in the process. This is a terminal operation.
665687
* @return list<TValue>

tests/ChunkTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ public static function provideIterables(): iterable
6969
$iteratorItem[2] = fromArray($iteratorItem[2]);
7070

7171
yield $iteratorItem;
72+
73+
$iteratorItem = $item;
74+
$iteratorItem[2] = take($iteratorItem[2]);
75+
76+
yield $iteratorItem;
7277
}
7378
}
7479

tests/CursorTest.php

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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 Iterator;
27+
use Pipeline\Helper\CursorIterator;
28+
use Pipeline\Standard;
29+
30+
use function iterator_count;
31+
use function Pipeline\fromArray;
32+
use function Pipeline\map;
33+
use function Pipeline\take;
34+
35+
/**
36+
* @covers \Pipeline\Standard::cursor
37+
* @covers \Pipeline\Helper\CursorIterator
38+
*
39+
* @internal
40+
*/
41+
final class CursorTest extends TestCase
42+
{
43+
public static function provideIterables(): iterable
44+
{
45+
yield 'array' => [fromArray([1, 2, 3, 4, 5])];
46+
47+
yield 'ArrayIterator' => [take(new ArrayIterator([1, 2, 3, 4, 5]))];
48+
49+
yield 'IteratorIterator' => [take(new IteratorIterator(new ArrayIterator([1, 2, 3, 4, 5])))];
50+
51+
yield 'IteratorAggregate' => [take(new Standard(new IteratorIterator(new ArrayIterator([1, 2, 3, 4, 5]))))];
52+
53+
yield 'generator' => [map(fn() => yield from [1, 2, 3, 4, 5])];
54+
55+
yield 'stream' => [fromArray([1, 2, 3, 4, 5])->stream()];
56+
}
57+
58+
/**
59+
* @dataProvider provideIterables
60+
*/
61+
public function testCursorContinuesAfterBreak(Standard $pipeline): void
62+
{
63+
$cursor = $pipeline->cursor();
64+
65+
$collected = [];
66+
foreach ($cursor as $i) {
67+
$collected[] = $i;
68+
if (2 === $i) {
69+
break;
70+
}
71+
}
72+
73+
$this->assertSame([1, 2], $collected);
74+
75+
$remaining = [];
76+
foreach ($cursor as $i) {
77+
$remaining[] = $i;
78+
}
79+
80+
// CursorIterator auto-advances past the break point
81+
$this->assertSame([3, 4, 5], $remaining);
82+
}
83+
84+
/**
85+
* @dataProvider provideIterables
86+
*/
87+
public function testCursorWithTakeCount(Standard $pipeline): void
88+
{
89+
$cursor = $pipeline->cursor();
90+
91+
foreach ($cursor as $i) {
92+
if (2 === $i) {
93+
break;
94+
}
95+
}
96+
97+
// 3 elements remain: 3, 4, 5
98+
$this->assertSame(3, take($cursor)->count());
99+
}
100+
101+
/**
102+
* @dataProvider provideIterables
103+
*/
104+
public function testCursorWithSlice(Standard $pipeline): void
105+
{
106+
$cursor = $pipeline->cursor();
107+
108+
$this->assertSame([1, 2], take($cursor)->slice(0, 2)->toList());
109+
110+
// 3 elements remain: 3, 4, 5
111+
$this->assertSame(3, take($cursor)->count());
112+
}
113+
114+
/**
115+
* @dataProvider provideIterables
116+
*/
117+
public function testCursorWithTakeReduce(Standard $pipeline): void
118+
{
119+
$cursor = $pipeline->cursor();
120+
121+
foreach ($cursor as $i) {
122+
if (2 === $i) {
123+
break;
124+
}
125+
}
126+
127+
// Remaining: 3 + 4 + 5 = 12
128+
$this->assertSame(12, take($cursor)->reduce());
129+
}
130+
131+
/**
132+
* @dataProvider provideIterables
133+
*/
134+
public function testExhaustedCursorReturnsEmpty(Standard $pipeline): void
135+
{
136+
$cursor = $pipeline->cursor();
137+
138+
// Consume all elements
139+
$this->assertSame(5, iterator_count($cursor));
140+
141+
$remaining = [];
142+
foreach ($cursor as $i) {
143+
$remaining[] = $i;
144+
}
145+
146+
$this->assertSame([], $remaining);
147+
}
148+
149+
public function testCursorReturnsIterator(): void
150+
{
151+
$pipeline = fromArray([1, 2, 3]);
152+
$cursor = $pipeline->cursor();
153+
154+
$this->assertInstanceOf(Iterator::class, $cursor);
155+
}
156+
157+
public function testCursorAvoidDoubleWrapping(): void
158+
{
159+
$pipeline = fromArray([1, 2, 3]);
160+
161+
$cursor1 = $pipeline->cursor();
162+
$cursor2 = take($cursor1)->cursor();
163+
164+
// Should be the same instance (no double wrapping)
165+
$this->assertSame($cursor1, $cursor2);
166+
}
167+
168+
public function testCursorWithEmptyPipeline(): void
169+
{
170+
$pipeline = fromArray([]);
171+
$cursor = $pipeline->cursor();
172+
173+
$this->assertSame([], take($cursor)->toList());
174+
}
175+
176+
public function testCursorPreservesKeys(): void
177+
{
178+
$pipeline = fromArray(['a' => 1, 'b' => 2, 'c' => 3]);
179+
$cursor = $pipeline->cursor();
180+
181+
$collected = [];
182+
foreach ($cursor as $key => $value) {
183+
$collected[$key] = $value;
184+
if ('a' === $key) {
185+
break;
186+
}
187+
}
188+
189+
$this->assertSame(['a' => 1], $collected);
190+
191+
$remaining = [];
192+
foreach ($cursor as $key => $value) {
193+
$remaining[$key] = $value;
194+
}
195+
196+
// CursorIterator auto-advances past 'a'
197+
$this->assertSame(['b' => 2, 'c' => 3], $remaining);
198+
}
199+
200+
public function testCursorManualIteration(): void
201+
{
202+
$pipeline = fromArray([1, 2, 3]);
203+
$cursor = $pipeline->cursor();
204+
205+
$this->assertTrue($cursor->valid());
206+
$this->assertSame(1, $cursor->current());
207+
208+
$cursor->next();
209+
$this->assertSame(2, $cursor->current());
210+
211+
$cursor->next();
212+
$this->assertSame(3, $cursor->current());
213+
214+
$cursor->next();
215+
$this->assertFalse($cursor->valid());
216+
}
217+
}

0 commit comments

Comments
 (0)