4 Commits

Author SHA1 Message Date
3cc780956d Convert tests to Pest 2024-11-20 20:27:18 -05:00
6779b2c554 Restrict PHP version
- Update deps
2024-09-30 20:14:52 -04:00
fad428a4e4 Add contains, exists, toArray
- Update docs
2024-07-29 13:58:33 -04:00
57af645d87 Add bind() to option and result 2024-07-28 22:50:59 -04:00
12 changed files with 2861 additions and 640 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.idea .idea
vendor vendor
*-tests.txt

View File

@@ -7,24 +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()`<br>`->isNone()` | `->isOK()`<br>`->isError()` | | | `::None()` for None | `::Error(TError)` for Error |
| **Reading**<br>_throws if called on missing value_ | `->get()` | `->getOK()`<br>`->getError()` | | | `::of($value)` _None if `null`_ | |
| **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U)` | `->map(callable(TOK): U)`<br>`->mapError(callable(TError): U)` | | **Querying** | `->isSome(): bool` | `->isOK(): bool` |
| **Iterating** | `->iter(callable(T): void)` | `->iter(callable(TOK): void)` | | | `->isNone(): bool` | `->isError(): bool` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void)` | `->tap(callable(Result<TOK, TError>): void)` | | | `->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: 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

View File

@@ -17,11 +17,11 @@
"rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss" "rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss"
}, },
"require": { "require": {
"php": "^8.2" "php": "8.2 - 8.3"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11", "phpoption/phpoption": "^1",
"phpoption/phpoption": "^1" "pestphp/pest": "^3.5"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -30,10 +30,15 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Test\\": "./tests" "Tests\\": "./tests"
} }
}, },
"archive": { "archive": {
"exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ] "exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ]
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
} }
} }

2255
composer.lock generated

File diff suppressed because it is too large Load Diff

