Skip to content

Commit 2e70e6b

Browse files
authored
Merge pull request #72 from Innmind/guard-failures
Add mechanism to prevent recovering from certain validation failures
2 parents c7c8009 + 17810e3 commit 2e70e6b

8 files changed

Lines changed: 319 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
7+
- `Innmind\Immutable\Validation::guard()`
8+
- `Innmind\Immutable\Validation::xotherwise()`
9+
310
## 5.18.0 - 2025-08-08
411

512
### Added

docs/structures/validation.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ $validation = isEmail('foo@example.com');
7171
$localEmail = $either->flatMap(fn(string $email): Validation => isLocal($email));
7272
```
7373

74+
## `->guard()`
75+
76+
This behaves like [`->flatMap()`](#-flatmap) except any failure contained in the validation returned by the callable won't be recovered when calling [`->xotherwise()`](#-xotherwise).
77+
7478
## `->match()`
7579

7680
This is the only way to extract the wrapped value.
@@ -98,6 +102,30 @@ $email = isEmail('invalid value')
98102
->otherwise(fn() => isEmail('foo@example.com'));
99103
```
100104

105+
## `->xotherwise()`
106+
107+
This behaves like [`->otherwise()`](#-otherwise) except when conjointly used with [`->guard()`](#-guard). Guarded failures can't be recovered.
108+
109+
An example of this problem is an HTTP router with 2 validations. One tries to validate it's a `POST` request, then validates the request body, the other tries to validate a `GET` request. It would look something like this:
110+
111+
```php
112+
$result = validatePost($request)
113+
->flatMap(static fn() => validateBody($request))
114+
->otherwise(static fn() => validateGet($request));
115+
```
116+
117+
The problem here is that if the request is indeed a `POST` we try to validate the body. But if the latter fails then we try to validate it's a `GET` query. In this case the failure would indicate the request is not a `GET`, which doesn't make sense.
118+
119+
The correct approach is:
120+
121+
```php
122+
$result = validatePost($request)
123+
->guard(static fn() => validateBody($request))
124+
->xotherwise(static fn() => validateGet($request));
125+
```
126+
127+
This way if the body validation fails it will return this failure and not that it's not a `GET`.
128+
101129
## `->mapFailures()`
102130

103131
This is similar to the `->map()` function but will be applied on each failure.

proofs/validation.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,58 @@ static function($assert, $a, $b) {
261261
);
262262
},
263263
);
264+
265+
yield proof(
266+
'Validation::guard()',
267+
given(
268+
Set::integers()->above(1),
269+
Set::integers()->below(-1),
270+
Set::type(),
271+
),
272+
static function($assert, $positive, $negative, $fail) {
273+
$assert->same(
274+
$positive,
275+
Validation::success($positive)
276+
->guard(static fn() => Validation::success($positive))
277+
->otherwise(static fn() => Validation::success($negative))
278+
->match(
279+
static fn($value) => $value,
280+
static fn() => null,
281+
),
282+
);
283+
284+
$assert->same(
285+
$negative,
286+
Validation::success($positive)
287+
->guard(static fn() => Validation::fail($fail))
288+
->otherwise(static fn() => Validation::success($negative))
289+
->match(
290+
static fn($value) => $value,
291+
static fn() => null,
292+
),
293+
);
294+
295+
$assert->same(
296+
[$fail],
297+
Validation::success($positive)
298+
->guard(static fn() => Validation::fail($fail))
299+
->xotherwise(static fn() => Validation::success($negative))
300+
->match(
301+
static fn($value) => $value,
302+
static fn($failures) => $failures->toList(),
303+
),
304+
);
305+
306+
$assert->same(
307+
$negative,
308+
Validation::success($positive)
309+
->flatMap(static fn() => Validation::fail($fail))
310+
->xotherwise(static fn() => Validation::success($negative))
311+
->match(
312+
static fn($value) => $value,
313+
static fn($failures) => $failures->toList(),
314+
),
315+
);
316+
},
317+
);
264318
};

src/Validation.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ public function flatMap(callable $map): self
7878
));
7979
}
8080

81+
/**
82+
* @template T
83+
* @template V
84+
*
85+
* @param callable(S): self<T, V> $map
86+
*
87+
* @return self<F|T, V>
88+
*/
89+
#[\NoDiscard]
90+
public function guard(callable $map): self
91+
{
92+
return new self($this->implementation->guard(
93+
$map,
94+
static fn(self $self) => $self->implementation,
95+
));
96+
}
97+
8198
/**
8299
* @template T
83100
*
@@ -105,6 +122,25 @@ public function otherwise(callable $map): self
105122
return $this->implementation->otherwise($map);
106123
}
107124

125+
/**
126+
* This prevents guarded failures from being recovered.
127+
*
128+
* @template T
129+
* @template V
130+
*
131+
* @param callable(Sequence<F>): self<T, V> $map
132+
*
133+
* @return self<F|T, S|V>
134+
*/
135+
#[\NoDiscard]
136+
public function xotherwise(callable $map): self
137+
{
138+
return $this->implementation->xotherwise(
139+
$map,
140+
static fn(Validation\Implementation $implementation) => new self($implementation),
141+
);
142+
}
143+
108144
/**
109145
* @template A
110146
* @template T

src/Validation/Fail.php

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
final class Fail implements Implementation
2020
{
2121
/**
22-
* @param Sequence<F> $failures
22+
* @param Sequence<F>|Guard<F> $failures
2323
*/
2424
private function __construct(
25-
private Sequence $failures,
25+
private Sequence|Guard $failures,
2626
) {
2727
}
2828

