diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bddb3b..1af2746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Added + +- `Innmind\Immutable\Validation::guard()` +- `Innmind\Immutable\Validation::xotherwise()` + ## 5.18.0 - 2025-08-08 ### Added diff --git a/docs/structures/validation.md b/docs/structures/validation.md index 718bceb..2bc257f 100644 --- a/docs/structures/validation.md +++ b/docs/structures/validation.md @@ -71,6 +71,10 @@ $validation = isEmail('foo@example.com'); $localEmail = $either->flatMap(fn(string $email): Validation => isLocal($email)); ``` +## `->guard()` + +This behaves like [`->flatMap()`](#-flatmap) except any failure contained in the validation returned by the callable won't be recovered when calling [`->xotherwise()`](#-xotherwise). + ## `->match()` This is the only way to extract the wrapped value. @@ -98,6 +102,30 @@ $email = isEmail('invalid value') ->otherwise(fn() => isEmail('foo@example.com')); ``` +## `->xotherwise()` + +This behaves like [`->otherwise()`](#-otherwise) except when conjointly used with [`->guard()`](#-guard). Guarded failures can't be recovered. + +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: + +```php +$result = validatePost($request) + ->flatMap(static fn() => validateBody($request)) + ->otherwise(static fn() => validateGet($request)); +``` + +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. + +The correct approach is: + +```php +$result = validatePost($request) + ->guard(static fn() => validateBody($request)) + ->xotherwise(static fn() => validateGet($request)); +``` + +This way if the body validation fails it will return this failure and not that it's not a `GET`. + ## `->mapFailures()` This is similar to the `->map()` function but will be applied on each failure. diff --git a/proofs/validation.php b/proofs/validation.php index b0ec6e8..f048212 100644 --- a/proofs/validation.php +++ b/proofs/validation.php @@ -261,4 +261,58 @@ static function($assert, $a, $b) { ); }, ); + + yield proof( + 'Validation::guard()', + given( + Set::integers()->above(1), + Set::integers()->below(-1), + Set::type(), + ), + static function($assert, $positive, $negative, $fail) { + $assert->same( + $positive, + Validation::success($positive) + ->guard(static fn() => Validation::success($positive)) + ->otherwise(static fn() => Validation::success($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + $negative, + Validation::success($positive) + ->guard(static fn() => Validation::fail($fail)) + ->otherwise(static fn() => Validation::success($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + [$fail], + Validation::success($positive) + ->guard(static fn() => Validation::fail($fail)) + ->xotherwise(static fn() => Validation::success($negative)) + ->match( + static fn($value) => $value, + static fn($failures) => $failures->toList(), + ), + ); + + $assert->same( + $negative, + Validation::success($positive) + ->flatMap(static fn() => Validation::fail($fail)) + ->xotherwise(static fn() => Validation::success($negative)) + ->match( + static fn($value) => $value, + static fn($failures) => $failures->toList(), + ), + ); + }, + ); }; diff --git a/src/Validation.php b/src/Validation.php index 8c8b2b8..26200ac 100644 --- a/src/Validation.php +++ b/src/Validation.php @@ -78,6 +78,23 @@ public function flatMap(callable $map): self )); } + /** + * @template T + * @template V + * + * @param callable(S): self $map + * + * @return self + */ + #[\NoDiscard] + public function guard(callable $map): self + { + return new self($this->implementation->guard( + $map, + static fn(self $self) => $self->implementation, + )); + } + /** * @template T * @@ -105,6 +122,25 @@ public function otherwise(callable $map): self return $this->implementation->otherwise($map); } + /** + * This prevents guarded failures from being recovered. + * + * @template T + * @template V + * + * @param callable(Sequence): self $map + * + * @return self + */ + #[\NoDiscard] + public function xotherwise(callable $map): self + { + return $this->implementation->xotherwise( + $map, + static fn(Validation\Implementation $implementation) => new self($implementation), + ); + } + /** * @template A * @template T diff --git a/src/Validation/Fail.php b/src/Validation/Fail.php index 88b9229..263eb36 100644 --- a/src/Validation/Fail.php +++ b/src/Validation/Fail.php @@ -19,10 +19,10 @@ final class Fail implements Implementation { /** - * @param Sequence $failures + * @param Sequence|Guard $failures */ private function __construct( - private Sequence $failures, + private Sequence|Guard $failures, ) { } @@ -70,6 +70,34 @@ public function flatMap(callable $map, callable $exfiltrate): Implementation return $this; } + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): Implementation $exfiltrate + * + * @return Implementation + */ + #[\Override] + public function guard(callable $map, callable $exfiltrate): Implementation + { + /** @var Implementation */ + return $this; + } + + #[\Override] + public function guardFailures(): self + { + if ($this->failures instanceof Guard) { + return $this; + } + + return new self(new Guard( + $this->failures, + )); + } + /** * @template T * @@ -94,6 +122,34 @@ public function mapFailures(callable $map): Implementation #[\Override] public function otherwise(callable $map): Validation { + if ($this->failures instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this->failures->unwrap()); + } + + /** @psalm-suppress ImpureFunctionCall */ + return $map($this->failures); + } + + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * @param callable(Implementation): Validation $wrap + * + * @return Validation + */ + #[\Override] + public function xotherwise( + callable $map, + callable $wrap, + ): Validation { + if ($this->failures instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $wrap($this); + } + /** @psalm-suppress ImpureFunctionCall */ return $map($this->failures); } @@ -133,6 +189,11 @@ public function and(Implementation $other, callable $fold): Implementation #[\Override] public function match(callable $success, callable $failure) { + if ($this->failures instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $failure($this->failures->unwrap()); + } + /** @psalm-suppress ImpureFunctionCall */ return $failure($this->failures); } @@ -152,6 +213,10 @@ public function maybe(): Maybe #[\Override] public function either(): Either { + if ($this->failures instanceof Guard) { + return Either::left($this->failures->unwrap()); + } + return Either::left($this->failures); } } diff --git a/src/Validation/Guard.php b/src/Validation/Guard.php new file mode 100644 index 0000000..4c8d806 --- /dev/null +++ b/src/Validation/Guard.php @@ -0,0 +1,58 @@ + $failures + */ + public function __construct( + private Sequence $failures, + ) { + } + + /** + * @template U + * + * @param callable(T): U $map + * + * @return self + */ + public function map(callable $map): self + { + return new self($this->failures->map($map)); + } + + /** + * @param Sequence|self $other + * + * @return self + */ + public function append(Sequence|self $other): self + { + if ($other instanceof self) { + $other = $other->failures; + } + + return new self( + $this->failures->append($other), + ); + } + + /** + * @return Sequence + */ + public function unwrap(): Sequence + { + return $this->failures; + } +} diff --git a/src/Validation/Implementation.php b/src/Validation/Implementation.php index a2b535c..35ff647 100644 --- a/src/Validation/Implementation.php +++ b/src/Validation/Implementation.php @@ -37,6 +37,22 @@ public function map(callable $map): self; */ public function flatMap(callable $map, callable $exfiltrate): self; + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): self $exfiltrate + * + * @return self + */ + public function guard(callable $map, callable $exfiltrate): self; + + /** + * @return self + */ + public function guardFailures(): self; + /** * @template T * @@ -56,6 +72,20 @@ public function mapFailures(callable $map): self; */ public function otherwise(callable $map): Validation; + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * @param callable(self): Validation $wrap + * + * @return Validation + */ + public function xotherwise( + callable $map, + callable $wrap, + ): Validation; + /** * @template A * @template T diff --git a/src/Validation/Success.php b/src/Validation/Success.php index 8ab4625..2f0fce9 100644 --- a/src/Validation/Success.php +++ b/src/Validation/Success.php @@ -70,6 +70,28 @@ public function flatMap(callable $map, callable $exfiltrate): Implementation return $exfiltrate($map($this->value)); } + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): Implementation $exfiltrate + * + * @return Implementation + */ + #[\Override] + public function guard(callable $map, callable $exfiltrate): Implementation + { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($map($this->value))->guardFailures(); + } + + #[\Override] + public function guardFailures(): self + { + return $this; + } + /** * @template T * @@ -98,6 +120,23 @@ public function otherwise(callable $map): Validation return Validation::success($this->value); } + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * @param callable(Implementation): Validation $wrap + * + * @return Validation + */ + #[\Override] + public function xotherwise( + callable $map, + callable $wrap, + ): Validation { + return Validation::success($this->value); + } + /** * @template A * @template T