17
phpunit.xml Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

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,21 +85,73 @@ 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)
*
* If this option is Some, the function will be called with the option's value. If this option is None, it will be
* immediately returned.
*
* @template TBound The type returned by Some in the bound function
* @param callable(T): Option<TBound> $f The function that will receive the Some value; can return a different type
* @return Option<TBound> The updated option if the starting value was Some, None otherwise
*/
public function bind(callable $f): 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 * 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
{ {
@@ -125,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,
};
} }
/** /**
@@ -168,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

@@ -87,12 +87,54 @@ readonly class Result
return $this->errorValue->isSome(); return $this->errorValue->isSome();
} }
/**
* Bind a function to this result (railway processing)
*
* If this result is OK, the function will be called with the OK value of the result. If this result is Error, it
* will be immediately returned. This allows for a sequence of functions to proceed on the happy path (OK all the
* way), or be shunted off to the exit ramp once an error occurs.
*
* @template TBoundOK The type returned by OK in the bound function
* @param callable(TOK): Result<TBoundOK, TError> $f The function that will receive the OK value; can return a different type
* @return Result<TBoundOK, TError> The updated result if the function was successful, an error otherwise
*/
public function bind(callable $f): 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 * 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
{ {
@@ -102,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
{ {
@@ -123,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

@@ -1,295 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use BitBadger\InspiredByFSharp\Option;
use InvalidArgumentException;
use PhpOption\{None, Some};
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the Option class
*/
class OptionTest extends TestCase
{
#[TestDox('Get succeeds for Some')]
public function testGetSucceedsForSome(): void
{
$it = Option::Some(9);
$this->assertTrue($it->isSome(), 'The option should have been "Some"');
$this->assertEquals(9, $it->get(), 'The value was incorrect');
}
#[TestDox('Get fails for None')]
public function testGetFailsForNone(): void
{
$this->expectException(InvalidArgumentException::class);
Option::None()->get();
}
#[TestDox('IsNone succeeds with None')]
public function testIsNoneSucceedsWithNone(): void
{
$this->assertTrue(Option::None()->isNone(), '"None" should return true');
}
#[TestDox('IsNone succeeds with Some')]
public function testIsNoneSucceedsWithSome(): void
{
$this->assertFalse(Option::Some(8)->isNone(), '"Some" should return false');
}
#[TestDox('IsSome succeeds with None')]
public function testIsSomeSucceedsWithNone(): void
{
$this->assertFalse(Option::None()->isSome(), '"None" should return false');
}
#[TestDox('IsSome succeeds with Some')]
public function testIsSomeSucceedsWithSome(): void
{
$this->assertTrue(Option::Some('boo')->isSome(), '"Some" should return true');
}
#[TestDox('GetOrDefault succeeds with None')]
public function testGetOrDefaultSucceedsWithNone(): void
{
$this->assertEquals('yes', Option::None()->getOrDefault('yes'), 'Value should have been default');
}
#[TestDox('GetOrDefault succeeds with Some')]
public function testGetOrDefaultSucceedsWithSome(): void
{
$this->assertEquals('no', Option::Some('no')->getOrDefault('yes'), 'Value should have been from option');
}
#[TestDox('GetOrCall succeeds with None')]
public function testGetOrCallSucceedsWithNone(): void
{
$value = Option::None()->getOrCall(new class { public function __invoke(): string { return 'called'; } });
$this->assertEquals('called', $value, 'The value should have been obtained from the callable');
}
#[TestDox('GetOrCall succeeds with Some')]
public function testGetOrCallSucceedsWithSome(): void
{
$value = Option::Some('passed')->getOrCall(
new class { public function __invoke(): string { return 'called'; } });
$this->assertEquals('passed', $value, 'The value should have been obtained from the option');
}
#[TestDox('Map succeeds with None')]
public function testMapSucceedsWithNone(): void
{
$tattle = new class { public bool $called = false; };
$none = Option::None();
$mapped = $none->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
$this->assertTrue($mapped->isNone(), 'The mapped option should be "None"');
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
$this->assertSame($none, $mapped, 'The same "None" instance should have been returned');
}
#[TestDox('Map succeeds with Some')]
public function testMapSucceedsWithSome(): void
{
$mapped = Option::Some('abc ')->map(fn($it) => str_repeat($it, 2));
$this->assertTrue($mapped->isSome(), 'The mapped option should be "Some"');
$this->assertEquals('abc abc ', $mapped->get(), 'The mapping function was not called correctly');
}
#[TestDox('Map fails with Some when mapping is null')]
public function testMapFailsWithSomeWhenMappingIsNull(): void
{
$this->expectException(InvalidArgumentException::class);
Option::Some('oof')->map(fn($it) => null);
}
#[TestDox('Iter succeeds with None')]
public function testIterSucceedsWithNone(): void
{
$target = new class { public mixed $called = null; };
Option::None()->iter(function () use ($target) { $target->called = 'uh oh'; });
$this->assertNull($target->called, 'The function should not have been called');
}
#[TestDox('Iter succeeds with Some')]
public function testIterSucceedsWithSome(): void
{
$target = new class { public mixed $called = null; };
Option::Some(33)->iter(function ($it) use ($target) { $target->called = $it; });
$this->assertEquals(33, $target->called, 'The function should have been called with the "Some" value');
}
#[TestDox('Filter succeeds with None')]
public function testFilterSucceedsWithNone(): void
{
$tattle = new class { public bool $called = false; };
$none = Option::None();
$filtered = $none->filter(function () use ($tattle)
{
$tattle->called = true;
return true;
});
$this->assertTrue($filtered->isNone(), 'The filtered option should have been "None"');
$this->assertFalse($tattle->called, 'The callable should not have been called');
$this->assertSame($none, $filtered, 'The "None" instance returned should have been the one passed');
}
#[TestDox('Filter succeeds with Some when true')]
public function testFilterSucceedsWithSomeWhenTrue(): void
{
$some = Option::Some(12);
$filtered = $some->filter(fn($it) => $it % 2 === 0);
$this->assertTrue($filtered->isSome(), 'The filtered option should have been "Some"');
$this->assertEquals(12, $filtered->get(), 'The filtered option value is incorrect');
$this->assertSame($some, $filtered, 'The same "Some" instance should have been returned');
}
#[TestDox('Filter succeeds with Some when false')]
public function testFilterSucceedsWithSomeWhenFalse(): void
{
$some = Option::Some(23);
$filtered = $some->filter(fn($it) => $it % 2 === 0);
$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
{
$this->assertNull(Option::None()->unwrap(), '"None" should return null');
}
#[TestDox('Unwrap succeeds with Some')]
public function testUnwrapSucceedsWithSome(): void
{
$this->assertEquals('boy howdy', Option::Some('boy howdy')->unwrap(), '"Some" should return its value');
}
#[TestDox('Tap succeeds with Some')]
public function testTapSucceedsWithSome(): void
{
$value = '';
$original = Option::Some('testing');
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; });
$this->assertEquals('testing', $value, 'The tapped function was not called');
$this->assertSame($original, $tapped, 'The same option should have been returned');
}
#[TestDox('Tap succeeds with None')]
public function testTapSucceedsWithNone(): void
{
$value = '';
$original = Option::None();
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; });
$this->assertEquals('none', $value, 'The tapped function was not called');
$this->assertSame($original, $tapped, 'The same option should have been returned');
}
#[TestDox('ToPhpOption succeeds for Some')]
public function testToPhpOptionSucceedsForSome(): void
{
$opt = Option::Some('php')->toPhpOption();
$this->assertNotNull($opt, 'The PhpOption should not have been null');
$this->assertInstanceOf('PhpOption\Some', $opt, 'The PhpOption should have been "Some"');
$this->assertTrue($opt->isDefined(), 'There should have been a value for the PhpOption');
$this->assertEquals('php', $opt->get(), 'The value was not correct');
}
#[TestDox('ToPhpOption succeeds for None')]
public function testToPhpOptionSucceedsForNone(): void
{
$opt = Option::None()->toPhpOption();
$this->assertNotNull($opt, 'The PhpOption should not have been null');
$this->assertInstanceOf('PhpOption\None', $opt, 'The PhpOption should have been "None"');
$this->assertFalse($opt->isDefined(), 'There should not have been a value for the PhpOption');
}
public function testSomeSucceedsWithValue(): void
{
$it = Option::Some('hello');
$this->assertTrue($it->isSome(), 'The option should have been "Some"');
}
public function testSomeFailsWithNull(): void
{
$this->expectException(InvalidArgumentException::class);
Option::Some(null);
}
public function testNoneSucceeds(): void
{
$it = Option::None();
$this->assertTrue($it->isNone(), 'The option should have been "None"');
}
public function testOfSucceedsWithNull(): void
{
$it = Option::of(null);
$this->assertTrue($it->isNone(), '"null" should have created a "None" option');
}
public function testOfSucceedsWithNonNull(): void
{
$it = Option::of('test');
$this->assertTrue($it->isSome(), 'A non-null value should have created a "Some" option');
$this->assertEquals('test', $it->get(), 'The value was not assigned correctly');
}
#[TestDox('Of succeeds with PhpOption\Some')]
public function testOfSucceedsWithPhpOptionSome(): void
{
$it = Option::of(Some::create('something'));
$this->assertTrue($it->isSome(), 'A "Some" PhpOption should have created a "Some" option');
$this->assertEquals('something', $it->get(), 'The value was not assigned correctly');
}
#[TestDox('Of succeeds with PhpOption\None')]
public function testOfSucceedsWithPhpOptionNone(): void
{
$it = Option::of(None::create());
$this->assertTrue($it->isNone(), 'A "None" PhpOption should have created a "None" option');
}
}

45
tests/Pest.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
// pest()->extend(Tests\TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

View File

@@ -1,223 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
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('IsOK succeeds for OK result')]
public function testIsOKSucceedsForOKResult(): void
{
$result = Result::OK('ok');
$this->assertTrue($result->isOK(), '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(), '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(), '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(), 'The check for "Error" should have returned false');
}
#[TestDox('Map succeeds for OK result')]
public function testMapSucceedsForOKResult(): void
{
$ok = Result::OK('yard');
$mapped = $ok->map(fn($it) => strrev($it));
$this->assertTrue($mapped->isOK(), '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::OK('not null')->map(fn($it) => null);
}
#[TestDox('Map succeeds for Error result')]
public function testMapSucceedsForErrorResult(): void
{
$tattle = new class { public bool $called = false; };
$error = Result::Error('nope');
$mapped = $error->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
$this->assertTrue($mapped->isError(), 'The mapped result should be "Error"');
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
$this->assertSame($error, $mapped, 'The same "Error" instance should have been returned');
}
#[TestDox('MapError succeeds for OK result')]
public function testMapErrorSucceedsForOKResult(): void
{
$tattle = new class { public bool $called = false; };
$ok = Result::OK('sure');
$mapped = $ok->mapError(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
$this->assertTrue($mapped->isOK(), 'The mapped result should be "OK"');
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
$this->assertSame($ok, $mapped, 'The same "OK" instance should have been returned');
}
#[TestDox('MapError succeeds for Error result')]
public function testMapErrorSucceedsForErrorResult(): void
{
$error = Result::Error('taco');
$mapped = $error->mapError(fn($it) => str_repeat('*', strlen($it)));
$this->assertTrue($mapped->isError(), '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::Error('pizza')->mapError(fn($it) => null);
}
#[TestDox('Iter succeeds for OK result')]
public function testIterSucceedsForOKResult(): void
{
$target = new class { public mixed $called = null; };
Result::OK(77)->iter(function ($it) use ($target) { $target->called = $it; });
$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::Error('')->iter(function () use ($target) { $target->called = 'uh oh'; });
$this->assertNull($target->called, 'The function should not have been called');
}
#[TestDox('ToOption succeeds for OK result')]
public function testToOptionSucceedsForOKResult()
{
$value = Result::OK(99)->toOption();
$this->assertTrue($value->isSome(), '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::Error('file not found')->toOption();
$this->assertTrue($value->isNone(), 'An "Error" result should map to a "None" option');
}
#[TestDox('Tap succeeds for OK result')]
public function testTapSucceedsForOKResult(): void
{
$value = '';
$original = Result::OK('working');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError();
});
$this->assertEquals('OK: working', $value, 'The tapped function was not called');
$this->assertSame($original, $tapped, 'The same result should have been returned');
}
#[TestDox('Tap succeeds for Error result')]
public function testTapSucceedsForErrorResult(): void
{
$value = '';
$original = Result::Error('failed');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError();
});
$this->assertEquals('Error: failed', $value, 'The tapped function was not called');
$this->assertSame($original, $tapped, 'The same result should have been returned');
}
#[TestDox('OK succeeds for non null result')]
public function testOKSucceedsForNonNullResult(): void
{
$result = Result::OK('something');
$this->assertTrue($result->isOK(), '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(), '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);
}
}

