From fad428a4e40b606987499b17bb2d5b7d4b04502d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 29 Jul 2024 13:58:33 -0400 Subject: [PATCH] Add contains, exists, toArray - Update docs --- README.md | 32 +++++++------ composer.lock | 4 +- src/Option.php | 80 +++++++++++++++++++++---------- src/Result.php | 48 ++++++++++++++++--- tests/OptionTest.php | 109 +++++++++++++++++++++++++++++++------------ tests/ResultTest.php | 65 ++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 7fdca88..3eac309 100644 --- a/README.md +++ b/README.md @@ -6,26 +6,32 @@ This project contains PHP utility classes whose functionality is inspired by the This early-stage library currently provides two classes, both of which are designed to wrap values and indicate the state of the action that produced them. `Option` represents a variable that may or may not have a value. `Result` represents the result of an action; the "ok" and "error" states both provide a value. -| | `Option`
Replaces `null` checks | `Result`
Replaces exception-based error handling | -|----------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------------------------------| -| **Creating** | `::Some(T)` for Some
`::None()` for None
`::of($value)` _None if `null`_ | `::OK(TOK)` for OK
`::Error(TError)` for Error | -| **Querying** | `->isSome(): bool`
`->isNone(): bool` | `->isOK(): bool`
`->isError(): bool` | -| **Reading**
_throws if called on missing value_ | `->get(): T` | `->getOK(): TOK`
`->getError(): TError` | -| **Transforming**
_still `Option` or `Result`_ | `->map(callable(T): U): U` | `->map(callable(TOK): U): U`
`->mapError(callable(TError): U): U` | -| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` | -| **Inspecting**
_returns the original instance_ | `->tap(callable(Option): void): void` | `->tap(callable(Result): void): void` | -| **Continued Processing** | `->bind(callable(T): Option): Option` | `->bind(callable(TOK): Result): Result` | +| | `Option`
Replaces `null` checks | `Result`
Replaces exception-based error handling | +|---------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------------------------------| +| **Creating** | `::Some(T)` for Some | `::OK(TOK)` for OK | +| | `::None()` for None | `::Error(TError)` for Error | +| | `::of($value)` _None if `null`_ | | +| **Querying** | `->isSome(): bool` | `->isOK(): bool` | +| | `->isNone(): bool` | `->isError(): bool` | +| | `->contains(T, $strict = true): bool` | `->contains(TOK, $strict = true): bool` | +| | `->exists(callable(T): bool): bool` | `->exists(callable(TOK): bool): bool` | +| **Reading**
| `->get(): T` | `->getOK(): TOK` | +| _all throw if called on missing value_ | | `->getError(): TError` | +| **Transforming**
| `->map(callable(T): TMapped): Option` | `->map(callable(TOK): TMapped): Result` | +| _all still `Option` or `Result`_ | | `->mapError(callable(TError): TMapped): Result` | +| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` | +| **Inspecting**
_returns the original instance_ | `->tap(callable(Option): void): Option` | `->tap(callable(Result): void): Result` | +| **Continued Processing** | `->bind(callable(T): Option): Option` | `->bind(callable(TOK): Result): Result` | +| **Changing Types** | `->toArray(): T[]` | `->toArray(): TOK[]` | +| | | `->toOption(): Option` | In addition to this, `Option` provides: - `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None. - `->getOrCall(callable(): mixed)` will call the given function if the option is None. That function may return a value, or may be `void` or `never`. +- `->getOrThrow(callable(): Exception)` will return the Some value if it exists, or throw the exception returned by the function if the option is None. - `->filter(callable(T): bool)` will compare a Some value against the callable, and if it returns `true`, will remain Some; if it returns `false`, the value will become None. -- `->is(T, $strict = true)` will return `true` if the option is Some and the value matches. Strict equality (the default) uses `===` for the comparison; if strict is set to `false`, the comparison will use `==` instead. - `->unwrap()` will return `null` for None options and the value for Some options. -`Result` also provides: -- `toOption()` will transform an OK result to a Some option, and an Error result to a None option. - Finally, we would be remiss to not acknowledge some really cool prior art in this area - the [PhpOption](https://github.com/schmittjoh/php-option) project. `Option::of` recognizes their options and converts them properly, and `Option` instances have a `->toPhpOption()` method that will convert these back into PhpOption's `Some` and `None` instances. There is also a [ResultType](https://github.com/GrahamCampbell/Result-Type) project from the same team, though this project's result does not (yet) have any conversion methods for it. ## The Inspiration diff --git a/composer.lock b/composer.lock index 5ebc5d5..14afaf3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c9d70d95d369f37fa95a10637a56a58", + "content-hash": "9c0a6b77d5d66ee91ab9946d5f5e2107", "packages": [], "packages-dev": [ { @@ -1721,7 +1721,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8" + "php": "^8.2" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/src/Option.php b/src/Option.php index 8177c1e..4f2a04b 100644 --- a/src/Option.php +++ b/src/Option.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace BitBadger\InspiredByFSharp; use Error; +use Exception; use InvalidArgumentException; /** @@ -84,15 +85,26 @@ readonly class Option /** * Get the value, or return the value of a callable function * - * @template U The return type of the callable provided - * @param callable(): U $f The callable function to use for `None` options - * @return T|mixed The value if `Some`, the result of the callable if `None` + * @template TCalled The return type of the callable provided + * @param callable(): TCalled $f The callable function to use for `None` options + * @return T|TCalled The value if `Some`, the result of the callable if `None` */ public function getOrCall(callable $f): mixed { return $this->value ?? $f(); } + /** + * Get the value, or throw the + * @param callable(): Exception $exFunc A function to construct the exception to throw + * @return T The value of the option if `Some` + * @throws Exception If the option is `None` + */ + public function getOrThrow(callable $exFunc): mixed + { + return $this->value ?? throw $exFunc(); + } + /** * Bind a function to this option (railway processing) * @@ -108,12 +120,38 @@ readonly class Option return $this->isNone() ? $this : $f($this->get()); } + /** + * Does the option contain the given value? + * + * @param T $value The value to be checked + * @param bool $strict True for strict equality (`===`), false for loose equality (`==`); optional, default is true + * @return bool True if the value matches, false if not; `None` always returns false + */ + public function contains(mixed $value, bool $strict = true): bool + { + return match (true) { + $this->isNone() => false, + default => $strict ? $this->value === $value : $this->value == $value, + }; + } + + /** + * Does the value of the option match the given predicate function? + * + * @param callable(T): bool $f The function to determine whether the value matches + * @return bool True if the `Some` value matches the function, false otherwise + */ + public function exists(callable $f): bool + { + return $this->isSome() ? $f($this->value) : false; + } + /** * Map this optional value to another value * - * @template U The type of the mapping function - * @param callable(T): U $f The mapping function - * @return Option A `Some` instance with the transformed value if `Some`, `None` otherwise + * @template TMapped The type of the mapping function + * @param callable(T): TMapped $f The mapping function + * @return Option A `Some` instance with the transformed value if `Some`, `None` otherwise */ public function map(callable $f): self { @@ -140,25 +178,7 @@ readonly class Option */ public function filter(callable $f): self { - return match (true) { - $this->isNone() => $this, - default => $f($this->value) ? $this : self::None(), - }; - } - - /** - * Does the option have the given value? - * - * @param T $value The value to be checked - * @param bool $strict True for strict equality (`===`), false for loose equality (`==`) - * @return bool True if the value matches, false if not; `None` always returns false - */ - public function is(mixed $value, bool $strict = true): bool - { - return match (true) { - $this->isNone() => false, - default => $strict ? $this->value === $value : $this->value == $value, - }; + return $this->isNone() || $this->exists($f) ? $this : self::None(); } /** @@ -183,6 +203,16 @@ readonly class Option return $this; } + /** + * Convert this option into a 0 or 1 item array + * + * @return T[] An empty array for `None`, a 1-item array for `Some` + */ + public function toArray(): array + { + return $this->isSome() ? [$this->value] : []; + } + /** * Convert this to a PhpOption option * diff --git a/src/Result.php b/src/Result.php index 06fd644..922078b 100644 --- a/src/Result.php +++ b/src/Result.php @@ -103,12 +103,38 @@ readonly class Result return $this->isError() ? $this : $f($this->getOK()); } + /** + * Does this result's "OK" value match the given value? + * + * @param TOK $value The value to be matched + * @param bool $strict True for strict equality (`===`), false for loose equality (`==`); optional, default is true + * @return bool True if the "OK" value matches the one provided, false otherwise + */ + public function contains(mixed $value, bool $strict = true): bool + { + return match (true) { + $this->isError() => false, + default => $this->okValue->contains($value, $strict), + }; + } + + /** + * Does the "OK" value of this result match the given predicate function? + * + * @param callable(TOK): bool $f The function to determine whether the value matches + * @return bool True if the OK value matches the function, false otherwise + */ + public function exists(callable $f): bool + { + return $this->isOK() ? $f($this->okValue->get()) : false; + } + /** * Map an `OK` result to another, leaving an `Error` result unmodified * - * @template U The type of the mapping function - * @param callable(TOK): U $f The mapping function - * @return Result A transformed `OK` instance or the original `Error` instance + * @template TMapped The type of the mapping function + * @param callable(TOK): TMapped $f The mapping function + * @return Result A transformed `OK` instance or the original `Error` instance */ public function map(callable $f): self { @@ -118,9 +144,9 @@ readonly class Result /** * Map an `Error` result to another, leaving an `OK` result unmodified * - * @template U The type of the mapping function - * @param callable(TError): U $f The mapping function - * @return Result A transformed `Error` instance or the original `OK` instance + * @template TMapped The type of the mapping function + * @param callable(TError): TMapped $f The mapping function + * @return Result A transformed `Error` instance or the original `OK` instance */ public function mapError(callable $f): self { @@ -139,6 +165,16 @@ readonly class Result } } + /** + * Convert this result into a 0 or 1 item array + * + * @return TOK[] An empty array for `Error`, a 1-item array for `OK` + */ + public function toArray(): array + { + return $this->okValue->toArray(); + } + /** * Transform a `Result`'s `OK` value to an `Option` * diff --git a/tests/OptionTest.php b/tests/OptionTest.php index f32d6f4..f1638d8 100644 --- a/tests/OptionTest.php +++ b/tests/OptionTest.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace Test; +use BadMethodCallException; use BitBadger\InspiredByFSharp\Option; use InvalidArgumentException; use PhpOption\{None, Some}; @@ -85,6 +86,20 @@ class OptionTest extends TestCase $this->assertEquals('passed', $value, 'The value should have been obtained from the option'); } + #[TestDox('GetOrThrow succeeds with Some')] + public function testGetOrThrowSucceedsWithSome(): void + { + $value = Option::Some('no throw')->getOrThrow(fn() => new BadMethodCallException('oops')); + $this->assertEquals('no throw', $value, 'The "Some" value should have been returned'); + } + + #[TestDox('GetOrThrow succeeds with None')] + public function testGetOrThrowSucceedsWithNone(): void + { + $this->expectException(BadMethodCallException::class); + Option::None()->getOrThrow(fn() => new BadMethodCallException('oops')); + } + #[TestDox('Bind succeeds with None')] public function testBindSucceedsWithNone(): void { @@ -109,6 +124,54 @@ class OptionTest extends TestCase $this->assertTrue($bound->isNone(), 'The option should have been None'); } + #[TestDox('Contains succeeds with None')] + public function testContainsSucceedsWithNone(): void + { + $this->assertFalse(Option::None()->contains(null), '"None" should always return false'); + } + + #[TestDox('Contains succeeds with Some when strictly equal')] + public function testContainsSucceedsWithSomeWhenStrictlyEqual(): void + { + $this->assertTrue(Option::Some(3)->contains(3), '"Some" with strict equality should be true'); + } + + #[TestDox('Contains succeeds with Some when strictly unequal')] + public function testContainsSucceedsWithSomeWhenStrictlyUnequal(): void + { + $this->assertFalse(Option::Some('3')->contains(3), '"Some" with strict equality should be false'); + } + + #[TestDox('Contains succeeds with Some when loosely equal')] + public function testContainsSucceedsWithSomeWhenLooselyEqual(): void + { + $this->assertTrue(Option::Some('3')->contains(3, strict: false), '"Some" with loose equality should be true'); + } + + #[TestDox('Contains succeeds with Some when loosely unequal')] + public function testContainsSucceedsWithSomeWhenLooselyUnequal(): void + { + $this->assertFalse(Option::Some('3')->contains(4, strict: false), '"Some" with loose equality should be false'); + } + + #[TestDox('Exists succeeds with Some when matching')] + public function testExistsSucceedsWithSomeWhenMatching(): void + { + $this->assertTrue(Option::Some(14)->exists(fn($it) => $it < 100), 'Exists should have returned true'); + } + + #[TestDox('Exists succeeds with Some when not matching')] + public function testExistsSucceedsWithSomeWhenNotMatching(): void + { + $this->assertFalse(Option::Some(14)->exists(fn($it) => $it > 100), 'Exists should have returned false'); + } + + #[TestDox('Exists succeeds with None')] + public function testExistsSucceedsWithNone(): void + { + $this->assertFalse(Option::None()->exists(fn($it) => true), 'Exists should have returned false'); + } + #[TestDox('Map succeeds with None')] public function testMapSucceedsWithNone(): void { @@ -188,36 +251,6 @@ class OptionTest extends TestCase $this->assertTrue($filtered->isNone(), 'The filtered option should have been "None"'); } - #[TestDox('Is succeeds with None')] - public function testIsSucceedsWithNone(): void - { - $this->assertFalse(Option::None()->is(null), '"None" should always return false'); - } - - #[TestDox('Is succeeds with Some when strictly equal')] - public function testIsSucceedsWithSomeWhenStrictlyEqual(): void - { - $this->assertTrue(Option::Some(3)->is(3), '"Some" with strict equality should be true'); - } - - #[TestDox('Is succeeds with Some when strictly unequal')] - public function testIsSucceedsWithSomeWhenStrictlyUnequal(): void - { - $this->assertFalse(Option::Some('3')->is(3), '"Some" with strict equality should be false'); - } - - #[TestDox('Is succeeds with Some when loosely equal')] - public function testIsSucceedsWithSomeWhenLooselyEqual(): void - { - $this->assertTrue(Option::Some('3')->is(3, strict: false), '"Some" with loose equality should be true'); - } - - #[TestDox('Is succeeds with Some when loosely unequal')] - public function testIsSucceedsWithSomeWhenLooselyUnequal(): void - { - $this->assertFalse(Option::Some('3')->is(4, strict: false), '"Some" with loose equality should be false'); - } - #[TestDox('Unwrap succeeds with None')] public function testUnwrapSucceedsWithNone(): void { @@ -252,6 +285,22 @@ class OptionTest extends TestCase $this->assertSame($original, $tapped, 'The same option should have been returned'); } + #[TestDox('ToArray succeeds with Some')] + public function testToArraySucceedsWithSome(): void + { + $arr = Option::Some('15')->toArray(); + $this->assertNotNull($arr, 'The array should not have been null'); + $this->assertEquals(['15'], $arr, 'The array was not created correctly'); + } + + #[TestDox('ToArray succeeds with None')] + public function testToArraySucceedsWithNone(): void + { + $arr = Option::None()->toArray(); + $this->assertNotNull($arr, 'The array should not have been null'); + $this->assertEmpty($arr, 'The array should have been empty'); + } + #[TestDox('ToPhpOption succeeds for Some')] public function testToPhpOptionSucceedsForSome(): void { diff --git a/tests/ResultTest.php b/tests/ResultTest.php index 6baeefb..25067fc 100644 --- a/tests/ResultTest.php +++ b/tests/ResultTest.php @@ -99,6 +99,55 @@ class ResultTest extends TestCase $this->assertSame($original, $result, 'The same Error result should have been returned'); } + + #[TestDox('Contains succeeds for Error result')] + public function testContainsSucceedsForErrorResult(): void + { + $this->assertFalse(Result::Error('ouch')->contains('ouch'), '"Error" should always return false'); + } + + #[TestDox('Contains succeeds for OK result when strictly equal')] + public function testContainsSucceedsForOKResultWhenStrictlyEqual(): void + { + $this->assertTrue(Result::OK(18)->contains(18), '"OK" with strict equality should be true'); + } + + #[TestDox('Contains succeeds for OK result when strictly unequal')] + public function testContainsSucceedsForOKResultWhenStrictlyUnequal(): void + { + $this->assertFalse(Result::OK(18)->contains('18'), '"OK" with strict equality should be false'); + } + + #[TestDox('Contains succeeds for OK result when loosely equal')] + public function testContainsSucceedsForOKResultWhenLooselyEqual(): void + { + $this->assertTrue(Result::OK(18)->contains('18', strict: false), '"OK" with loose equality should be true'); + } + + #[TestDox('Contains succeeds for OK result when loosely unequal')] + public function testContainsSucceedsForOKResultWhenLooselyUnequal(): void + { + $this->assertFalse(Result::OK(18)->contains(17, strict: false), '"OK" with loose equality should be false'); + } + + #[TestDox('Exists succeeds for OK result when matching')] + public function testExistsSucceedsForOKResultWhenMatching(): void + { + $this->assertTrue(Result::OK(14)->exists(fn($it) => $it < 100), 'Exists should have returned true'); + } + + #[TestDox('Exists succeeds for OK result when not matching')] + public function testExistsSucceedsForOKResultWhenNotMatching(): void + { + $this->assertFalse(Result::OK(14)->exists(fn($it) => $it > 100), 'Exists should have returned false'); + } + + #[TestDox('Exists succeeds for Error result')] + public function testExistsSucceedsForErrorResult(): void + { + $this->assertFalse(Result::Error(true)->exists(fn($it) => true), 'Exists should have returned false'); + } + #[TestDox('Map succeeds for OK result')] public function testMapSucceedsForOKResult(): void { @@ -177,6 +226,22 @@ class ResultTest extends TestCase $this->assertNull($target->called, 'The function should not have been called'); } + #[TestDox('ToArray succeeds for OK result')] + public function testToArraySucceedsForOKResult(): void + { + $arr = Result::OK('yay')->toArray(); + $this->assertNotNull($arr, 'The array should not have been null'); + $this->assertEquals(['yay'], $arr, 'The array was not created correctly'); + } + + #[TestDox('ToArray succeeds for Error result')] + public function testToArraySucceedsForErrorResult(): void + { + $arr = Result::Error('oh no')->toArray(); + $this->assertNotNull($arr, 'The array should not have been null'); + $this->assertEmpty($arr, 'The array should have been empty'); + } + #[TestDox('ToOption succeeds for OK result')] public function testToOptionSucceedsForOKResult() {