Skip to content

Commit

Permalink
feat(option): add Option::zip(), Option::zipWith() and `Option::u…
Browse files Browse the repository at this point in the history
…nzip()` methods (#434)

* feat(option): add `Option::zip()`, `Option::zipWith()` and `Option::unzip()` methods

* Requested changes, static analysis tests

* Updated wrong phpdoc

* Removed wrong and redundant phpdoc

* Removed conditional return
  • Loading branch information
devnix authored Dec 26, 2023
1 parent 15651cb commit 6f19d45
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 1 deletion.
2 changes: 1 addition & 1 deletion docs/component/option.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@

#### `Classes`

- [Option](./../../src/Psl/Option/Option.php#L16)
- [Option](./../../src/Psl/Option/Option.php#L17)


69 changes: 69 additions & 0 deletions src/Psl/Option/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Closure;
use Psl\Comparison;
use Psl\Type;

/**
* @template T
Expand Down Expand Up @@ -305,4 +306,72 @@ public function equals(mixed $other): bool
{
return Comparison\equal($this, $other);
}

/**
* Combines two `Option` values into a single `Option` containing a tuple of the two inner values.
* If either of the `Option`s is `None`, the resulting `Option` will also be `None`.
*
* @template Tu
*
* @param Option<Tu> $other The other `Option` to zip with.
*
* @return Option<array{T, Tu}> The resulting `Option` containing the combined tuple or `None`.
*/
public function zip(Option $other): Option
{
return $this->andThen(static function ($a) use ($other) {
return $other->map(static fn($b) => [$a, $b]);
});
}

/**
* Applies the provided closure to the value contained in this `Option` and the value contained in the $other `Option`,
* and returns a new `Option` containing the result of the closure.
*
* @template Tu
* @template Tr
*
* @param Option<Tu> $other The Option to zip with.
* @param (Closure(T, Tu): Tr) $closure The closure to apply to the values.
*
* @return Option<Tr> The new `Option` containing the result of applying the closure to the values,
* or `None` if either this or the $other `Option is `None`.
*/
public function zipWith(Option $other, Closure $closure): Option
{
return $this->andThen(
/** @param T $a */
static function ($a) use ($other, $closure) {
return $other->map(
/** @param Tu $b */
static fn ($b) => $closure($a, $b)
);
}
);
}

/**
* @template Tv
* @template Tr
*
* @psalm-if-this-is Option<array{Tv, Tr}>
*
* @throws Type\Exception\AssertException
*
* @return array{Option<Tv>, Option<Tr>}
*/
public function unzip(): array
{
if ($this->option === null) {
return [none(), none()];
}

// Assertion done in a separate variable to avoid Psalm inferring the type of $this->option as mixed
$option = $this->option[0];
Type\shape([Type\mixed(), Type\mixed()])->assert($option);

[$a, $b] = $option;

return [some($a), some($b)];
}
}
14 changes: 14 additions & 0 deletions tests/fixture/Point.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Fixture;

final class Point
{
public function __construct(
public readonly int $x,
public readonly int $y,
) {
}
}
76 changes: 76 additions & 0 deletions tests/static-analysis/Option/zip.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

use Psl\Option;
use Psl\Type;

/**
* @return Option\Option<array{never, int}>
*/
function test_partial_none_tuple_1(): Option\Option
{
return Option\none()->zip(Option\some(1));
}

/**
* @return Option\Option<array{int, never}>
*/
function test_partial_none_tuple_2(): Option\Option
{
return Option\some(1)->zip(Option\none());
}

/**
* @throws Type\Exception\AssertException
*
* @return array{Option\Option<never>, Option\Option<int>}
*/
function test_partial_none_unzip_1(): array
{
return test_partial_none_tuple_1()->unzip();
}

/**
* @return Option\Option<array{int, string}>
*/
function test_some_zip(): Option\Option
{
return Option\some(1)->zip(Option\some('2'));
}

/**
* @throws Type\Exception\AssertException
*
* @return array{Option\Option<int>, Option\Option<never>}
*/
function test_partial_none_unzip_2(): array
{
return test_partial_none_tuple_2()->unzip();
}

/**
* @throws Type\Exception\AssertException
*
* @return array{Option\Option<int>, Option\Option<string>}
*/
function test_some_unzip(): array
{
return test_some_zip()->unzip();
}

/**
* @return Option\Option<int>
*/
function test_some_zip_with()
{
return Option\some(1)->zipWith(Option\some('2'), static fn($a, $b) => $a + (int) $b);
}

/**
* @return Option\Option<string>
*/
function test_some_zip_with_2()
{
return Option\some(1)->zipWith(Option\some('2'), static fn($a, $b) => $b);
}
37 changes: 37 additions & 0 deletions tests/unit/Option/NoneTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,41 @@ public function testEquality(): void
static::assertTrue($a->equals(Option\none()));
static::assertFalse($a->equals(Option\some('other')));
}

public function testZip(): void
{
$x = Option\some(1);
$y = Option\none();

static::assertTrue($x->zip($y)->isNone());
static::assertTrue($y->zip($x)->isNone());
}

public function testZipWith(): void
{
$x = Option\some(1);
$y = Option\none();

static::assertTrue($x->zipWith($y, static fn($a, $b) => $a + $b)->isNone());
static::assertTrue($y->zipWith($x, static fn($a, $b) => $a + $b)->isNone());
}

/**
* @dataProvider provideTestUnzip
*/
public function testUnzip(Option\Option $option): void
{
[$x, $y] = $option->unzip();

static::assertTrue($x->isNone());
static::assertTrue($y->isNone());
}

private function provideTestUnzip(): iterable
{
yield [Option\none()];
yield [Option\none()->zip(Option\none())];
yield [Option\none()->zip(Option\some(1))];
yield [Option\some(1)->zip(Option\none())];
}
}
55 changes: 55 additions & 0 deletions tests/unit/Option/SomeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use Psl\Comparison\Equable;
use Psl\Comparison\Order;
use Psl\Option;
use Psl\Tests\Fixture;
use Psl\Type;