256
tests/Unit/OptionTest.php Normal file
View File

@@ -0,0 +1,256 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use PhpOption\{None, Some};
describe('->get()', function () {
test('retrieves the value for Some', function () {
expect(Option::Some(9)->get())->toBe(9);
});
test('throws an exception for None', function () {
expect(fn() => Option::None()->get())->toThrow(InvalidArgumentException::class);
});
});
describe('->isNone()', function () {
test('returns true for None', function () {
expect(Option::None()->isNone())->toBeTrue();
});
test('returns false for Some', function () {
expect(Option::Some(8)->isNone())->toBeFalse();
});
});
describe('->isSome()', function () {
test('returns false for None', function () {
expect(Option::None()->isSome())->toBeFalse();
});
test('returns true for Some', function () {
expect(Option::Some('boo')->isSome())->toBeTrue();
});
});
describe('->getOrDefault()', function () {
test('returns default value for None', function () {
expect(Option::None()->getOrDefault('yes'))->toBe('yes');
});
test('returns option value for Some', function () {
expect(Option::Some('no')->getOrDefault('yes'))->toBe('no');
});
});
describe('->getOrCall()', function () {
test('returns value from callable for None', function () {
expect(Option::None()->getOrCall(fn() => 'called'))->toBe('called');
});
test('returns option value for Some', function () {
expect(Option::Some('passed')->getOrCall(fn() => 'called'))->toBe('passed');
});
});
describe('->getOrThrow()', function () {
test('throws an exception for None', function () {
expect(fn() => Option::None()->getOrThrow(fn() => throw new BadMethodCallException()))
->toThrow(BadMethodCallException::class);
});
test('returns option value for Some', function () {
expect(Option::Some('no throw')->getOrThrow(fn() => throw new BadMethodCallException()))->toBe('no throw');
});
});
describe('->bind()', function () {
test('returns None when binding against None', function () {
$original = Option::None();
$bound = $original->bind(fn($it) => Option::Some('value'));
expect($bound)->isNone()->toBeTrue()->and($bound)->toBe($original);
});
test('returns Some when binding against Some with Some', function () {
expect(Option::Some('hello')->bind(fn($it) => Option::Some('goodbye')))
->isSome()->toBeTrue()->get()->toBe('goodbye');
});
test('returns None when binding against Some with None', function () {
expect(Option::Some('greetings')->bind(fn($it) => Option::None()))->isNone()->toBeTrue();
});
});
describe('->contains()', function () {
test('returns false for None', function () {
expect(Option::None()->contains(null))->toBeFalse();
});
test('returns true for Some when strict equality is matched', function () {
expect(Option::Some(3)->contains(3))->toBeTrue();
});
test('returns false for Some when strict equality is not matched', function () {
expect(Option::Some('3')->contains(3))->toBeFalse();
});
test('returns true for Some when loose equality is matched', function () {
expect(Option::Some('3')->contains(3, strict: false))->toBeTrue();
});
test('returns false for Some when loose equality is not matched', function () {
expect(Option::Some('3')->contains(4, strict: false))->toBeFalse();
});
});
describe('->exists()', function () {
test('returns false for None', function () {
expect(Option::None()->exists(fn($it) => true))->toBeFalse();
});
test('returns true for Some and matching condition', function () {
expect(Option::Some(14)->exists(fn($it) => $it < 100))->toBeTrue();
});
test('returns false for Some and non-matching condition', function () {
expect(Option::Some(14)->exists(fn($it) => $it > 100))->toBeFalse();
});
});
describe('->map()', function () {
test('does nothing for None', function () {
$tattle = new class { public bool $called = false; };
$none = Option::None();
$mapped = $none->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isNone()->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($none);
});
test('maps value for Some', function () {
expect(Option::Some('abc ')->map(fn($it) => str_repeat($it, 2)))
->isSome()->toBeTrue()->get()->toBe('abc abc ');
});
test('throws an exception if mapping returns null', function () {
expect(fn() => Option::Some('oof')->map(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
});
describe('->iter()', function () {
test('does nothing for None', function () {
$target = new class { public mixed $called = null; };
Option::None()->iter(function () use ($target) { $target->called = 'uh oh'; });
expect($target->called)->toBeNull();
});
test('iterates for Some', function () {
$target = new class { public mixed $called = null; };
Option::Some(33)->iter(function ($it) use ($target) { $target->called = $it; });
expect($target->called)->toBe(33);
});
});
describe('->filter()', function () {
test('does nothing for None', function () {
$tattle = new class { public bool $called = false; };
$none = Option::None();
$filtered = $none->filter(function () use ($tattle)
{
$tattle->called = true;
return true;
});
expect($filtered)
->isNone()->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($filtered)->toBe($none);
});
test('returns Some when filter is matched', function () {
$some = Option::Some(12);
$filtered = $some->filter(fn($it) => $it % 2 === 0);
expect($filtered)
->isSome()->toBeTrue()
->get()->toBe(12)
->and($filtered)->toBe($some);
});
test('returns None when filter is not matched', function () {
expect(Option::Some(23)->filter(fn($it) => $it % 2 === 0)->isNone())->toBeTrue();
});
});
describe('->unwrap()', function () {
test('returns null for None', function () {
expect(Option::None()->unwrap())->toBeNull();
});
test('returns option value for Some', function () {
expect(Option::Some('boy howdy')->unwrap())->toBe('boy howdy');
});
});
describe('->tap()', function () {
test('is called for None', function () {
$value = '';
$original = Option::None();
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; });
expect($value)->toBe('none')->and($original)->toBe($tapped);
});
test('is called for Some', function () {
$value = '';
$original = Option::Some('testing');
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; });
expect($value)->toBe('testing')->and($original)->toBe($tapped);
});
});
describe('->toArray()', function () {
test('returns empty array for None', function () {
expect(Option::None()->toArray())->not->toBeNull()->toBeEmpty();
});
test('returns one-item array for Some', function () {
expect(Option::Some('15')->toArray())->not->toBeNull()->toBe(['15']);
});
});
describe('->toPhpOption()', function () {
test('converts None', function () {
expect(Option::None()->toPhpOption())
->toBeInstanceOf('PhpOption\None')
->not->toBeNull()
->isDefined()->toBeFalse();
});
test('converts Some', function () {
expect(Option::Some('php')->toPhpOption())
->toBeInstanceOf('PhpOption\Some')
->not->toBeNull()
->isDefined()->toBeTrue()
->get()->toBe('php');
});
});
describe('::Some()', function () {
test('creates a Some option when given a value', function () {
expect(Option::Some('hello'))->not->toBeNull()->isSome()->toBeTrue();
});
test('throws an exception when given null', function () {
expect(fn() => Option::Some(null))->toThrow(InvalidArgumentException::class);
});
});
describe('::None()', function () {
test('creates a None option', function () {
expect(Option::None())->not->toBeNull()->isNone()->toBeTrue();
});
});
describe('::of()', function () {
test('creates a None option when given null', function () {
expect(Option::of(null))->not->toBeNull()->isNone()->toBeTrue();
});
test('creates a Some option when given a value', function () {
expect(Option::of('test'))->not->toBeNull()
->isSome()->toBeTrue()
->get()->toBe('test');
});
test('creates a None option when given PhpOption\None', function () {
expect(Option::of(None::create()))->isNone()->toBeTrue();
});
test('creates a Some option when given PhpOption\Some', function () {
expect(Option::of(Some::create('something')))->isSome()->toBeTrue()->get()->toBe('something');
});
});

