Add Result and tests
This commit is contained in:
parent
bfc27ccef5
commit
193147cfb3
168
src/Result.php
Normal file
168
src/Result.php
Normal file
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BitBadger\InspiredByFSharp;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* The result of an operation; one of two possible outcomes.
|
||||
*
|
||||
* While exceptions are still possible in F# (and PHP), a more robust model is returning an object that can represent
|
||||
* either success or failure. The `Result` type exposes an `OK` state for the successful result of an action and an
|
||||
* `Error` state for the unsuccessful result of that action. Neither result value can be `null`; both should be
|
||||
* meaningful for the action, and either eventuality should be handled by calling code.
|
||||
*
|
||||
* The net result _(no pun intended)_ is code that fails gracefully, and calling code that is aware that not every call
|
||||
* is successful.
|
||||
*
|
||||
* @template TOK The type of the OK result
|
||||
* @template TError The type of the error result
|
||||
*/
|
||||
readonly class Result
|
||||
{
|
||||
/** @var Option<TOK> The OK value for this result */
|
||||
private Option $okValue;
|
||||
|
||||
/** @var Option<TError> The error value for this result */
|
||||
private Option $errorValue;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param TOK|null $okValue The OK value for this result
|
||||
* @param TError|null $errorValue The error value for this result
|
||||
*/
|
||||
private function __construct(mixed $okValue = null, mixed $errorValue = null)
|
||||
{
|
||||
$this->okValue = Option::of($okValue);
|
||||
$this->errorValue = Option::of($errorValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for an `OK` result
|
||||
*
|
||||
* @return TOK The OK value for this result
|
||||
* @throws InvalidArgumentException If the result is an `Error` result
|
||||
*/
|
||||
public function getOK(): mixed
|
||||
{
|
||||
return $this->okValue->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for an `Error` result
|
||||
*
|
||||
* @return TError The error value for this result
|
||||
* @throws InvalidArgumentException If the result is an `OK` result
|
||||
*/
|
||||
public function getError(): mixed
|
||||
{
|
||||
return $this->errorValue->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an `OK` result
|
||||
*
|
||||
* @param TOK $value The OK value for this result
|
||||
* @return Result<TOK, TError> The `OK` result for the value specified
|
||||
*/
|
||||
public static function OK(mixed $value): self
|
||||
{
|
||||
if (is_null($value)) {
|
||||
throw new InvalidArgumentException('Cannot use null as an OK value');
|
||||
}
|
||||
return new self(okValue: $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an `Error` result
|
||||
*
|
||||
* @param TError $value The error value for this result
|
||||
* @return Result<TOK, TError> The `Error` result for the value specified
|
||||
*/
|
||||
public static function Error(mixed $value): self
|
||||
{
|
||||
if (is_null($value)) {
|
||||
throw new InvalidArgumentException('Cannot use null as an Error value');
|
||||
}
|
||||
return new self(errorValue: $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given result `OK`?
|
||||
*
|
||||
* @param Result $it The result in question
|
||||
* @return bool True if the result is `OK`, false if it is `Error`
|
||||
*/
|
||||
public static function isOK(Result $it): bool
|
||||
{
|
||||
return Option::isSome($it->okValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given result `Error`?
|
||||
*
|
||||
* @param Result $it The result in question
|
||||
* @return bool True if the result is `Error`, false if it is `OK`
|
||||
*/
|
||||
public static function isError(Result $it): bool
|
||||
{
|
||||
return Option::isSome($it->errorValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param Result<TOK, TError> $it The result in question
|
||||
* @return Result<U, TError> A transformed `OK` instance, or an `Error` instance with the same value
|
||||
*/
|
||||
public static function map(callable $f, Result $it): self
|
||||
{
|
||||
return self::isOK($it) ? self::OK($f($it->getOK())) : self::Error($it->getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param Result<TOK, TError> $it The result in question
|
||||
* @return Result<TOK, U> A transformed `Error` instance, or an `OK` instance with the same value
|
||||
*/
|
||||
public static function mapError(callable $f, Result $it): self
|
||||
{
|
||||
return self::isError($it) ? self::Error($f($it->getError())) : self::OK($it->getOK());
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function on an `OK` value (if it exists)
|
||||
*
|
||||
* @param callable(TOK): void $f The function to call
|
||||
* @param Result<TOK, TError> $it The result in question
|
||||
*/
|
||||
public static function iter(callable $f, Result $it): void
|
||||
{
|
||||
if (self::isOK($it)) {
|
||||
$f($it->getOK());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a `Result`'s `OK` value to an `Option`
|
||||
*
|
||||
* @param Result<TOK, TError> $it The result in question
|
||||
* @return Option<TOK> A `Some` option with the OK value if `OK`, `None` if `Error`
|
||||
*/
|
||||
public static function toOption(Result $it): Option
|
||||
{
|
||||
return Result::isOK($it) ? Option::Some($it->getOK()) : Option::None();
|
||||
}
|
||||
}
|
200
tests/ResultTest.php
Normal file
200
tests/ResultTest.php
Normal file
@ -0,0 +1,200 @@
|
||||
<?php
|
||||
/**
|
||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Test;
|
||||
|
||||
use BitBadger\InspiredByFSharp\Option;
|
||||
use BitBadger\InspiredByFSharp\Result;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Result class
|
||||
*/
|
||||
class ResultTest extends TestCase
|
||||
{
|
||||
#[TestDox('GetOK succeeds for OK result')]
|
||||
public function testGetOKSucceedsForOKResult(): void
|
||||
{
|
||||
$result = Result::OK('yay');
|
||||
$this->assertEquals('yay', $result->getOK(), 'The OK result should have been returned');
|
||||
}
|
||||
|
||||
#[TestDox('GetOK fails for Error result')]
|
||||
public function testGetOKFailsForErrorResult(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::Error('whoops')->getOK();
|
||||
}
|
||||
|
||||
#[TestDox('GetError succeeds for Error result')]
|
||||
public function testGetErrorSucceedsForErrorResult(): void
|
||||
{
|
||||
$result = Result::Error('boo');
|
||||
$this->assertEquals('boo', $result->getError(), 'The Error result should have been returned');
|
||||
}
|
||||
|
||||
#[TestDox('GetError fails for OK result')]
|
||||
public function testGetErrorFailsForOKResult(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::OK('yeah')->getError();
|
||||
}
|
||||
|
||||
#[TestDox('OK succeeds for non null result')]
|
||||
public function testOKSucceedsForNonNullResult(): void
|
||||
{
|
||||
$result = Result::OK('something');
|
||||
$this->assertTrue(Result::isOK($result), 'The result should have been "OK"');
|
||||
$this->assertEquals('something', $result->getOK(), 'The "OK" value was incorrect');
|
||||
}
|
||||
|
||||
#[TestDox('OK fails for null result')]
|
||||
public function testOKFailsForNullResult(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::OK(null);
|
||||
}
|
||||
|
||||
#[TestDox('Error succeeds for non null result')]
|
||||
public function testErrorSucceedsForNonNullResult(): void
|
||||
{
|
||||
$result = Result::Error('sad trombone');
|
||||
$this->assertTrue(Result::isError($result), 'The result should have been "Error"');
|
||||
$this->assertEquals('sad trombone', $result->getError(), 'The "Error" value was incorrect');
|
||||
}
|
||||
|
||||
#[TestDox('Error fails for null result')]
|
||||
public function testErrorFailsForNullResult(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::Error(null);
|
||||
}
|
||||
|
||||
#[TestDox('IsOK succeeds for OK result')]
|
||||
public function testIsOKSucceedsForOKResult(): void
|
||||
{
|
||||
$result = Result::OK('ok');
|
||||
$this->assertTrue(Result::isOK($result), 'The check for "OK" should have returned true');
|
||||
}
|
||||
|
||||
#[TestDox('IsOK succeeds for Error result')]
|
||||
public function testIsOKSucceedsForErrorResult(): void
|
||||
{
|
||||
$result = Result::Error('error');
|
||||
$this->assertFalse(Result::isOK($result), 'The check for "OK" should have returned false');
|
||||
}
|
||||
|
||||
#[TestDox('IsError succeeds for Error result')]
|
||||
public function testIsErrorSucceedsForErrorResult(): void
|
||||
{
|
||||
$result = Result::Error('not ok');
|
||||
$this->assertTrue(Result::isError($result), 'The check for "Error" should have returned true');
|
||||
}
|
||||
|
||||
#[TestDox('IsError succeeds for OK result')]
|
||||
public function testIsErrorSucceedsForOKResult(): void
|
||||
{
|
||||
$result = Result::OK('fine');
|
||||
$this->assertFalse(Result::isError($result), 'The check for "Error" should have returned false');
|
||||
}
|
||||
|
||||
#[TestDox('Map succeeds for OK result')]
|
||||
public function testMapSucceedsForOKResult(): void
|
||||
{
|
||||
$ok = Result::OK('yard');
|
||||
$mapped = Result::map(fn($it) => strrev($it), $ok);
|
||||
$this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"');
|
||||
$this->assertEquals('dray', $mapped->getOK(), 'The mapping function was not called correctly');
|
||||
}
|
||||
|
||||
#[TestDox('Map fails for OK result when mapping is null')]
|
||||
public function testMapFailsForOKResultWhenMappingIsNull(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::map(fn($it) => null, Result::OK('not null'));
|
||||
}
|
||||
|
||||
#[TestDox('Map succeeds for Error result')]
|
||||
public function testMapSucceedsForErrorResult(): void
|
||||
{
|
||||
$tattle = new class { public bool $called = false; };
|
||||
$error = Result::Error('nope');
|
||||
$mapped = Result::map(function ($ignored) use ($tattle)
|
||||
{
|
||||
$tattle->called = true;
|
||||
return 'hello';
|
||||
}, $error);
|
||||
$this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"');
|
||||
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
|
||||
$this->assertNotSame($error, $mapped, 'There should have been a new "Error" instance returned');
|
||||
}
|
||||
|
||||
#[TestDox('MapError succeeds for OK result')]
|
||||
public function testMapErrorSucceedsForOKResult(): void
|
||||
{
|
||||
$tattle = new class { public bool $called = false; };
|
||||
$ok = Result::OK('sure');
|
||||
$mapped = Result::mapError(function ($ignored) use ($tattle)
|
||||
{
|
||||
$tattle->called = true;
|
||||
return 'hello';
|
||||
}, $ok);
|
||||
$this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"');
|
||||
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
|
||||
$this->assertNotSame($ok, $mapped, 'There should have been a new "OK" instance returned');
|
||||
}
|
||||
|
||||
#[TestDox('MapError succeeds for Error result')]
|
||||
public function testMapErrorSucceedsForErrorResult(): void
|
||||
{
|
||||
$error = Result::Error('taco');
|
||||
$mapped = Result::mapError(fn($it) => str_repeat('*', strlen($it)), $error);
|
||||
$this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"');
|
||||
$this->assertEquals('****', $mapped->getError(), 'The mapping function was not called correctly');
|
||||
}
|
||||
|
||||
#[TestDox('MapError fails for Error result when mapping is null')]
|
||||
public function testMapErrorFailsForErrorResultWhenMappingIsNull(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
Result::mapError(fn($it) => null, Result::Error('pizza'));
|
||||
}
|
||||
|
||||
#[TestDox('Iter succeeds for OK result')]
|
||||
public function testIterSucceedsForOKResult(): void
|
||||
{
|
||||
$target = new class { public mixed $called = null; };
|
||||
Result::iter(function ($it) use ($target) { $target->called = $it; }, Result::OK(77));
|
||||
$this->assertEquals(77, $target->called, 'The function should have been called with the "OK" value');
|
||||
}
|
||||
|
||||
#[TestDox('Iter succeeds for Error result')]
|
||||
public function testIterSucceedsForErrorResult(): void
|
||||
{
|
||||
$target = new class { public mixed $called = null; };
|
||||
Result::iter(function ($ignored) use ($target) { $target->called = 'uh oh'; }, Result::Error(''));
|
||||
$this->assertNull($target->called, 'The function should not have been called');
|
||||
}
|
||||
|
||||
#[TestDox('ToOption succeeds for OK result')]
|
||||
public function testToOptionSucceedsForOKResult()
|
||||
{
|
||||
$value = Result::toOption(Result::OK(99));
|
||||
$this->assertTrue(Option::isSome($value), 'An "OK" result should map to a "Some" option');
|
||||
$this->assertEquals(99, $value->get(), 'The value is not correct');
|
||||
}
|
||||
|
||||
#[TestDox('ToOption succeeds for Error result')]
|
||||
public function testToOptionSucceedsForErrorResult()
|
||||
{
|
||||
$value = Result::toOption(Result::Error('file not found'));
|
||||
$this->assertTrue(Option::isNone($value), 'An "Error" result should map to a "None" option');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user