Skip to content

Commit 9ead386

Browse files
authored
Implement tap() (#248)
1 parent b23667d commit 9ead386

File tree

3 files changed

+251
-1
lines changed

3 files changed

+251
-1
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ All entry points always return an instance of the pipeline.
138138
| `unpack()` | Unpacks arrays into arguments for a callback. Flattens inputs if no callback provided. | |
139139
| `chunk()` | Chunks the pipeline into arrays of specified length. | `array_chunk` |
140140
| `filter()` | Removes elements unless a callback returns true. Removes falsey values if no callback provided. | `array_filter`, `Where` |
141+
| `tap()` | Performs side effects on each element without changing the values in the pipeline. | |
141142
| `skipWhile()` | Skips elements while the predicate returns true, and keeps everything after the predicate return false just once. | |
142143
| `slice()` | Extracts a slice from the inputs. Keys are not discarded intentionally. Suppors negative values for both arguments. | `array_slice` |
143144
| `fold()` | Reduces input values to a single value. Defaults to summation. Requires an initial value. | `array_reduce`, `Aggregate`, `Sum` |
@@ -419,6 +420,18 @@ $result = $pipeline->toList();
419420

420421
If in the example about one would use `iterator_to_array($result)` they would get just `[3, 4]`.
421422

423+
## `$pipeline->tap()`
424+
425+
Performs side effects on each element without changing the values in the pipeline. Useful for debugging, logging, or other side effects.
426+
427+
```php
428+
$pipeline->tap(function ($value, $key) {
429+
$this->log("Processing $key: $value");
430+
})->map(fn($x) => $x * 2);
431+
```
432+
433+
The `tap()` method executes the callback for each element as it flows through the pipeline, but the original values continue unchanged to the next stage.
434+
422435
## `$pipeline->each()`
423436

424437
Eagerly iterates over the sequence using the provided callback.

src/Standard.php

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,40 @@ public function finalVariance(
13781378
return $variance;
13791379
}
13801380

1381+
/**
1382+
* Performs side effects on each element without changing the values in the pipeline.
1383+
*
1384+
* @param callable(TValue, TKey=): void $func A callback such as fn($value, $key); return value is ignored.
1385+
*
1386+
* @phpstan-self-out self<TKey, TValue>
1387+
* @return Standard<TKey, TValue>
1388+
*/
1389+
public function tap(callable $func): self
1390+
{
1391+
if ($this->empty()) {
1392+
return $this;
1393+
}
1394+
1395+
$this->pipeline = self::tapValues($this->pipeline, $func);
1396+
1397+
return $this;
1398+
}
1399+
1400+
private static function tapValues(iterable $previous, callable $func): Generator
1401+
{
1402+
foreach ($previous as $key => $value) {
1403+
try {
1404+
$func($value, $key);
1405+
} catch (ArgumentCountError) {
1406+
// Optimization to reduce the number of argument count errors when calling internal callables.
1407+
$func = self::wrapInternalCallable($func);
1408+
$func($value);
1409+
}
1410+
1411+
yield $key => $value;
1412+
}
1413+
}
1414+
13811415
/**
13821416
* Eagerly iterates over the sequence using the provided callback. Discards the sequence after iteration.
13831417
*
@@ -1413,11 +1447,14 @@ private function eachInternal(callable $func): void
14131447
// to extra arguments), so we can wrap it to prevent the errors later. On the other hand, if there
14141448
// are too little arguments passed, it will blow up just a line later.
14151449
$func = self::wrapInternalCallable($func);
1416-
$func($value, $key);
1450+
$func($value);
14171451
}
14181452
}
14191453
}
14201454

1455+
/**
1456+
* Wraps internal callables to handle argument count mismatches by limiting to single argument.
1457+
*/
14211458
private static function wrapInternalCallable(callable $func): callable
14221459
{
14231460
return static fn($value) => $func($value);

tests/TapTest.php

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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 ArgumentCountError;
24+
use LogicException;
25+
use PHPUnit\Framework\TestCase;
26+
use Pipeline\Standard;
27+
use SplQueue;
28+
29+
use function Pipeline\fromArray;
30+
use function Pipeline\map;
31+
use function Pipeline\take;
32+
33+
/**
34+
* @covers \Pipeline\Standard
35+
*
36+
* @internal
37+
*/
38+
final class TapTest extends TestCase
39+
{
40+
private array $sideEffects;
41+
42+
protected function setUp(): void
43+
{
44+
parent::setUp();
45+
$this->sideEffects = [];
46+
}
47+
48+
private function recordValue($value): void
49+
{
50+
$this->sideEffects[] = $value;
51+
}
52+
53+
private function recordKeyValue($value, $key): void
54+
{
55+
$this->sideEffects[$key] = $value;
56+
}
57+
58+
public function testUninitialized(): void
59+
{
60+
$pipeline = new Standard();
61+
62+
$result = $pipeline->tap($this->recordValue(...));
63+
64+
$this->assertSame([], $result->toList());
65+
$this->assertSame([], $this->sideEffects);
66+
}
67+
68+
public function testEmpty(): void
69+
{
70+
$pipeline = take([]);
71+
72+
$result = $pipeline->tap($this->recordValue(...));
73+
74+
$this->assertSame([], $result->toList());
75+
$this->assertSame([], $this->sideEffects);
76+
}
77+
78+
public function testEmptyGenerator(): void
79+
{
80+
$pipeline = map(static fn() => yield from []);
81+
82+
$result = $pipeline->tap($this->recordValue(...));
83+
84+
$this->assertSame([], $result->toList());
85+
$this->assertSame([], $this->sideEffects);
86+
}
87+
88+
public function testBasicTap(): void
89+
{
90+
$pipeline = take([1, 2, 3, 4]);
91+
92+
$result = $pipeline->tap($this->recordValue(...))->toList();
93+
94+
$this->assertSame([1, 2, 3, 4], $result);
95+
$this->assertSame([1, 2, 3, 4], $this->sideEffects);
96+
}
97+
98+
public function testTapWithKeys(): void
99+
{
100+
$pipeline = take(['a' => 1, 'b' => 2, 'c' => 3]);
101+
102+
$result = $pipeline->tap($this->recordKeyValue(...))->toAssoc();
103+
104+
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $result);
105+
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $this->sideEffects);
106+
}
107+
108+
public function testTapDoesNotChangeValues(): void
109+
{
110+
$pipeline = take([1, 2, 3]);
111+
112+
$result = $pipeline
113+
->tap(fn($value) => $value * 10)
114+
->map(fn($value) => $value * 2)
115+
->toList();
116+
117+
$this->assertSame([2, 4, 6], $result);
118+
}
119+
120+
public function testTapChaining(): void
121+
{
122+
$result = take([1, 2, 3])
123+
->tap($this->recordValue(...))
124+
->cast(fn($value) => $value * 2)
125+
->tap($this->recordValue(...))
126+
->toList();
127+
128+
$this->assertSame([2, 4, 6], $result);
129+
$this->assertSame([1, 2, 2, 4, 3, 6], $this->sideEffects);
130+
}
131+
132+
public function testLaziness(): void
133+
{
134+
$pipeline = take([1, 2, 3, 4, 5]);
135+
136+
$result = $pipeline
137+
->tap($this->recordValue(...))
138+
->filter(fn($value) => $value <= 3);
139+
140+
$this->assertSame([], $this->sideEffects, 'Tap should not execute until consumed');
141+
142+
$final = $result->toList();
143+
144+
$this->assertSame([1, 2, 3], $final);
145+
$this->assertSame([1, 2, 3, 4, 5], $this->sideEffects);
146+
}
147+
148+
public function testTapWithException(): void
149+
{
150+
$pipeline = take([1, 2, 3])
151+
->tap(function ($value): void {
152+
$this->recordValue($value);
153+
if (2 === $value) {
154+
throw new LogicException('Test exception');
155+
}
156+
});
157+
158+
$this->expectException(LogicException::class);
159+
160+
$pipeline->toList();
161+
}
162+
163+
public function testTapStrictArity(): void
164+
{
165+
$queue = new SplQueue();
166+
$pipeline = fromArray([1, 2, 3]);
167+
168+
$result = $pipeline->tap($queue->enqueue(...))->toList();
169+
170+
$this->assertSame([1, 2, 3], $result);
171+
$this->assertSame([1, 2, 3], take($queue)->toList());
172+
}
173+
174+
public function testTapVariadicInternal(): void
175+
{
176+
$this->expectOutputString("123");
177+
178+
$pipeline = fromArray(['1', '2', '3']);
179+
$pipeline->tap(printf(...))->toList();
180+
}
181+
182+
public function testTapArgumentCountError(): void
183+
{
184+
$pipeline = fromArray(['1', '2', '3'])
185+
->tap(static function ($a, $b, $c): void {});
186+
187+
$this->expectException(ArgumentCountError::class);
188+
$this->expectExceptionMessage('Too few arguments');
189+
190+
$pipeline->toList();
191+
}
192+
193+
public function testTapReturnsSameInstance(): void
194+
{
195+
$pipeline = take([1, 2, 3]);
196+
$result = $pipeline->tap($this->recordValue(...));
197+
198+
$this->assertSame($pipeline, $result);
199+
}
200+
}

0 commit comments

Comments
 (0)