206
tests/Unit/ResultTest.php Normal file
View File

@@ -0,0 +1,206 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\{Option, Result};
describe('->getOK()', function () {
test('returns OK value for OK result', function () {
expect(Result::OK('yay')->getOK())->toBe('yay');
});
test('throws an exception for Error result', function () {
expect(fn() => Result::Error('whoops')->getOK())->toThrow(InvalidArgumentException::class);
});
});
describe('->getError()', function () {
test('throws an exception for OK result', function () {
expect(fn() => Result::OK('yeah')->getError())->toThrow(InvalidArgumentException::class);
});
test('returns Error value for Error result', function () {
expect(Result::Error('boo')->getError())->toBe('boo');
});
});
describe('->isOK()', function () {
test('returns true for OK result', function () {
expect(Result::OK('ok')->isOK())->toBeTrue();
});
test('returns false for Error result', function () {
expect(Result::Error('error')->isOK())->toBeFalse();
});
});
describe('->isError()', function () {
test('returns false for OK result', function () {
expect(Result::OK('fine')->isError())->toBeFalse();
});
test('returns true for Error result', function () {
expect(Result::Error('not ok')->isError())->toBeTrue();
});
});
describe('->bind()', function () {
test('returns OK when binding against OK with OK', function () {
expect(Result::OK('one')->bind(fn($it) => Result::OK("$it two")))
->isOK()->toBeTrue()->getOK()->toBe('one two');
});
test('returns Error when binding against OK with Error', function () {
expect(Result::OK('three')->bind(fn($it) => Result::Error('back to two')))
->isError()->toBeTrue()->getError()->toBe('back to two');
});
test('returns Error when binding against Error', function () {
$original = Result::Error('oops');
$result = $original->bind(fn($it) => Result::OK('never mind - it worked!'));
expect($result->isError())->toBeTrue()
->and($result)->toBe($original);
});
});
describe('->contains()', function () {
test('returns true when OK is strictly equal', function () {
expect(Result::OK(18)->contains(18))->toBeTrue();
});
test('returns false when OK is not strictly equal', function () {
expect(Result::OK(18)->contains('18'))->toBeFalse();
});
test('returns true when OK is loosely equal', function () {
expect(Result::OK(18)->contains('18', strict: false))->toBeTrue();
});
test('returns false when OK is not loosely equal', function () {
expect(Result::OK(18)->contains(17, strict: false))->toBeFalse();
});
test('returns false for Error', function () {
expect(Result::Error('ouch')->contains('ouch'))->toBeFalse();
});
});
describe('->exists()', function () {
test('returns true for OK when condition matches', function () {
expect(Result::OK(14)->exists(fn($it) => $it < 100))->toBeTrue();
});
test('returns false for OK when condition does not match', function () {
expect(Result::OK(14)->exists(fn($it) => $it > 100))->toBeFalse();
});
test('returns false for Error', function () {
expect(Result::Error(true)->exists(fn($it) => true))->toBeFalse();
});
});
describe('->map()', function () {
test('maps value for OK', function () {
expect(Result::OK('yard')->map(fn($it) => strrev($it)))
->isOK()->toBeTrue()->getOK()->toBe('dray');
});
test('throws an exception for OK when mapping result is null', function () {
expect(fn() => Result::OK('not null')->map(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
test('does nothing for Error', function () {
$tattle = new class { public bool $called = false; };
$error = Result::Error('nope');
$mapped = $error->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isError()->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($error);
});
});
describe('->mapError()', function () {
test('does nothing for OK', function () {
$tattle = new class { public bool $called = false; };
$ok = Result::OK('sure');
$mapped = $ok->mapError(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isOK()->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($ok);
});
test('maps value for Error', function () {
expect(Result::Error('taco')->mapError(fn($it) => str_repeat('*', strlen($it))))
->isError()->toBeTrue()->getError()->toBe('****');
});
test('throws an exception for Error when mapping result is null', function () {
expect(fn() => Result::Error('pizza')->mapError(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
});
describe('->iter()', function () {
test('iterates for OK', function () {
$target = new class { public mixed $called = null; };
Result::OK(77)->iter(function ($it) use ($target) { $target->called = $it; });
expect($target->called)->toBe(77);
});
test('does nothing for Error', function () {
$target = new class { public mixed $called = null; };
Result::Error('')->iter(function () use ($target) { $target->called = 'uh oh'; });
expect($target->called)->toBeNull();
});
});
describe('->toArray()', function () {
test('returns a one-item array for OK', function () {
expect(Result::OK('yay')->toArray())->not->toBeNull()->toBe(['yay']);
});
test('returns an empty array for Error', function () {
expect(Result::Error('oh no')->toArray())->not->toBeNull()->toBeEmpty();
});
});
describe('->toOption()', function () {
test('returns a Some option for OK', function () {
expect(Result::OK(99)->toOption())->isSome()->toBeTrue()->get()->toBe(99);
});
test('returns a None option for Error', function () {
expect(Result::Error('file not found')->toOption())->isNone()->toBeTrue();
});
});
describe('->tap()', function () {
test('is called for OK', function () {
$value = '';
$original = Result::OK('working');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError();
});
expect($value)->toBe('OK: working')->and($tapped)->toBe($original);
});
test('is called for Error', function () {
$value = '';
$original = Result::Error('failed');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError();
});
expect($value)->toBe('Error: failed')->and($tapped)->toBe($original);
});
});
describe('::OK()', function () {
test('creates an OK result for a non-null value', function () {
expect(Result::OK('something'))->isOK()->toBeTrue()->getOK()->toBe('something');
});
test('throws an exception for OK with a null value', function () {
expect(fn() => Result::OK(null))->toThrow(InvalidArgumentException::class);
});
});
describe('::Error()', function () {
test('creates an Error result for a non-null value', function () {
expect(Result::Error('sad trombone'))->isError()->toBeTrue()->getError()->toBe('sad trombone');
});
test('throws an exception for Error with a null value', function () {
expect(fn() => Result::Error(null))->toThrow(InvalidArgumentException::class);
});
});