Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(option): add Option::zip(), Option::zipWith() and Option::unzip() methods #434

Merged
merged 5 commits into from
Dec 26, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)


75 changes: 75 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,78 @@ 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`.
*
* @note: If an element is `None`, both elements in the resulting tuple will be `None`.
devnix marked this conversation as resolved.
Show resolved Hide resolved
*
* @template Tu
*
* @param Option<Tu> $other The other `Option` to zip with.
*
* @return (
devnix marked this conversation as resolved.
Show resolved Hide resolved
* T is never
* ? Option<never>
* : (Tu is never ? Option<never> : Option<array{T, Tu}>)
* )
*/
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<never>
*/
function test_partial_none_tuple_1(): Option\Option
{
return Option\none()->zip(Option\some(1));
}

/**
* @return Option\Option<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<never>}
*/
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<never>, 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();
devnix marked this conversation as resolved.
Show resolved Hide resolved

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
devnix marked this conversation as resolved.
Show resolved Hide resolved
{
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])];
}
}