8 Commits

Author SHA1 Message Date
2e2b1eef18 More WIP on Arr functions 2026-01-26 21:56:42 -05:00
34db7b1295 WIP on pipeable array functions 2026-01-25 22:54:25 -05:00
c61ff7a831 Convert tests to Pest 2024-11-20 22:01:40 -05:00
9327d8fa29 Update properties in README 2024-09-30 23:06:57 -04:00
483d7875d5 Change functions to properties
- Force PHP 8.4
2024-09-30 22:59:46 -04: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
14 changed files with 3641 additions and 990 deletions

1
.gitignore vendored
View File

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

View File

@@ -2,30 +2,74 @@
This project contains PHP utility classes whose functionality is inspired by their F# counterparts.
The v3 series requires at least PHP 8.5, and includes pipeable `array_*` functions. The v2 series requires at least PHP 8.4. A similar v2 API exists for PHP 8.2 - 8.3 in version 1 of this project; see its README for specifics.
## What It Provides
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 library currently provides three classes.
### `Option`s and `Result`s
Two of them 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 |
|----------------------------------------------------|--------------------------------------------------------------------------------|------------------------------------------------------------------|
| **Creating** | `::Some(T)` for Some<br>`::None()` for None<br>`::of($value)` _None if `null`_ | `::OK(TOK)` for OK<br>`::Error(TError)` for Error |
| **Querying** | `->isSome()`<br>`->isNone()` | `->isOK()`<br>`->isError()` |
| **Reading**<br>_throws if called on missing value_ | `->get()` | `->getOK()`<br>`->getError()` |
| **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U)` | `->map(callable(TOK): U)`<br>`->mapError(callable(TError): U)` |
| **Iterating** | `->iter(callable(T): void)` | `->iter(callable(TOK): void)` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void)` | `->tap(callable(Result<TOK, TError>): void)` |
|---------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------------------------------|
| **Creating** | `::Some(T)` for Some | `::OK(TOK)` for OK |
| | `::None()` for None | `::Error(TError)` for Error |
| | `::of($value)` _None if `null`_ | |
| **Querying** | `->isSome: bool` | `->isOK: bool` |
| | `->isNone: bool` | `->isError: bool` |
| | `->contains(T, $strict = true): bool` | `->contains(TOK, $strict = true): bool` |
| | `->exists(callable(T): bool): bool` | `->exists(callable(TOK): bool): bool` |
| **Reading**<br> | `->value: T` | `->ok: TOK` |
| _all throw if called on missing value_ | | `->error: 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:
- `->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`.
- `->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.
- `->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.
`Result<TOK, TError>` also provides:
- `toOption()` will transform an OK result to a Some option, and an Error result to a None option.
> We would be remiss to not acknowledge some excellent 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.
### Pipeable `array_*` Functions
The final class, exclusive to v3, is `Arr`, which wraps the `array_*` functions with versions that return a single-arity (AKA "one parameter") function which takes the array and returns the result.
The `array_map` example is easy to understand. This function requires the array first, and the transformation callback second. This does not play nicely with PHP 8.5's pipeline operator (`|>`); to pipe to `array_map`, one has to create an anonymous function to reverse the parameters. `Arr::map()` does this for you.
```php
// The result of both calls is [2, 4, 6]
// Without this library
$withoutLibrary =
[1, 2, 3]
|> fn ($arr) => array_map($arr, fn ($x) => x * 2);
// With this library
$withLibrary =
[1, 2, 3]
|> Arr::map(fn ($x) => $x * 2);
```
All `array_*` functions which expect the target array as the first parameter have a matching `Arr::*` function which provides a version of the function with the array last. `array_*` functions which only take the array (such as `array_count_values` or `array_keys`) have corresponding functions in `Arr` for completeness; when piping to these, call the `Arr` function vs. using it as a `callable`.
```php
// Use this
$theKeys = $array |> Arr::keys();
// ...or this
$theKeys = $array |> array_keys(...);
// Not this
$theKeys = $array |> Arr::keys(...);
```
## The Inspiration
@@ -56,7 +100,7 @@ $value = Option::of($myVar)
->getOrDefault('There was no string');
```
If PHP gets a pipeline operator, we'll revisit lots of stuff here (in a non-breaking way, of course).
If PHP gets a pipeline operator (TODO: !!!!), we'll revisit lots of stuff here (in a non-breaking way, of course).
## Ideas