@@ -70,6 +70,34 @@ public function flatMap(callable $map, callable $exfiltrate): Implementation
7070
return $this;
7171
}
7272

73+
/**
74+
* @template T
75+
* @template V
76+
*
77+
* @param callable(S): Validation<T, V> $map
78+
* @param pure-callable(Validation<T, V>): Implementation<T, V> $exfiltrate
79+
*
80+
* @return Implementation<F|T, V>
81+
*/
82+
#[\Override]
83+
public function guard(callable $map, callable $exfiltrate): Implementation
84+
{
85+
/** @var Implementation<F|T, V> */
86+
return $this;
87+
}
88+
89+
#[\Override]
90+
public function guardFailures(): self
91+
{
92+
if ($this->failures instanceof Guard) {
93+
return $this;
94+
}
95+
96+
return new self(new Guard(
97+
$this->failures,
98+
));
99+
}
100+
73101
/**
74102
* @template T
75103
*
@@ -94,6 +122,34 @@ public function mapFailures(callable $map): Implementation
94122
#[\Override]
95123
public function otherwise(callable $map): Validation
96124
{
125+
if ($this->failures instanceof Guard) {
126+
/** @psalm-suppress ImpureFunctionCall */
127+
return $map($this->failures->unwrap());
128+
}
129+
130+
/** @psalm-suppress ImpureFunctionCall */
131+
return $map($this->failures);
132+
}
133+
134+
/**
135+
* @template T
136+
* @template V
137+
*
138+
* @param callable(Sequence<F>): Validation<T, V> $map
139+
* @param callable(Implementation<F|T, S|V>): Validation<F|T, S|V> $wrap
140+
*
141+
* @return Validation<F|T, S|V>
142+
*/
143+
#[\Override]
144+
public function xotherwise(
145+
callable $map,
146+
callable $wrap,
147+
): Validation {
148+
if ($this->failures instanceof Guard) {
149+
/** @psalm-suppress ImpureFunctionCall */
150+
return $wrap($this);
151+
}
152+
97153
/** @psalm-suppress ImpureFunctionCall */
98154
return $map($this->failures);
99155
}
@@ -133,6 +189,11 @@ public function and(Implementation $other, callable $fold): Implementation
133189
#[\Override]
134190
public function match(callable $success, callable $failure)
135191
{
192+
if ($this->failures instanceof Guard) {
193+
/** @psalm-suppress ImpureFunctionCall */
194+
return $failure($this->failures->unwrap());
195+
}
196+
136197
/** @psalm-suppress ImpureFunctionCall */
137198
return $failure($this->failures);
138199
}
@@ -152,6 +213,10 @@ public function maybe(): Maybe
152213
#[\Override]
153214
public function either(): Either
154215
{
216+
if ($this->failures instanceof Guard) {
217+
return Either::left($this->failures->unwrap());
218+
}
219+
155220
return Either::left($this->failures);
156221
}
157222
}

src/Validation/Guard.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Innmind\Immutable\Validation;
5+
6+
use Innmind\Immutable\Sequence;
7+
8+
/**
9+
* @internal
10+
* @template T
11+
* @psalm-immutable
12+
*/
13+
final class Guard
14+
{
15+
/**
16+
* @param Sequence<T> $failures
17+
*/
18+
public function __construct(
19+
private Sequence $failures,
20+
) {
21+
}
22+
23+
/**
24+
* @template U
25+
*
26+
* @param callable(T): U $map
27+
*
28+
* @return self<U>
29+
*/
30+
public function map(callable $map): self
31+
{
32+
return new self($this->failures->map($map));
33+
}
34+
35+
/**
36+
* @param Sequence<T>|self<T> $other
37+
*
38+
* @return self<T>
39+
*/
40+
public function append(Sequence|self $other): self
41+
{
42+
if ($other instanceof self) {
43+
$other = $other->failures;
44+
}
45+
46+
return new self(
47+
$this->failures->append($other),
48+
);
49+
}
50+
51+
/**
52+
* @return Sequence<T>
53+
*/
54+
public function unwrap(): Sequence
55+
{
56+
return $this->failures;
57+
}
58+
}

src/Validation/Implementation.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ public function map(callable $map): self;
3737
*/
3838
public function flatMap(callable $map, callable $exfiltrate): self;
3939

40+
/**
41+
* @template T
42+
* @template V
43+
*
44+
* @param callable(S): Validation<T, V> $map
45+
* @param pure-callable(Validation<T, V>): self<T, V> $exfiltrate
46+
*
47+
* @return self<F|T, V>
48+
*/
49+
public function guard(callable $map, callable $exfiltrate): self;
50+
51+
/**
52+
* @return self<F, S>
53+
*/
54+
public function guardFailures(): self;
55+
4056
/**
4157
* @template T
4258
*
@@ -56,6 +72,20 @@ public function mapFailures(callable $map): self;
5672
*/
5773
public function otherwise(callable $map): Validation;
5874

75+
/**
76+
* @template T
77+
* @template V
78+
*
79+
* @param callable(Sequence<F>): Validation<T, V> $map
80+
* @param callable(self<F|T, S|V>): Validation<F|T, S|V> $wrap
81+
*
82+
* @return Validation<F|T, S|V>
83+
*/
84+
public function xotherwise(
85+
callable $map,
86+
callable $wrap,
87+
): Validation;
88+
5989
/**
6090
* @template A
6191
* @template T

0 commit comments

Comments
 (0)