diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b3298..f039cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Added + +- `Innmind\Immutable\Attempt::guard()` +- `Innmind\Immutable\Attempt::xrecover()` + ## 5.19.0 - 2025-09-03 ### Added diff --git a/docs/structures/attempt.md b/docs/structures/attempt.md index 7e28077..d8b110f 100644 --- a/docs/structures/attempt.md +++ b/docs/structures/attempt.md @@ -111,6 +111,10 @@ $attempt = Attempt::result(2 - $reduction) If `#!php $reduction` is `#!php 2` then `#!php $attempt` will contain a `DivisionByZeroError` otherwise for any other value it will contain a fraction of `#!php 42`. +## `->guard()` + +This behaves like [`->flatMap()`](#-flatmap) except any error contained in the attempt returned by the callable won't be recovered when calling [`->xrecover()`](#-xrecover). + ## `->match()` This extracts the result value but also forces you to deal with any potential error. @@ -146,6 +150,30 @@ $attempt = Attempt::of(static fn() => 1/0) Here `#!php $attempt` is `#!php 42` because the first `Attempt` raised a `DivisionByZeroError`. +## `->xrecover()` + +This behaves like [`->recover()`](#-recover) except when conjointly used with [`->guard()`](#-guard). Guarded errors can't be recovered. + +An example of this problem is an HTTP router with 2 routes. One tries to handle a `POST` request, then do some logging, the other tries to handle a `GET` request. It would look something like this: + +```php +$response = handlePost($request) + ->flatMap(static fn($response) => log($response)) + ->recover(static fn() => handleGet($request)); +``` + +The problem here is that if the request is indeed a `POST` we handle it then log the response. But if the logging fails then we try to handle it as a `GET` request. In this case we handle the request twice, which isn't good. + +The correct approach is: + +```php +$response = handlePost($request) + ->guard(static fn($response) => log($response)) + ->xrecover(static fn() => handleGet($request)); +``` + +This way if the logging fails it will return this failure and not call `handleGet()`. + ## `->maybe()` This converts an `Attempt` to a `Maybe`. diff --git a/proofs/attempt.php b/proofs/attempt.php index 5df373f..16dcdcd 100644 --- a/proofs/attempt.php +++ b/proofs/attempt.php @@ -568,4 +568,112 @@ static function($assert, $result1, $result2, $error1, $error2) { ); }, ); + + yield proof( + 'Attempt::guard()', + given( + Set::integers()->above(1), + Set::integers()->below(-1), + ), + static function($assert, $positive, $negative) { + $fail = new Exception; + $assert->same( + $positive, + Attempt::result($positive) + ->guard(static fn() => Attempt::result($positive)) + ->recover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + $negative, + Attempt::result($positive) + ->guard(static fn() => Attempt::error($fail)) + ->recover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + $fail, + Attempt::result($positive) + ->guard(static fn() => Attempt::error($fail)) + ->xrecover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn($e) => $e, + ), + ); + + $assert->same( + $negative, + Attempt::result($positive) + ->flatMap(static fn() => Attempt::error($fail)) + ->xrecover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn($e) => $e, + ), + ); + }, + ); + + yield proof( + 'Attempt::defer()->guard()', + given( + Set::integers()->above(1), + Set::integers()->below(-1), + ), + static function($assert, $positive, $negative) { + $fail = new Exception; + $assert->same( + $positive, + Attempt::defer(static fn() => Attempt::result($positive)) + ->guard(static fn() => Attempt::result($positive)) + ->recover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + $negative, + Attempt::defer(static fn() => Attempt::result($positive)) + ->guard(static fn() => Attempt::error($fail)) + ->recover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $assert->same( + $fail, + Attempt::defer(static fn() => Attempt::result($positive)) + ->guard(static fn() => Attempt::error($fail)) + ->xrecover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn($e) => $e, + ), + ); + + $assert->same( + $negative, + Attempt::defer(static fn() => Attempt::result($positive)) + ->flatMap(static fn() => Attempt::error($fail)) + ->xrecover(static fn() => Attempt::result($negative)) + ->match( + static fn($value) => $value, + static fn($e) => $e, + ), + ); + }, + ); }; diff --git a/src/Attempt.php b/src/Attempt.php index 4194f96..a0a4ee2 100644 --- a/src/Attempt.php +++ b/src/Attempt.php @@ -108,7 +108,26 @@ public function map(callable $map): self #[\NoDiscard] public function flatMap(callable $map): self { - return $this->implementation->flatMap($map); + return new self($this->implementation->flatMap( + $map, + static fn(self $self) => $self->implementation, + )); + } + + /** + * @template U + * + * @param callable(T): self $map + * + * @return self + */ + #[\NoDiscard] + public function guard(callable $map): self + { + return new self($this->implementation->guard( + $map, + static fn(self $self) => $self->implementation, + )); } /** @@ -163,7 +182,28 @@ public function mapError(callable $map): self #[\NoDiscard] public function recover(callable $recover): self { - return $this->implementation->recover($recover); + return new self($this->implementation->recover( + $recover, + static fn(self $self) => $self->implementation, + )); + } + + /** + * This prevents guarded errors from being recovered. + * + * @template U + * + * @param callable(\Throwable): self $recover + * + * @return self + */ + #[\NoDiscard] + public function xrecover(callable $recover): self + { + return new self($this->implementation->xrecover( + $recover, + static fn(self $self) => $self->implementation, + )); } /** @@ -192,7 +232,9 @@ public function either(): Either #[\NoDiscard] public function memoize(): self { - return $this->implementation->memoize(); + return new self($this->implementation->memoize( + static fn(self $self) => $self->implementation, + )); } /** @@ -206,6 +248,10 @@ public function memoize(): self #[\NoDiscard] public function eitherWay(callable $result, callable $error): self { - return $this->implementation->eitherWay($result, $error); + return new self($this->implementation->eitherWay( + $result, + $error, + static fn(self $self) => $self->implementation, + )); } } diff --git a/src/Attempt/Defer.php b/src/Attempt/Defer.php index 639f3f2..e6c3ceb 100644 --- a/src/Attempt/Defer.php +++ b/src/Attempt/Defer.php @@ -39,11 +39,29 @@ public function map(callable $map): self } #[\Override] - public function flatMap(callable $map): Attempt - { + public function flatMap( + callable $map, + callable $exfiltrate, + ): self { + $captured = $this->capture(); + + return new self(static fn() => self::detonate($captured)->flatMap($map)); + } + + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): self { $captured = $this->capture(); - return Attempt::defer(static fn() => self::detonate($captured)->flatMap($map)); + return new self(static fn() => self::detonate($captured)->guard($map)); + } + + #[\Override] + public function guardError(): self + { + return $this; } #[\Override] @@ -61,11 +79,23 @@ public function mapError(callable $map): self } #[\Override] - public function recover(callable $recover): Attempt - { + public function recover( + callable $recover, + callable $exfiltrate, + ): self { + $captured = $this->capture(); + + return new self(static fn() => self::detonate($captured)->recover($recover)); + } + + #[\Override] + public function xrecover( + callable $recover, + callable $exfiltrate, + ): self { $captured = $this->capture(); - return Attempt::defer(static fn() => self::detonate($captured)->recover($recover)); + return new self(static fn() => self::detonate($captured)->xrecover($recover)); } #[\Override] @@ -84,21 +114,21 @@ public function either(): Either return Either::defer(static fn() => self::detonate($captured)->either()); } - /** - * @return Attempt - */ #[\Override] - public function memoize(): Attempt + public function memoize(callable $exfiltrate): Implementation { - return $this->unwrap(); + return $exfiltrate($this->unwrap()); } #[\Override] - public function eitherWay(callable $result, callable $error): Attempt - { + public function eitherWay( + callable $result, + callable $error, + callable $exfiltrate, + ): self { $captured = $this->capture(); - return Attempt::defer( + return new self( static fn() => self::detonate($captured)->eitherWay($result, $error), ); } diff --git a/src/Attempt/Error.php b/src/Attempt/Error.php index 09c2bea..8d904b6 100644 --- a/src/Attempt/Error.php +++ b/src/Attempt/Error.php @@ -4,7 +4,6 @@ namespace Innmind\Immutable\Attempt; use Innmind\Immutable\{ - Attempt, Maybe, Either, }; @@ -18,7 +17,7 @@ final class Error implements Implementation { public function __construct( - private \Throwable $value, + private \Throwable|Guard $value, ) { } @@ -37,14 +36,39 @@ public function map(callable $map): self } #[\Override] - public function flatMap(callable $map): Attempt + public function flatMap( + callable $map, + callable $exfiltrate, + ): self { + return $this; + } + + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): self { + return $this; + } + + #[\Override] + public function guardError(): self { - return Attempt::error($this->value); + if ($this->value instanceof Guard) { + return $this; + } + + return new self(new Guard($this->value)); } #[\Override] public function match(callable $result, callable $error) { + if ($this->value instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $error($this->value->unwrap()); + } + /** @psalm-suppress ImpureFunctionCall */ return $error($this->value); } @@ -52,15 +76,42 @@ public function match(callable $result, callable $error) #[\Override] public function mapError(callable $map): self { + if ($this->value instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return new self(new Guard( + $map($this->value->unwrap()), + )); + } + /** @psalm-suppress ImpureFunctionCall */ return new self($map($this->value)); } #[\Override] - public function recover(callable $recover): Attempt - { + public function recover( + callable $recover, + callable $exfiltrate, + ): Implementation { + if ($this->value instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($recover($this->value->unwrap())); + } + /** @psalm-suppress ImpureFunctionCall */ - return $recover($this->value); + return $exfiltrate($recover($this->value)); + } + + #[\Override] + public function xrecover( + callable $recover, + callable $exfiltrate, + ): Implementation { + if ($this->value instanceof Guard) { + return $this; + } + + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($recover($this->value)); } #[\Override] @@ -72,22 +123,31 @@ public function maybe(): Maybe #[\Override] public function either(): Either { + if ($this->value instanceof Guard) { + return Either::left($this->value->unwrap()); + } + return Either::left($this->value); } - /** - * @return Attempt - */ #[\Override] - public function memoize(): Attempt + public function memoize(callable $exfiltrate): self { - return Attempt::error($this->value); + return $this; } #[\Override] - public function eitherWay(callable $result, callable $error): Attempt - { + public function eitherWay( + callable $result, + callable $error, + callable $exfiltrate, + ): Implementation { + if ($this->value instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($error($this->value->unwrap())); + } + /** @psalm-suppress ImpureFunctionCall */ - return $error($this->value); + return $exfiltrate($error($this->value)); } } diff --git a/src/Attempt/Guard.php b/src/Attempt/Guard.php new file mode 100644 index 0000000..3a4b755 --- /dev/null +++ b/src/Attempt/Guard.php @@ -0,0 +1,21 @@ +e; + } +} diff --git a/src/Attempt/Implementation.php b/src/Attempt/Implementation.php index 65ae759..2569cf4 100644 --- a/src/Attempt/Implementation.php +++ b/src/Attempt/Implementation.php @@ -29,10 +29,32 @@ public function map(callable $map): self; * @template U * * @param callable(T): Attempt $map + * @param pure-callable(Attempt): self $exfiltrate * - * @return Attempt + * @return self + */ + public function flatMap( + callable $map, + callable $exfiltrate, + ): self; + + /** + * @template U + * + * @param callable(T): Attempt $map + * @param pure-callable(Attempt): self $exfiltrate + * + * @return self + */ + public function guard( + callable $map, + callable $exfiltrate, + ): self; + + /** + * @return self */ - public function flatMap(callable $map): Attempt; + public function guardError(): self; /** * @template U @@ -55,10 +77,27 @@ public function mapError(callable $map): self; * @template U * * @param callable(\Throwable): Attempt $recover + * @param pure-callable(Attempt): self $exfiltrate * - * @return Attempt + * @return self */ - public function recover(callable $recover): Attempt; + public function recover( + callable $recover, + callable $exfiltrate, + ): self; + + /** + * @template U + * + * @param callable(\Throwable): Attempt $recover + * @param pure-callable(Attempt): self $exfiltrate + * + * @return self + */ + public function xrecover( + callable $recover, + callable $exfiltrate, + ): self; /** * @return Maybe @@ -71,17 +110,24 @@ public function maybe(): Maybe; public function either(): Either; /** - * @return Attempt + * @param pure-callable(Attempt): self $exfiltrate + * + * @return self */ - public function memoize(): Attempt; + public function memoize(callable $exfiltrate): self; /** * @template V * * @param callable(T): Attempt $result * @param callable(\Throwable): Attempt $error + * @param pure-callable(Attempt): self $exfiltrate * - * @return Attempt + * @return self */ - public function eitherWay(callable $result, callable $error): Attempt; + public function eitherWay( + callable $result, + callable $error, + callable $exfiltrate, + ): self; } diff --git a/src/Attempt/Result.php b/src/Attempt/Result.php index 876c831..65b93c7 100644 --- a/src/Attempt/Result.php +++ b/src/Attempt/Result.php @@ -4,7 +4,6 @@ namespace Innmind\Immutable\Attempt; use Innmind\Immutable\{ - Attempt, Maybe, Either, }; @@ -33,10 +32,27 @@ public function map(callable $map): self } #[\Override] - public function flatMap(callable $map): Attempt - { + public function flatMap( + callable $map, + callable $exfiltrate, + ): Implementation { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($map($this->value)); + } + + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): Implementation { /** @psalm-suppress ImpureFunctionCall */ - return $map($this->value); + return $exfiltrate($map($this->value))->guardError(); + } + + #[\Override] + public function guardError(): self + { + return $this; } #[\Override] @@ -53,9 +69,19 @@ public function mapError(callable $map): self } #[\Override] - public function recover(callable $recover): Attempt - { - return Attempt::result($this->value); + public function recover( + callable $recover, + callable $exfiltrate, + ): self { + return $this; + } + + #[\Override] + public function xrecover( + callable $recover, + callable $exfiltrate, + ): self { + return $this; } #[\Override] @@ -70,19 +96,19 @@ public function either(): Either return Either::right($this->value); } - /** - * @return Attempt - */ #[\Override] - public function memoize(): Attempt + public function memoize(callable $exfiltrate): self { - return Attempt::result($this->value); + return $this; } #[\Override] - public function eitherWay(callable $result, callable $error): Attempt - { + public function eitherWay( + callable $result, + callable $error, + callable $exfiltrate, + ): Implementation { /** @psalm-suppress ImpureFunctionCall */ - return $result($this->value); + return $exfiltrate($result($this->value)); } }