Add Option and tests
This commit is contained in:
parent
e16bf30dc3
commit
bfc27ccef5
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.idea
|
||||
vendor
|
38
composer.json
Normal file
38
composer.json
Normal 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
1653
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
205
src/Option.php
Normal file
205
src/Option.php
Normal 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
240
tests/OptionTest.php
Normal 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');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user