Skip to content

Commit 3ea3bcd

Browse files
committed
rename pipe() to map() and pull() to get(). document possible side effects when abusing map(). add banned code extension for phpstan
1 parent 30d9052 commit 3ea3bcd

File tree

5 files changed

+133
-26
lines changed

5 files changed

+133
-26
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"phpstan/phpstan": "^2.1",
2424
"rector/rector": "^2.0",
2525
"pestphp/pest": "^3.7",
26-
"phpstan/phpstan-strict-rules": "^2.0"
26+
"phpstan/phpstan-strict-rules": "^2.0",
27+
"ekino/phpstan-banned-code": "^3.0"
2728
},
2829
"config": {
2930
"allow-plugins": {

composer.lock

Lines changed: 67 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpstan.neon

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
includes:
22
- vendor/phpstan/phpstan-strict-rules/rules.neon
3+
- vendor/ekino/phpstan-banned-code/extension.neon
34

45
parameters:
56
paths:
67
- src
78
- tests
89
level: 9
910
strictRules:
10-
disallowedLooseComparison: false
11+
disallowedLooseComparison: false
12+
banned_code:
13+
non_ignorable: false

src/Box.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use LogicException;
88

99
/**
10-
* Wrapper class to enable chainable pipe transformations.
10+
* A container that allows chaining transformations and assertions on a value.
1111
*
1212
* @template T
1313
*/
@@ -39,11 +39,14 @@ public function __construct($value)
3939
/**
4040
* Apply a transformation function to the value.
4141
*
42+
* Caution: This method will reuse values passed by reference (e.g. objects) to minimize performance overhead.
43+
* For a side effect free version, use pure() instead.
44+
*
4245
* @template U
4346
* @param callable(T): U $callback
4447
* @return Box<U>
4548
*/
46-
public function pipe(callable $callback): Box
49+
public function map(callable $callback): Box
4750
{
4851
return new self($callback($this->value));
4952
}
@@ -56,7 +59,7 @@ public function pipe(callable $callback): Box
5659
* @param callable(T): U $callback
5760
* @return U
5861
*/
59-
public function pull(callable $callback)
62+
public function get(callable $callback)
6063
{
6164
return $callback($this->unbox());
6265
}
@@ -82,7 +85,7 @@ public function unbox()
8285
*/
8386
public function assert(mixed $check): Box
8487
{
85-
$isClosure = is_callable($check) && !is_string($check);
88+
$isClosure = is_callable($check);
8689

8790
$pass = $isClosure
8891
? $check($this->value)
@@ -111,10 +114,10 @@ public function assert(mixed $check): Box
111114
public function dump(?string $message = null): Box
112115
{
113116
if ($message !== null) {
114-
echo $message . ': ';
117+
echo $message . ': '; // @phpstan-ignore ekinoBannedCode.expression
115118
}
116119

117-
var_dump($this->value);
120+
var_dump($this->value); // @phpstan-ignore ekinoBannedCode.function
118121

119122
return $this;
120123
}

tests/Unit/BoxTest.php

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44

55
test('we can box, transform and unbox a value', function () {
66
$result = Box::of(5)
7-
->pipe(fn($x) => $x * 2)
8-
->pipe(fn($x) => $x + 1)
7+
->map(fn($x) => $x * 2)
8+
->map(fn($x) => $x + 1)
99
->unbox();
1010

1111
expect($result)->toBe(11);
1212
});
1313

1414
test('we can combine a pipe and unbox into one statement by using pull', function () {
1515
$result = Box::of(5)
16-
->pipe(fn($x) => $x * 2)
17-
->pull(fn($x) => $x + 1);
16+
->map(fn($x) => $x * 2)
17+
->get(fn($x) => $x + 1);
1818

1919
expect($result)->toBe(11);
2020
});
@@ -26,17 +26,51 @@
2626
->toThrow(LogicException::class, $expectedMessage);
2727
});
2828

29-
test('assertion by value passes when the values are the same', function () {
30-
$result = Box::of('Hello')->pipe(strtoupper(...))->assert('HELLO')->unbox();
29+
// @phpstan-ignore-next-line method.notFound
30+
test('assertion by value passes when the values are the same', function (mixed $input, mixed $output) {
31+
$result = Box::of($input)->assert($output)->unbox();
3132

32-
expect($result)->toBe('HELLO');
33+
expect($result)->toBe($input);
34+
})->with([
35+
['HELLO', 'HELLO'],
36+
[5, 5],
37+
[5.5, 5.5],
38+
[true, true],
39+
[false, false],
40+
[null, null],
41+
[[], []],
42+
[[1, 2, 3], [1, 2, 3]],
43+
[['a' => 1, 'b' => 2], ['a' => 1, 'b' => 2]],
44+
['str_split', 'str_split'], // making sure functions as strings are treated as strings
45+
// [(object)[], (object)[]], // this one will fail because two objects have different references
46+
]);
47+
48+
test('assertion by value fails for two equal objects', function () {
49+
$object1 = (object)['number' => 5];
50+
$object2 = (object)['number' => 5];
51+
52+
expect(fn() => Box::of($object1)->assert($object2))->toThrow(LogicException::class);
3353
});
3454

35-
test('assertion by callback fails when check is not passed', function () {
36-
$expectedMessage = 'Value did not pass the callback check';
55+
test('assertion by value passes for two equal objects with the same reference', function () {
56+
$object = (object)['number' => 5];
3757

38-
expect(fn() => Box::of(5)->assert(fn($x) => $x < 5))
39-
->toThrow(LogicException::class, $expectedMessage);
58+
$result = Box::of($object)->assert($object)->unbox();
59+
60+
expect($result->number)->toBe(5);
61+
});
62+
63+
// This test is not here to "lock in" desired behavior. It is here to document a pitfall.
64+
test('performing a mutation on an object using map() will produce side effects', function () {
65+
$object = (object)['number' => 5];
66+
67+
// Avoid code like this at all costs!
68+
// Problem 1: The original object is mutated
69+
// Problem 2: The object is thrown away and instead $value will simply be 10
70+
$newObject = Box::of($object)->map(fn($x) => $x->number = 10)->unbox();
71+
72+
expect($object->number)->toBe(10, 'Bad: Original object is mutated');
73+
expect($newObject)->toBeInt('Bad: $newObject is not an object, instead it is an int.');
4074
});
4175

4276
test('assertion by callback passes when check is passed', function () {
@@ -50,12 +84,12 @@
5084

5185
test('we can chain multiple transformations and assertions', function () {
5286
$result = Box::of('Hello')
53-
->pipe(strtoupper(...))->assert('HELLO')
54-
->pipe(strrev(...))->assert('OLLEH')
55-
->pipe(str_split(...))->assert(['O', 'L', 'L', 'E', 'H'])
56-
->pipe(fn($arr) => array_map(ord(...), $arr))->assert([79, 76, 76, 69, 72])
57-
->pipe(array_sum(...))->assert(372)->assert(fn($x) => $x > 0)
58-
->pull(fn($x) => $x + 100);
87+
->map(strtoupper(...))->assert('HELLO')
88+
->map(strrev(...))->assert('OLLEH')
89+
->map(str_split(...))->assert(['O', 'L', 'L', 'E', 'H'])
90+
->map(fn($arr) => array_map(ord(...), $arr))->assert([79, 76, 76, 69, 72])
91+
->map(array_sum(...))->assert(372)->assert(fn($x) => $x > 0)
92+
->get(fn($x) => $x + 100);
5993

6094
expect($result)->toBe(472);
6195
});

0 commit comments

Comments
 (0)