Add contains, exists, toArray

- Update docs
This commit is contained in:
Daniel J. Summers 2024-07-29 13:58:33 -04:00
parent 57af645d87
commit fad428a4e4
6 changed files with 262 additions and 76 deletions

View File

@ -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<T>` represents a variable that may or may not have a value. `Result<TOK, TError>` represents the result of an action; the "ok" and "error" states both provide a value.
| | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>Replaces exception-based error handling |
|----------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| **Creating** | `::Some(T)` for Some<br>`::None()` for None<br>`::of($value)` _None if `null`_ | `::OK(TOK)` for OK<br>`::Error(TError)` for Error |
| **Querying** | `->isSome(): bool`<br>`->isNone(): bool` | `->isOK(): bool`<br>`->isError(): bool` |
| **Reading**<br>_throws if called on missing value_ | `->get(): T` | `->getOK(): TOK`<br>`->getError(): TError` |
| **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U): U` | `->map(callable(TOK): U): U`<br>`->mapError(callable(TError): U): U` |
| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void): void` | `->tap(callable(Result<TOK, TError>): void): void` |
| **Continued Processing** | `->bind(callable(T): Option<TBound>): Option<TBound>` | `->bind(callable(TOK): Result<TBoundOK, TError>): Result<TBoundOK, TError>` |
| | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>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**<br> | `->get(): T` | `->getOK(): TOK` |
| _all throw if called on missing value_ | | `->getError(): TError` |
| **Transforming**<br> | `->map(callable(T): TMapped): Option<TMapped>` | `->map(callable(TOK): TMapped): Result<TMapped, TError>` |
| _all still `Option` or `Result`_ | | `->mapError(callable(TError): TMapped): Result<TOK, TMapped>` |
| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void): Option<T>` | `->tap(callable(Result<TOK, TError>): void): Result<TOK, TError>` |
| **Continued Processing** | `->bind(callable(T): Option<TBound>): Option<TBound>` | `->bind(callable(TOK): Result<TBoundOK, TError>): Result<TBoundOK, TError>` |
| **Changing Types** | `->toArray(): T[]` | `->toArray(): TOK[]` |
| | | `->toOption(): Option<TOK>` |
In addition to this, `Option<T>` 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<TOK, TError>` 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<T>` instances have a `->toPhpOption()` method that will convert these back into PhpOption's `Some<T>` 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

4
composer.lock generated
View File

@ -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"

View File

@ -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<U> 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<TMapped> 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
*

View File

@ -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<U, TError> 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<TMapped, TError> 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<TOK, U> 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<TOK, TMapped> 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`
*

View File

@ -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
{

View File

@ -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()
{