From b44bd58e8fd37dd5a4808a0a10ef4850296a44e2 Mon Sep 17 00:00:00 2001 From: Mathieu Rochette Date: Sun, 23 Jul 2023 00:03:33 +0200 Subject: [PATCH] Revert "Revert "feature: Add Result\ify()"" This reverts commit b2c07d2907926d56b643d37582871aa04d57be95. --- composer.json | 2 +- phpcs.xml.dist | 1 + src/functions/option.php | 25 +++++++---- src/functions/result.php | 84 ++++++++++++++++++++++++++++++++--- tests/Unit/Result/IfyTest.php | 66 +++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/Result/IfyTest.php diff --git a/composer.json b/composer.json index ad02258..91248e8 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "psr-4": { "TH\\Maybe\\": "src/" }, - "files": ["src/functions/option.php", "src/functions/result.php"] + "files": ["src/functions/option.php", "src/functions/result.php", "src/functions/internal.php"] }, "autoload-dev": { "psr-4": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f7e09e1..abd0e27 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -34,6 +34,7 @@ + diff --git a/src/functions/option.php b/src/functions/option.php index d535ead..128586d 100644 --- a/src/functions/option.php +++ b/src/functions/option.php @@ -3,6 +3,7 @@ namespace TH\Maybe\Option; use TH\DocTest\Attributes\ExamplesSetup; +use TH\Maybe\Internal; use TH\Maybe\Option; use TH\Maybe\Result; use TH\Maybe\Tests\Helpers\IgnoreUnusedResults; @@ -114,7 +115,9 @@ function of(callable $callback, mixed $noneValue = null, bool $strict = true): O * ``` * * @template U + * @template E of \Throwable * @param callable():U $callback + * @param class-string $exceptionClass * @return Option * @throws \Throwable */ @@ -154,7 +157,7 @@ function tryOf( * ``` * * @template U - * @param callable():U $callback + * @param callable(mixed...):U $callback * @return \Closure(mixed...):Option */ function ify(callable $callback, mixed $noneValue = null, bool $strict = true): \Closure @@ -194,7 +197,10 @@ function ify(callable $callback, mixed $noneValue = null, bool $strict = true): * ``` * * @template U - * @param callable():U $callback + * @template E of \Throwable + * @param callable(mixed...):U $callback + * @param class-string $exceptionClass + * @param class-string $additionalExceptionClasses * @return \Closure(mixed...):Option */ function tryIfy( @@ -202,17 +208,20 @@ function tryIfy( mixed $noneValue = null, bool $strict = true, string $exceptionClass = \Exception::class, + string ...$additionalExceptionClasses, ): \Closure { - return static function (...$args) use ($callback, $noneValue, $strict, $exceptionClass): mixed { + return static function (...$args) use ( + $callback, + $noneValue, + $strict, + $exceptionClass, + $additionalExceptionClasses, + ): mixed { try { return Option\fromValue($callback(...$args), $noneValue, $strict); } catch (\Throwable $th) { - if (\is_a($th, $exceptionClass)) { - return Option\none(); - } - - throw $th; + return Internal\trap($th, Option\none(...), $exceptionClass, ...$additionalExceptionClasses); } }; } diff --git a/src/functions/result.php b/src/functions/result.php index e7a7da7..aa01f0c 100644 --- a/src/functions/result.php +++ b/src/functions/result.php @@ -3,6 +3,7 @@ namespace TH\Maybe\Result; use TH\DocTest\Attributes\ExamplesSetup; +use TH\Maybe\Internal; use TH\Maybe\Option; use TH\Maybe\Result; use TH\Maybe\Tests\Helpers\IgnoreUnusedResults; @@ -79,24 +80,93 @@ function err(mixed $value): Result\Err * @template E of \Throwable * @param callable(mixed...):U $callback * @param class-string $exceptionClass + * @param class-string $additionalExceptionClasses * @return Result * @throws \Throwable */ #[ExamplesSetup(IgnoreUnusedResults::class)] -function trap(callable $callback, string $exceptionClass = \Exception::class): Result -{ +function trap( + callable $callback, + string $exceptionClass = \Exception::class, + string ...$additionalExceptionClasses, +): Result { try { /** @var Result */ return Result\ok($callback()); } catch (\Throwable $th) { - if (\is_a($th, $exceptionClass)) { - return Result\err($th); - } - - throw $th; + /** @var Result\Err */ + return Internal\trap($th, Result\err(...), $exceptionClass, ...$additionalExceptionClasses); } } +/** + * Wrap a callable into one that transforms its returned value or thrown exception + * into a `Result` like `Result\trap()` does, but without executing it. + * + * # Examples + * + * Successful execution: + * + * ``` + * self::assertEq(Result\ok(3), Result\ify(fn () => 3)()); + * ``` + * + * Checked exception: + * + * ``` + * $x = Result\ify(fn () => new \DateTimeImmutable("2020-30-30 UTC"))(); + * self::assertTrue($x->isErr()); + * $x->unwrap(); + * // @throws Exception Failed to parse time string (2020-30-30 UTC) at position 6 (0): Unexpected character + * ``` + * + * Unchecked exception: + * + * ``` + * Result\ify(fn () => 1/0)(); + * // @throws DivisionByZeroError Division by zero + * ``` + * + * Result-ify `strtotime()`: + * + * ``` + * $strtotime = Result\ify( + * static fn (...$args) + * => \strtotime(...$args) + * ?: throw new \RuntimeException("Could not convert string to time"), + * ); + * + * self::assertEq($strtotime("2015-09-21 UTC midnight")->unwrap(), 1442793600); + * + * $r = $strtotime("nope"); + * self::assertTrue($r->isErr()); + * $r->unwrap(); // @throws RuntimeException Could not convert string to time + * ``` + * + * @template U + * @template E of \Throwable + * @param callable(mixed...):U $callback + * @param class-string $exceptionClass + * @param class-string $additionalExceptionClasses + * @return \Closure(mixed...):Result + */ +#[ExamplesSetup(IgnoreUnusedResults::class)] +function ify( + callable $callback, + string $exceptionClass = \Exception::class, + string ...$additionalExceptionClasses, +): \Closure { + return static function (...$args) use ($callback, $exceptionClass, $additionalExceptionClasses): Result + { + try { + return Result\ok($callback(...$args)); + } catch (\Throwable $th) { + /** @var Result\Err */ + return Internal\trap($th, Result\err(...), $exceptionClass, ...$additionalExceptionClasses); + } + }; +} + /** * Converts from `Result, E>` to `Result`. * diff --git a/tests/Unit/Result/IfyTest.php b/tests/Unit/Result/IfyTest.php new file mode 100644 index 0000000..656c939 --- /dev/null +++ b/tests/Unit/Result/IfyTest.php @@ -0,0 +1,66 @@ + $value; + + Assert::assertEquals($value, Result\ify($callback)()->unwrap()); + } + + public function testIfyCheckedException(): void + { + $this->expectExceptionObject( + new \Exception( + "Failed to parse time string (nope) at position 0 (n): The timezone could not be found in the database", + ), + ); + + Result\ify( + // @phpstan-ignore-next-line + static fn () => new \DateTimeImmutable("nope"), + )()->unwrap(); + } + + public function testIfyUncheckedException(): void + { + try { + // @phpstan-ignore-next-line + Result\ify(static fn () => 1 / 0)(); + Assert::fail("An exception should have been thrown"); + } catch (\DivisionByZeroError $ex) { + Assert::assertEquals( + "Division by zero", + $ex->getMessage(), + ); + } + } + + /** + * @dataProvider values + */ + public function testIfyWithArguments(mixed $value): void + { + $fileGetContents = Result\ify( + callback: static fn (string $filename): string => match ($content = \file_get_contents($filename)) { + false => throw new \RuntimeException("Can't get content from $filename"), + default => $content, + }, + ); + + Assert::assertIsCallable($fileGetContents); + } +}