From 8a7d3c344c7144ddfd7762a4f00d444367949872 Mon Sep 17 00:00:00 2001 From: Mathieu Rochette Date: Sun, 23 Jul 2023 00:03:29 +0200 Subject: [PATCH 1/2] Revert "Revert "Feature: add Option::ify() & Option::tryIfy()"" This reverts commit 95dc3bfc4e879abbbfd1935168bb286b5d735815. --- src/functions/option.php | 84 ++++++++++++++++++++++++++++++++++- tests/Unit/Option/IfyTest.php | 72 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Option/IfyTest.php diff --git a/src/functions/option.php b/src/functions/option.php index 8c99001..d535ead 100644 --- a/src/functions/option.php +++ b/src/functions/option.php @@ -114,9 +114,7 @@ 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 */ @@ -137,6 +135,88 @@ function tryOf( } } +/** + * Wrap a callable into one that transforms its result into an `Option`. + * It will be a `Some` option containing the result if it is different from `$noneValue` (default `null`). + * + * # Examples + * + * Successful execution: + * + * ``` + * self::assertEq(Option\ify(strtolower(...))("FRUITS"), Option\some("fruits")); + * ``` + * + * Convertion of `null` to `Option\None`: + * + * ``` + * self::assertEq(Option\ify(fn() => null)(), Option\none()); + * ``` + * + * @template U + * @param callable():U $callback + * @return \Closure(mixed...):Option + */ +function ify(callable $callback, mixed $noneValue = null, bool $strict = true): \Closure +{ + return static fn (...$args) => Option\fromValue($callback(...$args), $noneValue, $strict); +} + +/** + * Wrap a callable into one that transforms its result into an `Option` like `Option\ify()` does + * but also return `Option\None` if it an exception matching $exceptionClass was thrown. + * + * # Examples + * + * Successful execution: + * + * ``` + * self::assertEq(Option\tryIfy(strtolower(...))("FRUITS"), Option\some("fruits")); + * ``` + * + * Convertion of `null` to `Option\None`: + * + * ``` + * self::assertEq(Option\tryIfy(fn() => null)(), Option\none()); + * ``` + * + * Checked Exception: + * + * ``` + * self::assertEq(Option\tryIfy(fn () => new \DateTimeImmutable("nope"))(), Option\none()); + * ``` + * + * Unchecked Exception: + * + * ``` + * self::assertEq(Option\tryIfy(fn () => 1 / 0)(), Option\none()); + * // @throws DivisionByZeroError Division by zero + * ``` + * + * @template U + * @param callable():U $callback + * @return \Closure(mixed...):Option + */ +function tryIfy( + callable $callback, + mixed $noneValue = null, + bool $strict = true, + string $exceptionClass = \Exception::class, +): \Closure +{ + return static function (...$args) use ($callback, $noneValue, $strict, $exceptionClass): mixed { + try { + return Option\fromValue($callback(...$args), $noneValue, $strict); + } catch (\Throwable $th) { + if (\is_a($th, $exceptionClass)) { + return Option\none(); + } + + throw $th; + } + }; +} + /** * Converts from `Option>` to `Option`. * diff --git a/tests/Unit/Option/IfyTest.php b/tests/Unit/Option/IfyTest.php new file mode 100644 index 0000000..6a765bd --- /dev/null +++ b/tests/Unit/Option/IfyTest.php @@ -0,0 +1,72 @@ + $expected + */ + public function testIfy(Option $expected, mixed $value, mixed $noneValue, bool $strict = true): void + { + Assert::assertEquals($expected, Option\ify(static fn () => $value, $noneValue, strict: $strict)()); + } + + /** + * @dataProvider fromValueMatrix + * @param Option $expected + */ + public function testTryOf(Option $expected, mixed $value, mixed $noneValue, bool $strict = true): void + { + Assert::assertEquals($expected, Option\tryIfy(static fn () => $value, $noneValue, strict: $strict)()); + } + + public function testOfDefaultToNull(): void + { + Assert::assertEquals(Option\none(), Option\ify(static fn () => null)()); + Assert::assertEquals(Option\some(1), Option\ify(static fn () => 1)()); + } + + public function testTryOfDefaultToNull(): void + { + Assert::assertEquals(Option\none(), Option\tryIfy(static fn () => null)()); + Assert::assertEquals(Option\some(1), Option\tryIfy(static fn () => 1)()); + } + + public function testOfDefaultToStrict(): void + { + $o = (object)[]; + + Assert::assertEquals(Option\none(), Option\ify(static fn () => $o, (object)[], strict: false)()); + Assert::assertEquals($o, Option\ify(static fn () => $o, (object)[])()->unwrap()); + } + + public function testTryOfDefaultToStrict(): void + { + $o = (object)[]; + + Assert::assertEquals(Option\none(), Option\tryIfy(static fn () => $o, (object)[], strict: false)()); + Assert::assertEquals($o, Option\tryIfy(static fn () => $o, (object)[])()->unwrap()); + } + + public function testTryOfExeptions(): void + { + // @phpstan-ignore-next-line + Assert::assertEquals(Option\none(), Option\tryIfy(static fn () => new \DateTimeImmutable("nope"))()); + + try { + // @phpstan-ignore-next-line + Option\tryIfy(static fn () => 1 / 0)(); + Assert::fail("An exception should have been thrown"); + } catch (\DivisionByZeroError) { + } + } +} From b44bd58e8fd37dd5a4808a0a10ef4850296a44e2 Mon Sep 17 00:00:00 2001 From: Mathieu Rochette Date: Sun, 23 Jul 2023 00:03:33 +0200 Subject: [PATCH 2/2] 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); + } +}