Skip to content

Commit d7046ec

Browse files
authored
Extend select() with onReject hook (#288)
1 parent c02b303 commit d7046ec

File tree

4 files changed

+183
-30
lines changed

4 files changed

+183
-30
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ All entry points always return an instance of the pipeline.
132132
| `unpack()` | Unpacks arrays into arguments for a callback. Flattens inputs if no callback provided. | |
133133
| `chunk()` | Chunks the pipeline into arrays of specified length. | `array_chunk` |
134134
| `chunkBy()` | Chunks the pipeline into arrays with variable sizes. Size 0 produces empty arrays. | |
135-
| `select()` | Selects elements for which the callback returns true. By default only removes `null` and `false`. | `array_filter`, `filter`, `Where` |
135+
| `select()` | Selects elements for which the callback returns true. By default only removes `null` and `false`. Optional `onReject` for side effects. | `array_filter`, `filter`, `Where` |
136136
| `filter()` | Alias for `select()` with `strict: false` default. Removes all falsy values like `array_filter`. | `array_filter` |
137137
| `tap()` | Performs side effects on each element without changing the values in the pipeline. | |
138138
| `skipWhile()` | Skips elements while the predicate returns true, and keeps everything after the predicate return false just once. | |
@@ -371,6 +371,14 @@ $pipeline->select(strict: false);
371371
$pipeline->filter();
372372
```
373373

374+
The optional `onReject` callback allows side effects (like logging) for rejected items. It receives `($value, $key)` like `tap()`:
375+
```php
376+
$pipeline->select(
377+
fn(MyItem $item) => $item->isGood(),
378+
onReject: fn($item, $key) => $this->logBad($key, $item),
379+
);
380+
```
381+
374382
## `$pipeline->slice()`
375383

376384
Takes offset and length arguments, functioning in a very similar fashion to how `array_slice` does with `$preserve_keys` set to true.

src/Standard.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,11 +492,12 @@ private static function applyOnce(iterable $previous, callable $func): Generator
492492
*
493493
* @param null|callable(TValue): bool $func A callback that accepts a single value and returns a boolean value.
494494
* @param bool $strict When true, only `null` and `false` are filtered out.
495+
* @param null|callable(TValue, TKey=): void $onReject Optional callback for rejected items (side effects like logging).
495496
*
496497
* @phpstan-self-out self<TKey, TValue>
497498
* @return Standard<TKey, TValue>
498499
*/
499-
public function select(?callable $func = null, bool $strict = true): self
500+
public function select(?callable $func = null, bool $strict = true, ?callable $onReject = null): self
500501
{
501502
// No-op: an empty array or null.
502503
if ($this->empty()) {
@@ -505,6 +506,13 @@ public function select(?callable $func = null, bool $strict = true): self
505506

506507
$func = self::resolvePredicate($func, $strict);
507508

509+
// When onReject callback is provided, use generator path for side effects.
510+
if (null !== $onReject) {
511+
$this->pipeline = self::selectWithRejectCallback($this->pipeline, $func, $onReject);
512+
513+
return $this;
514+
}
515+
508516
// We got an array, that's what we need. Moving along.
509517
if (is_array($this->pipeline)) {
510518
$this->pipeline = array_filter($this->pipeline, $func);
@@ -517,6 +525,19 @@ public function select(?callable $func = null, bool $strict = true): self
517525
return $this;
518526
}
519527

528+
private static function selectWithRejectCallback(iterable $previous, callable $predicate, callable $onReject): Generator
529+
{
530+
foreach ($previous as $key => $value) {
531+
if ($predicate($value)) {
532+
yield $key => $value;
533+
534+
continue;
535+
}
536+
537+
self::callWithValueKey($onReject, $value, $key);
538+
}
539+
}
540+
520541
/**
521542
* Removes elements unless a callback returns true. Alias for select().
522543
*

tests/FilterTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2017, 2018 Alexey Kopytko <alexey@kopytko.com>
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 Pipeline\Standard;
24+
use PHPUnit\Framework\Attributes\DataProvider;
25+
26+
/**
27+
* @covers \Pipeline\Standard::filter
28+
*
29+
* @internal
30+
*/
31+
final class FilterTest extends TestCase
32+
{
33+
public function testFilterIsNotStrictByDefault(): void
34+
{
35+
$pipeline = $this->getMockBuilder(Standard::class)
36+
->setConstructorArgs([[1]])
37+
->onlyMethods(['select'])
38+
->getMock();
39+
40+
$pipeline->expects($this->once())
41+
->method('select')
42+
->with(null, false)
43+
->willReturn($pipeline);
44+
45+
$pipeline->filter();
46+
}
47+
48+
public static function provideFilterIsEquivalentToSelect(): iterable
49+
{
50+
yield [null, false];
51+
yield [null, true];
52+
yield [fn($value) => true, false];
53+
yield [fn($value) => true, true];
54+
}
55+
56+
#[DataProvider('provideFilterIsEquivalentToSelect')]
57+
public function testFilterIsEquivalentToSelect(?callable $func, bool $strict): void
58+
{
59+
$pipeline = $this->getMockBuilder(Standard::class)
60+
->setConstructorArgs([[1]])
61+
->onlyMethods(['select'])
62+
->getMock();
63+
64+
$pipeline->expects($this->once())
65+
->method('select')
66+
->with($func, $strict)
67+
->willReturn($pipeline);
68+
69+
$pipeline->filter($func, $strict);
70+
}
71+
}

tests/SelectTest.php

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
namespace Tests\Pipeline;
2222

2323
use ArrayIterator;
24-
use PHPUnit\Framework\TestCase;
2524
use Pipeline\Standard;
26-
use PHPUnit\Framework\Attributes\DataProvider;
25+
use SplQueue;
2726

2827
use function iterator_to_array;
28+
use function Pipeline\fromArray;
2929
use function Pipeline\fromValues;
3030
use function Pipeline\map;
31+
use function Pipeline\take;
3132

3233
/**
3334
* @covers \Pipeline\Standard
@@ -44,6 +45,24 @@ final class SelectTest extends TestCase
4445
[],
4546
];
4647

48+
private array $rejected;
49+
50+
protected function setUp(): void
51+
{
52+
parent::setUp();
53+
$this->rejected = [];
54+
}
55+
56+
private function recordValue($value): void
57+
{
58+
$this->rejected[] = $value;
59+
}
60+
61+
private function recordKeyValue($value, $key): void
62+
{
63+
$this->rejected[$key] = $value;
64+
}
65+
4766
public function testStandardStringFunctions(): void
4867
{
4968
$pipeline = new Standard(new ArrayIterator([1, 2, 'foo', 'bar']));
@@ -155,42 +174,76 @@ public function testSelectUnprimed(): void
155174
$this->assertSame([], $pipeline->toList());
156175
}
157176

158-
public function testFilterIsNotStrictByDefault(): void
177+
public function testSelectOnRejectCallback(): void
159178
{
160-
$pipeline = $this->getMockBuilder(Standard::class)
161-
->setConstructorArgs([[1]])
162-
->onlyMethods(['select'])
163-
->getMock();
179+
$pipeline = fromValues(1, 2, 3, 4, 5);
180+
$pipeline->select(
181+
fn($value) => 0 === $value % 2,
182+
onReject: $this->recordValue(...),
183+
);
184+
185+
$this->assertSame([2, 4], $pipeline->toList());
186+
$this->assertSame([1, 3, 5], $this->rejected);
187+
}
188+
189+
public function testSelectOnRejectCallbackWithKey(): void
190+
{
191+
$pipeline = take(new ArrayIterator(['a' => 1, 'b' => 2, 'c' => 3]));
192+
$pipeline->select(
193+
fn($value) => 2 === $value,
194+
onReject: $this->recordKeyValue(...),
195+
);
196+
197+
$this->assertSame(['b' => 2], $pipeline->toAssoc());
198+
$this->assertSame(['a' => 1, 'c' => 3], $this->rejected);
199+
}
164200

165-
$pipeline->expects($this->once())
166-
->method('select')
167-
->with(null, false)
168-
->willReturn($pipeline);
201+
public function testSelectOnRejectCallbackWithArrayInput(): void
202+
{
203+
$pipeline = fromArray([1, 2, 3]);
204+
$pipeline->select(
205+
fn($value) => $value > 2,
206+
onReject: $this->recordValue(...),
207+
);
208+
209+
$this->assertSame([3], $pipeline->toList());
210+
$this->assertSame([1, 2], $this->rejected);
211+
}
212+
213+
public function testSelectOnRejectCallbackWithStrictMode(): void
214+
{
215+
$pipeline = fromValues(null, false, 0, '', 'valid');
216+
$pipeline->select(
217+
onReject: $this->recordValue(...),
218+
);
169219

170-
$pipeline->filter();
220+
$this->assertSame([0, '', 'valid'], $pipeline->toList());
221+
$this->assertSame([null, false], $this->rejected);
171222
}
172223

173-
public static function provideFilterIsEquivalentToSelect(): iterable
224+
public function testSelectOnRejectCallbackWithNonStrictMode(): void
174225
{
175-
yield [null, false];
176-
yield [null, true];
177-
yield [fn($value) => true, false];
178-
yield [fn($value) => true, true];
226+
$pipeline = fromValues(null, false, 0, '', 'valid');
227+
$pipeline->select(
228+
strict: false,
229+
onReject: $this->recordValue(...),
230+
);
231+
232+
$this->assertSame(['valid'], $pipeline->toList());
233+
$this->assertSame([null, false, 0, ''], $this->rejected);
179234
}
180235

181-
#[DataProvider('provideFilterIsEquivalentToSelect')]
182-
public function testFilterIsEquivalentToSelect(?callable $func, bool $strict): void
236+
public function testSelectOnRejectCallbackWithInternalCallable(): void
183237
{
184-
$pipeline = $this->getMockBuilder(Standard::class)
185-
->setConstructorArgs([[1]])
186-
->onlyMethods(['select'])
187-
->getMock();
238+
$queue = new SplQueue();
188239

189-
$pipeline->expects($this->once())
190-
->method('select')
191-
->with($func, $strict)
192-
->willReturn($pipeline);
240+
$pipeline = fromValues(1, 2, 3);
241+
$pipeline->select(
242+
fn($value) => 2 === $value,
243+
onReject: $queue->enqueue(...),
244+
);
193245

194-
$pipeline->filter($func, $strict);
246+
$this->assertSame([2], $pipeline->toList());
247+
$this->assertSame([1, 3], iterator_to_array($queue));
195248
}
196249
}

0 commit comments

Comments
 (0)