Add Option and tests

This commit is contained in:
Daniel J. Summers 2024-07-26 18:10:02 -04:00
parent e16bf30dc3
commit bfc27ccef5
5 changed files with 2138 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
vendor

38
composer.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "bit-badger/inspired-by-fsharp",
"description": "PHP utility classes whose functionality is inspired by their F# counterparts",
"keywords": ["option", "result"],
"license": "MIT",
"authors": [
{
"name": "Daniel J. Summers",
"email": "daniel@bitbadger.solutions",
"homepage": "https://bitbadger.solutions",
"role": "Developer"
}
],
"support": {
"email": "daniel@bitbadger.solutions",
"source": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp",
"rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss"
},
"require": {
"php": "^8"
},
"require-dev": {
"phpunit/phpunit": "^11"
},
"autoload": {
"psr-4": {
"BitBadger\\InspiredByFSharp\\": "./src"
}
},
"autoload-dev": {
"psr-4": {
"Test\\": "./tests"
}
},
"archive": {
"exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ]
}
}

1653
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

205
src/Option.php Normal file
View File

@ -0,0 +1,205 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace BitBadger\InspiredByFSharp;
use InvalidArgumentException;
/**
* Option represents a value that may (or may not) exist.
*
* Idiomatic F# does not use `null`; rather, values that can be present or missing are represented as the `Option` type.
* `Option`s can be `Some` or `None`, and they can be treated as collections with zero or one item (so functions like
* `map` and `iter` become de facto conditional operators).
*
* `Option::Some(T)` and `Option::None()` create instances. `get()` is available on options, but will throw an exception
* if called on a `None` option. The remaining functions are statically available, and should be provided an `Option`
* instance as their final parameter.
*
* @template T The type of value represented by this option
*/
readonly class Option
{
/** @var ?T $value The value for this option */
private mixed $value;
/**
* @param T|null $value The possibly null value for this option
*/
private function __construct(mixed $value = null)
{
$this->value = $value;
}
/**
* Get the value of this option
*
* @return T The value of the option
*/
public function get(): mixed
{
return match (true) {
self::isSome($this) => $this->value,
default => throw new InvalidArgumentException('Cannot get the value of a None option'),
};
}
/**
* Create a `Some` option with the given value
*
* @param T $value The value for the option
* @return Option<T> The `Some` option with the given value
*/
public static function Some(mixed $value): self
{
if (is_null($value)) {
throw new InvalidArgumentException('Cannot create a Some option with null');
}
return new self($value);
}
/**
* Create a `None` option
*
* @return Option<T> A `None` option
*/
public static function None(): self
{
return new self();
}
/**
* Create an option from a value
*
* @param ?T $value The possibly null value from which an option should be constructed
* @return Option<T> The optional value
*/
public static function of(mixed $value): self
{
return match (true) {
// TODO: can we do this check without requiring this package?
// $value instanceof PhpOption => $value->isDefined() ? self::Some($value->get()) : self::None(),
default => new self($value),
};
}
/**
* Does this option have a `None` value?
*
* @param Option<T> $it The option in question
* @return bool True if the option is `None`, false if it is `Some`
*/
public static function isNone(Option $it): bool
{
return is_null($it->value);
}
/**
* Does this option have a `Some` value?
*
* @param Option<T> $it The option in question
* @return bool True if the option is `Some`, false if it is `None`
*/
public static function isSome(Option $it): bool
{
return !self::isNone($it);
}
/**
* Get the value, or a default value, from an option
*
* @param T $default The default value to return if the option is `None`
* @param Option<T> $it The option in question
* @return T The `Some` value, or the default value if the option is `None`
*/
public static function defaultValue(mixed $default, Option $it): mixed
{
return self::isSome($it) ? $it->get() : $default;
}
/**
* 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
* @param Option<T> $it The option in question
* @return T|mixed The value if `Some`, the result of the callable if `None`
*/
public static function getOrCall(callable $f, Option $it): mixed
{
return self::isSome($it) ? $it->get() : $f();
}
/**
* Map this optional value to another value
*
* @template U The type of the mapping function
* @param callable(T): U $f The mapping function
* @param Option<T> $it The option in question
* @return Option<U> A `Some` instance with the transformed value if `Some`, `None` otherwise
*/
public static function map(callable $f, Option $it): self
{
return self::isSome($it) ? self::Some($f($it->get())) : self::None();
}
/**
* Execute a function on the value (if it exists)
*
* @param callable(T): void $f The function to call
* @param Option<T> $it The option in question
*/
public static function iter(callable $f, Option $it): void
{
if (self::isSome($it)) {
$f($it->get());
}
}
/**
* Transform an option into `None` if it does not match the given function
*
* @param callable(T): bool $f The filter function to run
* @param Option<T> $it The option in question
* @return Option<T> The option, if it was `Some` and the function returned true; `None` otherwise
*/
public static function filter(callable $f, Option $it): self
{
return match (true) {
self::isNone($it) => self::None(),
default => $f($it->get()) ? self::Some($it->get()) : self::None(),
};
}
/**
* Does the option have the given value?
*
* @param T $value The value to be checked
* @param Option<T> $it The option in question
* @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 static function is(mixed $value, Option $it, bool $strict = true): bool
{
return match (true) {
self::isNone($it) => false,
default => $strict ? $it->value === $value : $it->value == $value,
};
}
/**
* Safely retrieve the optional value as a nullable value
*
* @param Option<T> $it The option in question
* @return ?T The value for `Some` instances, `null` for `None` instances
*/
public static function unwrap(Option $it): mixed
{
return self::isSome($it) ? $it->get() : null;
}
}

