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

@ -7,25 +7,31 @@ 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. 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 | | | `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 | | **Creating** | `::Some(T)` for Some | `::OK(TOK)` for OK |
| **Querying** | `->isSome(): bool`<br>`->isNone(): bool` | `->isOK(): bool`<br>`->isError(): bool` | | | `::None()` for None | `::Error(TError)` for Error |
| **Reading**<br>_throws if called on missing value_ | `->get(): T` | `->getOK(): TOK`<br>`->getError(): TError` | | | `::of($value)` _None if `null`_ | |
| **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U): U` | `->map(callable(TOK): U): U`<br>`->mapError(callable(TError): U): U` | | **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` | | **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` | | **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>` | | **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: 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. - `->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`. - `->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. - `->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. - `->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. 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 ## 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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9c9d70d95d369f37fa95a10637a56a58", "content-hash": "9c0a6b77d5d66ee91ab9946d5f5e2107",
"packages": [], "packages": [],
"packages-dev": [ "packages-dev": [
{ {
@ -1721,7 +1721,7 @@
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8" "php": "^8.2"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace BitBadger\InspiredByFSharp; namespace BitBadger\InspiredByFSharp;
use Error; use Error;
use Exception;
use InvalidArgumentException; use InvalidArgumentException;
/** /**
@ -84,15 +85,26 @@ readonly class Option
/** /**
* Get the value, or return the value of a callable function * Get the value, or return the value of a callable function
* *
* @template U The return type of the callable provided * @template TCalled The return type of the callable provided
* @param callable(): U $f The callable function to use for `None` options * @param callable(): TCalled $f The callable function to use for `None` options
* @return T|mixed The value if `Some`, the result of the callable if `None` * @return T|TCalled The value if `Some`, the result of the callable if `None`
*/ */
public function getOrCall(callable $f): mixed public function getOrCall(callable $f): mixed
{ {
return $this->value ?? $f(); 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) * Bind a function to this option (railway processing)
* *
@ -108,12 +120,38 @@ readonly class Option
return $this->isNone() ? $this : $f($this->get()); 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 * Map this optional value to another value
* *
* @template U The type of the mapping function * @template TMapped The type of the mapping function
* @param callable(T): U $f The mapping function * @param callable(T): TMapped $f The mapping function
* @return Option<U> A `Some` instance with the transformed value if `Some`, `None` otherwise * @return Option<TMapped> A `Some` instance with the transformed value if `Some`, `None` otherwise
*/ */
public function map(callable $f): self public function map(callable $f): self
{ {
@ -140,25 +178,7 @@ readonly class Option
*/ */
public function filter(callable $f): self public function filter(callable $f): self
{ {
return match (true) { return $this->isNone() || $this->exists($f) ? $this : self::None();
$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,
};
} }
/** /**
@ -183,6 +203,16 @@ readonly class Option
return $this; 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 * Convert this to a PhpOption option
* *

View File

@ -103,12 +103,38 @@ readonly class Result
return $this->isError() ? $this : $f($this->getOK()); 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 * Map an `OK` result to another, leaving an `Error` result unmodified
* *
* @template U The type of the mapping function * @template TMapped The type of the mapping function
* @param callable(TOK): U $f The mapping function * @param callable(TOK): TMapped $f The mapping function
* @return Result<U, TError> A transformed `OK` instance or the original `Error` instance * @return Result<TMapped, TError> A transformed `OK` instance or the original `Error` instance
*/ */
public function map(callable $f): self public function map(callable $f): self
{ {
@ -118,9 +144,9 @@ readonly class Result
/** /**
* Map an `Error` result to another, leaving an `OK` result unmodified * Map an `Error` result to another, leaving an `OK` result unmodified
* *
* @template U The type of the mapping function * @template TMapped The type of the mapping function
* @param callable(TError): U $f The mapping function * @param callable(TError): TMapped $f The mapping function
* @return Result<TOK, U> A transformed `Error` instance or the original `OK` instance * @return Result<TOK, TMapped> A transformed `Error` instance or the original `OK` instance
*/ */
public function mapError(callable $f): self 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` * Transform a `Result`'s `OK` value to an `Option`
* *

View File

@ -8,6 +8,7 @@ declare(strict_types=1);
namespace Test; namespace Test;
use BadMethodCallException;
use BitBadger\InspiredByFSharp\Option; use BitBadger\InspiredByFSharp\Option;
use InvalidArgumentException; use InvalidArgumentException;
use PhpOption\{None, Some}; 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'); $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')] #[TestDox('Bind succeeds with None')]
public function testBindSucceedsWithNone(): void public function testBindSucceedsWithNone(): void
{ {
@ -109,6 +124,54 @@ class OptionTest extends TestCase
$this->assertTrue($bound->isNone(), 'The option should have been None'); $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')] #[TestDox('Map succeeds with None')]
public function testMapSucceedsWithNone(): void public function testMapSucceedsWithNone(): void
{ {
@ -188,36 +251,6 @@ class OptionTest extends TestCase
$this->assertTrue($filtered->isNone(), 'The filtered option should have been "None"'); $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')] #[TestDox('Unwrap succeeds with None')]
public function testUnwrapSucceedsWithNone(): void public function testUnwrapSucceedsWithNone(): void
{ {
@ -252,6 +285,22 @@ class OptionTest extends TestCase
$this->assertSame($original, $tapped, 'The same option should have been returned'); $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')] #[TestDox('ToPhpOption succeeds for Some')]
public function testToPhpOptionSucceedsForSome(): void 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'); $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')] #[TestDox('Map succeeds for OK result')]
public function testMapSucceedsForOKResult(): void public function testMapSucceedsForOKResult(): void
{ {
@ -177,6 +226,22 @@ class ResultTest extends TestCase
$this->assertNull($target->called, 'The function should not have been called'); $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')] #[TestDox('ToOption succeeds for OK result')]
public function testToOptionSucceedsForOKResult() public function testToOptionSucceedsForOKResult()
{ {