final class SomeTest extends TestCase
{
Expand Down Expand Up @@ -129,4 +131,57 @@ public function testEquality()
static::assertFalse($a->equals(Option\some('other')));
static::assertTrue($a->equals(Option\some('a')));
}

public function testZip(): void
{
$x = Option\some(1);
$y = Option\some("hi");

static::assertTrue(Option\some([1, 'hi'])->equals($x->zip($y)));
static::assertTrue(Option\some(['hi', 1])->equals($y->zip($x)));
}

public function testZipWith(): void
{
$x = Option\some(17);
$y = Option\some(42);

$point = $x->zipWith($y, static fn($a, $b) => new Fixture\Point($a, $b));

static::assertTrue(Option\some(new Fixture\Point(17, 42))->equals($point));
}

/**
* @dataProvider provideTestUnzip
*/
public function testUnzip(Option\Option $option, mixed $expectedX, mixed $expectedY): void
{
[$x, $y] = $option->unzip();

static::assertSame($expectedX, $x->unwrap());
static::assertSame($expectedY, $y->unwrap());
}

private function provideTestUnzip(): iterable
{
yield [Option\some(null)->zip(Option\some('hi')), null, 'hi'];
yield [Option\some(1)->zip(Option\some('hi')), 1, 'hi'];
yield [Option\some([true, false]), true, false];
}

/**
* @dataProvider provideTestUnzipAssertionException
*/
public function testUnzipAssertionException(Option\Option $option): void
{
static::expectException(Type\Exception\AssertException::class);
$option->unzip();
}

private function provideTestUnzipAssertionException(): iterable
{
yield [Option\some(null)];
yield [Option\some(1)];
yield [Option\some([true])];
}
}

0 comments on commit 6f19d45

Please sign in to comment.