240
tests/OptionTest.php Normal file
View File

@ -0,0 +1,240 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use BitBadger\InspiredByFSharp\Option;
use InvalidArgumentException;
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(Option::isSome($it), '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();
}
public function testSomeSucceedsWithValue(): void
{
$it = Option::Some('hello');
$this->assertTrue(Option::isSome($it), '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(Option::isNone($it), 'The option should have been "None"');
}
public function testOfSucceedsWithNull(): void
{
$it = Option::of(null);
$this->assertTrue(Option::isNone($it), '"null" should have created a "None" option');
}
public function testOfSucceedsWithNonNull(): void
{
$it = Option::of('test');
$this->assertTrue(Option::isSome($it), 'A non-null value should have created a "Some" option');
$this->assertEquals('test', $it->get(), 'The value was not assigned correctly');
}
#[TestDox('IsNone succeeds with None')]
public function testIsNoneSucceedsWithNone(): void
{
$this->assertTrue(Option::isNone(Option::None()), '"None" should return true');
}
#[TestDox('IsNone succeeds with Some')]
public function testIsNoneSucceedsWithSome(): void
{
$this->assertFalse(Option::isNone(Option::Some(8)), '"Some" should return false');
}
#[TestDox('IsSome succeeds with None')]
public function testIsSomeSucceedsWithNone(): void
{
$this->assertFalse(Option::isSome(Option::None()), '"None" should return false');
}
#[TestDox('IsSome succeeds with Some')]
public function testIsSomeSucceedsWithSome(): void
{
$this->assertTrue(Option::isSome(Option::Some('boo')), '"Some" should return true');
}
#[TestDox('DefaultValue succeeds with None')]
public function testDefaultValueSucceedsWithNone(): void
{
$this->assertEquals('yes', Option::defaultValue('yes', Option::None()), 'Value should have been default');
}
#[TestDox('DefaultValue succeeds with Some')]
public function testDefaultValueSucceedsWithSome(): void
{
$this->assertEquals('no', Option::defaultValue('yes', Option::Some('no')),
'Value should have been from option');
}
#[TestDox('GetOrCall succeeds with None')]
public function testGetOrCallSucceedsWithNone(): void
{
$value = Option::getOrCall(new class { public function __invoke(): string { return 'called'; } },
Option::None());
$this->assertEquals('called', $value, 'The value should have been obtained from the callable');
}
#[TestDox('GetOrCall succeeds with Some')]
public function testGetOrCallSucceedsWithSome(): void
{
$value = Option::getOrCall(new class { public function __invoke(): string { return 'called'; } },
Option::Some('passed'));
$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 = Option::map(function ($ignored) use ($tattle)
{
$tattle->called = true;
return 'hello';
}, $none);
$this->assertTrue(Option::isNone($mapped), 'The mapped option should be "None"');
$this->assertFalse($tattle->called, 'The mapping function should not have been called');
$this->assertNotSame($none, $mapped, 'There should have been a new "None" instance returned');
}
#[TestDox('Map succeeds with Some')]
public function testMapSucceedsWithSome(): void
{
$mapped = Option::map(fn($it) => str_repeat($it, 2), Option::Some('abc '));
$this->assertTrue(Option::isSome($mapped), '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::map(fn($it) => null, Option::Some('oof'));
}
#[TestDox('Iter succeeds with None')]
public function testIterSucceedsWithNone(): void
{
$target = new class { public mixed $called = null; };
Option::iter(function ($ignored) use ($target) { $target->called = 'uh oh'; }, Option::None());
$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::iter(function ($it) use ($target) { $target->called = $it; }, Option::Some(33));
$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 = Option::filter(function ($ignored) use ($tattle)
{
$tattle->called = true;
return true;
}, $none);
$this->assertTrue(Option::isNone($filtered), 'The filtered option should have been "None"');
$this->assertFalse($tattle->called, 'The callable should not have been called');
}
#[TestDox('Filter succeeds with Some when true')]
public function testFilterSucceedsWithSomeWhenTrue(): void
{
$some = Option::Some(12);
$filtered = Option::filter(fn($it) => $it % 2 === 0, $some);
$this->assertTrue(Option::isSome($filtered), 'The filtered option should have been "Some"');
$this->assertEquals(12, $filtered->get(), 'The filtered option value is incorrect');
$this->assertNotSame($some, $filtered, 'There should have been a new option instance returned');
}
#[TestDox('Filter succeeds with Some when false')]
public function testFilterSucceedsWithSomeWhenFalse(): void
{
$some = Option::Some(23);
$filtered = Option::filter(fn($it) => $it % 2 === 0, $some);
$this->assertTrue(Option::isNone($filtered), 'The filtered option should have been "None"');
}
#[TestDox('Is succeeds with None')]
public function testIsSucceedsWithNone(): void
{
$this->assertFalse(Option::is(null, Option::None()), '"None" should always return false');
}
#[TestDox('Is succeeds with Some when strictly equal')]
public function testIsSucceedsWithSomeWhenStrictlyEqual(): void
{
$this->assertTrue(Option::is(3, Option::Some(3)), '"Some" with strict equality should be true');
}
#[TestDox('Is succeeds with Some when strictly unequal')]
public function testIsSucceedsWithSomeWhenStrictlyUnequal(): void
{
$this->assertFalse(Option::is(3, Option::Some('3')), '"Some" with strict equality should be false');
}
#[TestDox('Is succeeds with Some when loosely equal')]
public function testIsSucceedsWithSomeWhenLooselyEqual(): void
{
$this->assertTrue(Option::is(3, Option::Some('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::is(4, Option::Some('3'), strict: false),
'"Some" with loose equality should be false');
}
#[TestDox('Unwrap succeeds with None')]
public function testUnwrapSucceedsWithNone(): void
{
$this->assertNull(Option::unwrap(Option::None()), '"None" should return null');
}
#[TestDox('Unwrap succeeds with Some')]
public function testUnwrapSucceedsWithSome(): void
{
$this->assertEquals('boy howdy', Option::unwrap(Option::Some('boy howdy')), '"Some" should return its value');
}
}