From 0608dcce41d6ec6254bed83bb87b57ce7358e992 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Sep 2025 11:22:56 +0200 Subject: [PATCH 1/4] give more flexibility to the attempt implementations --- src/Attempt.php | 20 ++++++++++++++++---- src/Attempt/Defer.php | 32 ++++++++++++++++++-------------- src/Attempt/Error.php | 33 ++++++++++++++++++--------------- src/Attempt/Implementation.php | 31 +++++++++++++++++++++++-------- src/Attempt/Result.php | 33 ++++++++++++++++++--------------- 5 files changed, 93 insertions(+), 56 deletions(-) diff --git a/src/Attempt.php b/src/Attempt.php index 4194f96..2ee0c08 100644 --- a/src/Attempt.php +++ b/src/Attempt.php @@ -108,7 +108,10 @@ 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, + )); } /** @@ -163,7 +166,10 @@ 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, + )); } /** @@ -192,7 +198,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 +214,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..9413d94 100644 --- a/src/Attempt/Defer.php +++ b/src/Attempt/Defer.php @@ -39,11 +39,13 @@ 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 Attempt::defer(static fn() => self::detonate($captured)->flatMap($map)); + return new self(static fn() => self::detonate($captured)->flatMap($map)); } #[\Override] @@ -61,11 +63,13 @@ 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 Attempt::defer(static fn() => self::detonate($captured)->recover($recover)); + return new self(static fn() => self::detonate($captured)->recover($recover)); } #[\Override] @@ -84,21 +88,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..967cd8a 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, }; @@ -37,9 +36,11 @@ public function map(callable $map): self } #[\Override] - public function flatMap(callable $map): Attempt - { - return Attempt::error($this->value); + public function flatMap( + callable $map, + callable $exfiltrate, + ): self { + return $this; } #[\Override] @@ -57,10 +58,12 @@ public function mapError(callable $map): self } #[\Override] - public function recover(callable $recover): Attempt - { + public function recover( + callable $recover, + callable $exfiltrate, + ): Implementation { /** @psalm-suppress ImpureFunctionCall */ - return $recover($this->value); + return $exfiltrate($recover($this->value)); } #[\Override] @@ -75,19 +78,19 @@ public function either(): Either 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 { /** @psalm-suppress ImpureFunctionCall */ - return $error($this->value); + return $exfiltrate($error($this->value)); } } diff --git a/src/Attempt/Implementation.php b/src/Attempt/Implementation.php index 65ae759..76da722 100644 --- a/src/Attempt/Implementation.php +++ b/src/Attempt/Implementation.php @@ -29,10 +29,14 @@ 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): Attempt; + public function flatMap( + callable $map, + callable $exfiltrate, + ): self; /** * @template U @@ -55,10 +59,14 @@ 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; /** * @return Maybe @@ -71,17 +79,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..5ff62c1 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,12 @@ 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 $map($this->value); + return $exfiltrate($map($this->value)); } #[\Override] @@ -53,9 +54,11 @@ 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] @@ -70,19 +73,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)); } } From c73d2a2f89b3e809425b31ffa4eadcacb87f1e3d Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Sep 2025 11:43:39 +0200 Subject: [PATCH 2/4] add Attempt::guard() and ::xrecover() --- CHANGELOG.md | 7 ++++ docs/structures/attempt.md | 28 ++++++++++++++++ proofs/attempt.php | 54 +++++++++++++++++++++++++++++++ src/Attempt.php | 34 ++++++++++++++++++++ src/Attempt/Defer.php | 26 +++++++++++++++ src/Attempt/Error.php | 59 +++++++++++++++++++++++++++++++++- src/Attempt/Guard.php | 21 ++++++++++++ src/Attempt/Implementation.php | 31 ++++++++++++++++++ src/Attempt/Result.php | 23 +++++++++++++ 9 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/Attempt/Guard.php 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..0186c5f 100644 --- a/proofs/attempt.php +++ b/proofs/attempt.php @@ -568,4 +568,58 @@ 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, + ), + ); + }, + ); }; diff --git a/src/Attempt.php b/src/Attempt.php index 2ee0c08..a0a4ee2 100644 --- a/src/Attempt.php +++ b/src/Attempt.php @@ -114,6 +114,22 @@ public function flatMap(callable $map): self )); } + /** + * @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, + )); + } + /** * @template U * @@ -172,6 +188,24 @@ public function recover(callable $recover): self )); } + /** + * 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, + )); + } + /** * @return Maybe */ diff --git a/src/Attempt/Defer.php b/src/Attempt/Defer.php index 9413d94..e6c3ceb 100644 --- a/src/Attempt/Defer.php +++ b/src/Attempt/Defer.php @@ -48,6 +48,22 @@ public function flatMap( return new self(static fn() => self::detonate($captured)->flatMap($map)); } + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): self { + $captured = $this->capture(); + + return new self(static fn() => self::detonate($captured)->guard($map)); + } + + #[\Override] + public function guardError(): self + { + return $this; + } + #[\Override] public function match(callable $result, callable $error) { @@ -72,6 +88,16 @@ public function recover( return new self(static fn() => self::detonate($captured)->recover($recover)); } + #[\Override] + public function xrecover( + callable $recover, + callable $exfiltrate, + ): self { + $captured = $this->capture(); + + return new self(static fn() => self::detonate($captured)->xrecover($recover)); + } + #[\Override] public function maybe(): Maybe { diff --git a/src/Attempt/Error.php b/src/Attempt/Error.php index 967cd8a..8d904b6 100644 --- a/src/Attempt/Error.php +++ b/src/Attempt/Error.php @@ -17,7 +17,7 @@ final class Error implements Implementation { public function __construct( - private \Throwable $value, + private \Throwable|Guard $value, ) { } @@ -43,9 +43,32 @@ public function flatMap( return $this; } + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): self { + return $this; + } + + #[\Override] + public function guardError(): self + { + 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); } @@ -53,6 +76,13 @@ 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)); } @@ -62,6 +92,24 @@ 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 $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)); } @@ -75,6 +123,10 @@ 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); } @@ -90,6 +142,11 @@ public function eitherWay( callable $error, callable $exfiltrate, ): Implementation { + if ($this->value instanceof Guard) { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($error($this->value->unwrap())); + } + /** @psalm-suppress ImpureFunctionCall */ 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 76da722..2569cf4 100644 --- a/src/Attempt/Implementation.php +++ b/src/Attempt/Implementation.php @@ -38,6 +38,24 @@ public function flatMap( 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 guardError(): self; + /** * @template U * @@ -68,6 +86,19 @@ public function 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 */ diff --git a/src/Attempt/Result.php b/src/Attempt/Result.php index 5ff62c1..65b93c7 100644 --- a/src/Attempt/Result.php +++ b/src/Attempt/Result.php @@ -40,6 +40,21 @@ public function flatMap( return $exfiltrate($map($this->value)); } + #[\Override] + public function guard( + callable $map, + callable $exfiltrate, + ): Implementation { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($map($this->value))->guardError(); + } + + #[\Override] + public function guardError(): self + { + return $this; + } + #[\Override] public function match(callable $result, callable $error) { @@ -61,6 +76,14 @@ public function recover( return $this; } + #[\Override] + public function xrecover( + callable $recover, + callable $exfiltrate, + ): self { + return $this; + } + #[\Override] public function maybe(): Maybe { From 8cb13a98df52adec184fc1d10709e3c7113e91e0 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Sep 2025 11:51:41 +0200 Subject: [PATCH 3/4] prove deferred attempts behave the same way --- proofs/attempt.php | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/proofs/attempt.php b/proofs/attempt.php index 0186c5f..def320f 100644 --- a/proofs/attempt.php +++ b/proofs/attempt.php @@ -622,4 +622,58 @@ static function($assert, $positive, $negative) { ); }, ); + + 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, + Atttempt::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, + Atttempt::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, + Atttempt::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, + Atttempt::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, + ), + ); + }, + ); }; From 030df120feaa89ddbe5b2db793fc5d587085c669 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Sep 2025 11:52:42 +0200 Subject: [PATCH 4/4] typo --- proofs/attempt.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proofs/attempt.php b/proofs/attempt.php index def320f..16dcdcd 100644 --- a/proofs/attempt.php +++ b/proofs/attempt.php @@ -633,7 +633,7 @@ static function($assert, $positive, $negative) { $fail = new Exception; $assert->same( $positive, - Atttempt::defer(static fn() => Attempt::result($positive)) + Attempt::defer(static fn() => Attempt::result($positive)) ->guard(static fn() => Attempt::result($positive)) ->recover(static fn() => Attempt::result($negative)) ->match( @@ -644,7 +644,7 @@ static function($assert, $positive, $negative) { $assert->same( $negative, - Atttempt::defer(static fn() => Attempt::result($positive)) + Attempt::defer(static fn() => Attempt::result($positive)) ->guard(static fn() => Attempt::error($fail)) ->recover(static fn() => Attempt::result($negative)) ->match( @@ -655,7 +655,7 @@ static function($assert, $positive, $negative) { $assert->same( $fail, - Atttempt::defer(static fn() => Attempt::result($positive)) + Attempt::defer(static fn() => Attempt::result($positive)) ->guard(static fn() => Attempt::error($fail)) ->xrecover(static fn() => Attempt::result($negative)) ->match( @@ -666,7 +666,7 @@ static function($assert, $positive, $negative) { $assert->same( $negative, - Atttempt::defer(static fn() => Attempt::result($positive)) + Attempt::defer(static fn() => Attempt::result($positive)) ->flatMap(static fn() => Attempt::error($fail)) ->xrecover(static fn() => Attempt::result($negative)) ->match(