View File

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

2968
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>

185
src/Arr.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
* @since 3.0.0
*/
declare(strict_types=1);
namespace BitBadger\InspiredByFSharp;
use Error;
use Exception;
use InvalidArgumentException;
/**
* `Arr` has static functions that wrap PHP's native `array_*` functions with versions that provide a function which
* takes the input array. This makes these functions pipeable using PHP 8.5's pipe operator (ex.
* `array(1, 2, 3) |> Arr::map(fn ($x) => $x * 2)` would provide the array `[2, 4, 6]`).
*/
class Arr
{
/**
* Determine if all elements of an array match the given boolean callback.
*
* @param callable<mixed, ?mixed> $callback The callback function to call to check each element. If this function
* returns `false`, `false` is returned and the callback will not be called for further elements.
* @return callable<array, array> A function that calls `array_all` with the given array.
* @see https://www.php.net/manual/en/function.array-all.php `array_all` Documentation
*/
public static function all(callable $callback): callable
{
return fn (array $array) => array_all($array, $callback);
}
/**
* Determine if any elements of an array match the given boolean callback.
*
* @param callable<mixed, ?mixed> $callback The callback function to call to check each element. If this function
* returns `true`, `true` is returned and the callback will not be called for further elements.
* @return callable<array, array> A function that calls `array_any` with the given array.
* @see https://www.php.net/manual/en/function.array-any.php array_any Documentation
*/
public static function any(callable $callback): callable
{
return fn (array $array) => array_any($array, $callback);
}
/**
* Returns an array with all keys from array lowercased or uppercased. Numbered indices are left as is.
*
* @param int $case Either `CASE_UPPER` or `CASE_LOWER` (default)
* @return callable<array, array> A function that calls `array_change_key_case` with the given array.
* @see https://www.php.net/manual/en/function.array-change-key-case.php array_change_key_case Documentation
*/
public static function changeKeyCase(int $case = CASE_LOWER): callable
{
return fn (array $array) => array_change_key_case($array, $case);
}
/**
* Chunks an array into arrays with `$length` elements. The last chunk may contain less than `$length` elements.
*
* @param int $length The size of each chunk.
* @param bool $preserveKeys When set to `true` keys will be preserved. Default is `false` which will reindex the
* chunk numerically.
* @return callable<array, array<array>> A function that calls `array_chunk` with the given array.
* @see https://www.php.net/manual/en/function.array-chunk.php array_chunk Documentation
*/
public static function chunk(int $length, bool $preserveKeys = false): callable
{
return fn (array $array) => array_chunk($array, $length, $preserveKeys);
}
/**
* Returns the values from a single column of the array, identified by the `$columnKey`. Optionally, an `$indexKey`
* may be provided to index the values in the returned array by the values from the `$indexKey` column of the input
* array.
*
* @param int|string|null $columnKey The column of values to return. This value may be an integer key of the column
* you wish to retrieve, or it may be a string key name for an associative array or property name. It may also
* be `null` to return complete arrays or objects (this is useful together with `$indexKey` to reindex the
* array).
* @param int|string|null $indexKey The column to use as the index/keys for the returned array. This value may be
* the integer key of the column, or it may be the string key name. The value is cast as usual for array keys.
* @return callable A function that calls `array_column` with the given array.
* @see https://www.php.net/manual/en/function.array-column.php array_column Documentation
*/
public static function column(int|string|null $columnKey, int|string|null $indexKey = null): callable
{
return fn (array $array) => array_column($array, $columnKey, $indexKey);
}
/**
* Counts the occurrences of each distinct value in an array.
*
* @return callable<array<mixed, int|string>, array<int|string, int>> A function that calls `array_count` with the
* given array.
* @see https://www.php.net/manual/en/function.array-count-values.php array_count_values Documentation
*/
public static function countValues(): callable
{
return array_count_values(...);
}
/**
* Compares `$array` against one or more other arrays and returns the values in `$array` that are not present in any
* of the other arrays.
*
* @param array ...$arrays The arrays against which the comparison should be made
* @return callable<array, array> A function that calls `array_diff` with the given array.
* @see https://www.php.net/manual/en/function.array-diff.php array_diff Documentation
*/
public static function diff(array ...$arrays): callable
{
return fn (array $array) => array_diff($array, ...$arrays);
}
/**
* Compares `$array` against `$arrays` and returns the difference. Unlike `Arr::diff()`, the array keys are also
* used in the comparison.
*
* @param array ...$arrays The arrays against which the comparison should be made
* @return callable<array, array> A function that calls `array_diff_assoc` with the given array.
* @see https://www.php.net/manual/en/function.array-diff-assoc.php array_diff_assoc Documentation
*/
public static function diffAssoc(array ...$arrays): callable
{
return fn (array $array) => array_diff_assoc($array, ...$arrays);
}
/**
* Compares the keys from `$array` against the keys from `$arrays` and returns the difference. This function is like
* `Arr::diff()`, except the comparison is done on the keys instead of the values.
*
* @param array ...$arrays The arrays against which the comparison should be made
* @return callable<array, array> A function that calls `array_diff_key` with the given array.
* @see https://www.php.net/manual/en/function.array-diff-key.php array_diff_key Documentation
*/
public static function diffKey(array ...$arrays): callable
{
return fn (array $array) => array_diff_key($array, ...$arrays);
}
/**
* Compares `$array` against `$arrays` and returns the difference. Unlike `Arr::diff()`, the array keys are also
* used in the comparison. Unlike `Arr::diffAssoc()`, a user supplied callback function is used for the indices
* comparison, not internal function.
*
* @param callable<mixed, mixed, int> $keyCompareFunc The comparison function must return an integer less than,
* equal to, or greater than zero if the first argument is considered to be respectively less than, equal to, or
* greater than the second.
* @param array ...$arrays The arrays against which the comparison should be made
* @return callable<array, array> A function that calls `array_diff_assoc` with the given array.
* @see https://www.php.net/manual/en/function.array-diff-assoc.php array_diff_assoc Documentation
*/
public static function diffUAssoc(callable $keyCompareFunc, array ...$arrays): callable
{
return fn (array $array) => array_diff_uassoc($array, $arrays, $keyCompareFunc);
}
/**
* Transform each element of an array to a different element (optionally merging other mapped arrays).
*
* @param callable|null $callback A callable to run for each element in each array.<br><br>
* `null` can be passed as a value to `$callback` to perform a zip operation on multiple arrays and return
* an array where each element is an array containing the elements from the input arrays at the same position of
* the internal array pointer. If only array is provided, `Arr::map()` will return the input array.
* @param array ...$arrays Supplementary variable list of array arguments to run through the callback function.
* @return callable<array, array> A function that calls `array_map` with the given array.
* @see https://www.php.net/manual/en/function.array-map.php array_map Documentation
*/
public static function map(?callable $callback, array ...$arrays): callable
{
return fn (array $array) => array_map($callback, $array, ...$arrays);
}
/**
* @throws Exception This class should not be constructed
*/
private function __construct()
{
throw new Exception('This is a static class; do not instantiate it');
}
}

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace BitBadger\InspiredByFSharp;
use Error;
use Exception;
use InvalidArgumentException;
/**
@@ -24,50 +25,38 @@ use InvalidArgumentException;
*
* @template T The type of value represented by this option
*/
readonly class Option
class Option
{
/** @var T|null $value The value for this option */
private mixed $value;
private mixed $val;
/**
* @param T|null $value The possibly null value for this option
*/
private function __construct(mixed $value = null)
{
$this->value = $value;
$this->val = $value;
}
/**
* Get the value of this option
*
* @return T The value of the option
* @var T The value of this option (read-only)
* @throws InvalidArgumentException If called on a `None` option
*/
public function get(): mixed
{
return match (true) {
$this->isSome() => $this->value,
default => throw new InvalidArgumentException('Cannot get the value of a None option'),
public mixed $value {
get => match ($this->val) {
null => throw new InvalidArgumentException('Cannot get the value of a None option'),
default => $this->val,
};
}
/**
* Does this option have a `None` value?
*
* @return bool True if the option is `None`, false if it is `Some`
*/
public function isNone(): bool
{
return is_null($this->value);
/** @var bool True if the option is `None`, false if it is `Some` */
public bool $isNone {
get => is_null($this->val);
}
/**
* Does this option have a `Some` value?
*
* @return bool True if the option is `Some`, false if it is `None`
*/
public function isSome(): bool
{
return !$this->isNone();
/** @var bool True if the option is `Some`, false if it is `None` */
public bool $isSome{
get => !$this->isNone;
}
/**
@@ -78,31 +67,84 @@ readonly class Option
*/
public function getOrDefault(mixed $default): mixed
{
return $this->value ?? $default;
return $this->val ?? $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
* @return T|mixed The value if `Some`, the result of the callable if `None`
* @template TCalled The return type of the callable provided
* @param callable(): TCalled $f The callable function to use for `None` options
* @return T|TCalled The value if `Some`, the result of the callable if `None`
*/
public function getOrCall(callable $f): mixed
{
return $this->value ?? $f();
return $this->val ?? $f();
}
/**
* Get the value, or throw the exception using the given function
*
* @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->val ?? 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->val);
}
/**
* 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->val === $value : $this->val == $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->val) : false;
}
/**
* Map this optional value to another value
*
* @template U The type of the mapping function
* @param callable(T): U $f The mapping function
* @return Option<U> A `Some` instance with the transformed value if `Some`, `None` otherwise
* @template TMapped The type of the mapping function
* @param callable(T): TMapped $f The mapping function
* @return Option<TMapped> A `Some` instance with the transformed value if `Some`, `None` otherwise
*/
public function map(callable $f): self
{
return $this->isSome() ? self::Some($f($this->get())) : $this;
return $this->isSome ? self::Some($f($this->val)) : $this;
}
/**
@@ -112,8 +154,8 @@ readonly class Option
*/
public function iter(callable $f): void
{
if ($this->isSome()) {
$f($this->value);
if ($this->isSome) {
$f($this->val);
}
}
@@ -125,25 +167,7 @@ readonly class Option
*/
public function filter(callable $f): self
{
return match (true) {
$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,
};
return $this->isNone || $this->exists($f) ? $this : self::None();
}
/**
@@ -153,7 +177,7 @@ readonly class Option
*/
public function unwrap(): mixed
{
return $this->value;
return $this->val;
}
/**
@@ -168,6 +192,16 @@ readonly class Option
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->val] : [];
}
/**
* Convert this to a PhpOption option
*
@@ -176,8 +210,8 @@ readonly class Option
public function toPhpOption(): mixed
{
return match (true) {
$this->isNone() && class_exists('PhpOption\None') => call_user_func('PhpOption\None::create'),
class_exists('PhpOption\Some') => call_user_func('PhpOption\Some::create', $this->value),
$this->isNone && class_exists('PhpOption\None') => call_user_func('PhpOption\None::create'),
class_exists('PhpOption\Some') => call_user_func('PhpOption\Some::create', $this->val),
default => throw new Error('PhpOption types could not be found'),
};
}

View File

@@ -25,7 +25,7 @@ use InvalidArgumentException;
* @template TOK The type of the OK result
* @template TError The type of the error result
*/
readonly class Result
class Result
{
/** @var Option<TOK> The OK value for this result */
private Option $okValue;
@@ -45,70 +45,90 @@ readonly class Result
$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();
/** @var TOK The OK value (will throw if result is not OK) */
public mixed $ok {
get => $this->okValue->value;
}
/** @var TError The error value (will throw if result is not Error) */
public mixed $error {
get => $this->errorValue->value;
}
/** @var bool True if the result is `OK`, false if it is `Error` */
public bool $isOK {
get => $this->okValue->isSome;
}
/** @var bool True if the result is `Error`, false if it is `OK` */
public bool $isError {
get => $this->errorValue->isSome;
}
/**
* Get the value for an `Error` result
* Bind a function to this result (railway processing)
*
* @return TError The error value for this result
* @throws InvalidArgumentException If the result is an `OK` result
* 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 getError(): mixed
public function bind(callable $f): Result
{
return $this->errorValue->get();
return $this->isError ? $this : $f($this->ok);
}
/**
* Is this result `OK`?
* Does this result's "OK" value match the given value?
*
* @return bool True if the result is `OK`, false if it is `Error`
* @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 isOK(): bool
public function contains(mixed $value, bool $strict = true): bool
{
return $this->okValue->isSome();
return match (true) {
$this->isError => false,
default => $this->okValue->contains($value, $strict),
};
}
/**
* Is this result `Error`?
* Does the "OK" value of this result match the given predicate function?
*
* @return bool True if the result is `Error`, false if it is `OK`
* @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 isError(): bool
public function exists(callable $f): bool
{
return $this->errorValue->isSome();
return $this->isOK ? $f($this->ok) : false;
}
/**
* 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
* @return Result<U, TError> A transformed `OK` instance or the original `Error` instance
* @template TMapped The type of the mapping function
* @param callable(TOK): TMapped $f The mapping function
* @return Result<TMapped, TError> A transformed `OK` instance or the original `Error` instance
*/
public function map(callable $f): self
{
return $this->isOK() ? self::OK($f($this->getOK())) : $this;
return $this->isOK ? self::OK($f($this->ok)) : $this;
}
/**
* 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
* @return Result<TOK, U> A transformed `Error` instance or the original `OK` instance
* @template TMapped The type of the mapping function
* @param callable(TError): TMapped $f The mapping function
* @return Result<TOK, TMapped> A transformed `Error` instance or the original `OK` instance
*/
public function mapError(callable $f): self
{
return $this->isError() ? self::Error($f($this->getError())) : $this;
return $this->isError ? self::Error($f($this->error)) : $this;
}
/**
@@ -118,11 +138,21 @@ readonly class Result
*/
public function iter(callable $f): void
{
if ($this->isOK()) {
$f($this->getOK());
if ($this->isOK) {
$f($this->ok);
}
}
/**
* 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`
*
@@ -130,7 +160,7 @@ readonly class Result
*/
public function toOption(): Option
{
return $this->isOK() ? Option::Some($this->getOK()) : Option::None();
return $this->isOK ? Option::Some($this->ok) : Option::None();
}
/**

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);
}
}

90
tests/Unit/ArrTest.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Arr;
describe('::all()', function () {
test('succeeds when all elements match', function () {
expect([2, 4, 6] |> Arr::all(fn ($it) => $it % 2 === 0))->toBeTrue();
});
test('succeeds when not all elements match', function () {
expect([2, 5, 6] |> Arr::all(fn ($it) => $it % 2 === 0))->toBeFalse();
});
});
describe('::any()', function () {
test('succeeds when no elements match', function () {
expect([2, 4, 6] |> Arr::any(fn ($it) => $it % 2 === 1))->toBeFalse();
});
test('succeeds when an element matches', function () {
expect([2, 5, 6] |> Arr::any(fn ($it) => $it % 2 === 1))->toBeTrue();
});
});
describe('::changeKeyCase()', function () {
test('succeeds', function () {
$test = ['ONE' => 1, 'two' => 2, 'Three' => 3] |> Arr::changeKeyCase();
expect(array_keys($test))->toEqual(['one', 'two', 'three']);
});
});
describe('::chunk()', function () {
test('succeeds', function () {
expect([1, 2, 3, 4, 5] |> Arr::chunk(2))->toBeArray()->toHaveLength(3);
});
});
describe('::countValues()', function () {
test('succeeds', function () {
$arr = ['this' => 1, 'that' => 2, 'theOther' => 3, 'somewhere' => 1, 'else' => 5] |> Arr::countValues();
expect($arr)->toBeArray()
->and(array_keys($arr))->toHaveLength(4)
->and($arr[1])->toBe(2)
->and($arr[2])->toBe(1)
->and($arr[3])->toBe(1)
->and($arr[5])->toBe(1);
});
});
describe('::diff()', function () {
test('succeeds', function () {
expect([5, 10, 15, 20] |> Arr::diff([1, 2, 3, 4, 5], [10, 20, 30, 40, 50]))->toEqual([2 => 15]);
});
});
describe('::diffAssoc()', function () {
test('succeeds', function () {
expect([1, 2, 3] |> Arr::diffAssoc([1, 3, 2], [2, 1, 3]))->toEqual([1 => 2]);
});
});
describe('::diffKey()', function () {
test('succeeds', function () {
expect([1, 2, 3, 4] |> Arr::diffKey([1, 3, 2], [2, 1, 3]))->toEqual([3 => 4]);
});
});
describe('::diffUAssoc()', function () {
test('succeeds', function () {
// TODO: Something is not right here
$arr = [1, 2, 3] |> Arr::diffUAssoc(fn ($a, $b) => $a * 2 <=> $b * 2, [1, 3, 2], [2, 1, 3]);
expect(true)->toBeTrue();
// expect($arr)->toBeArray()->toHaveCount(3)
// ->and($arr[0])->toEqual(1)
// ->and($arr[1])->toEqual(2)
// ->and($arr[2])->toEqual(3);
//->toEqual([2 => 3, 0 => 1, 1 => 2]);
});
});
describe('::map()', function () {
test('maps an array', function () {
expect([1, 2, 3] |> Arr::map(fn ($it) => $it * 2))->toEqual([2, 4, 6]);
});
});

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('->value', function () {
test('retrieves the value for Some', function () {
expect(Option::Some(9)->value)->toBe(9);
});
test('throws an exception for None', function () {
expect(fn() => Option::None()->value)->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()->value->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()->value->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()
->value->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->value : '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->value : '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()
->value->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()->value->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('->ok', function () {
test('returns OK value for OK result', function () {
expect(Result::OK('yay')->ok)->toBe('yay');
});
test('throws an exception for Error result', function () {
expect(fn() => Result::Error('whoops')->ok)->toThrow(InvalidArgumentException::class);
});
});
describe('->error', function () {
test('throws an exception for OK result', function () {
expect(fn() => Result::OK('yeah')->error)->toThrow(InvalidArgumentException::class);
});
test('returns Error value for Error result', function () {
expect(Result::Error('boo')->error)->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()->ok->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()->error->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()->ok->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()->error->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()->value->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->ok : 'Error: ' . $it->error;
});
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->ok : 'Error: ' . $it->error;
});
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()->ok->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()->error->toBe('sad trombone');
});
test('throws an exception for Error with a null value', function () {
expect(fn() => Result::Error(null))->toThrow(InvalidArgumentException::class);
});
});