From 3b4ab3f853082ac69dde4a13037043ccf5299e58 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 6 Nov 2023 18:38:01 +0100 Subject: [PATCH 1/5] fix doc error --- docs/EITHER.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/EITHER.md b/docs/EITHER.md index 92453df..e405f25 100644 --- a/docs/EITHER.md +++ b/docs/EITHER.md @@ -17,7 +17,7 @@ function identify(ServerRequest $request): Either { return Either::right($theUser); } - Either::left(new Error('User not found')); + return Either::left(new Error('User not found')); } /** @@ -83,7 +83,7 @@ This will apply the map transformation on the right value if there is one, other ```php /** @var Either */ -$either = identify($serverRequest) +$either = identify($serverRequest); /** @var Either */ $impersonated = $either->map(fn(User $user): Impersonated => $user->impersonateAdmin()); ``` From 3be58cd13bb41941499819813555b722ba525cee Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 6 Nov 2023 18:54:07 +0100 Subject: [PATCH 2/5] add Validation --- CHANGELOG.md | 6 + docs/README.md | 3 +- docs/VALIDATION.md | 176 ++++++++++++++++++++ proofs/validation.php | 264 ++++++++++++++++++++++++++++++ src/Validation.php | 146 +++++++++++++++++ src/Validation/Fail.php | 152 +++++++++++++++++ src/Validation/Implementation.php | 89 ++++++++++ src/Validation/Success.php | 149 +++++++++++++++++ 8 files changed, 984 insertions(+), 1 deletion(-) create mode 100644 docs/VALIDATION.md create mode 100644 proofs/validation.php create mode 100644 src/Validation.php create mode 100644 src/Validation/Fail.php create mode 100644 src/Validation/Implementation.php create mode 100644 src/Validation/Success.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d05a8b2..4992243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- `Innmind\Immutable\Validation` + ## 5.2.0 - 2023-11-05 ### Added diff --git a/docs/README.md b/docs/README.md index 2c72926..1d4c8c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ composer require innmind/immutable ## Structures -This library provides the 7 following structures: +This library provides the 10 following structures: - [`Sequence`](SEQUENCE.md) - [`Set`](SET.md) @@ -21,6 +21,7 @@ This library provides the 7 following structures: - [`RegExp`](REGEXP.md) - [`Maybe`](MAYBE.md) - [`Either`](EITHER.md) +- [`Validation`](VALIDATION.md) - [`State`](STATE.md) - [`Fold`](FOLD.md) diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..673653f --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,176 @@ +# `Validation` + +This structure is similar to [`Either`](EITHER.md) except that the right side is called success and left fail. The difference is that `Validation` allows to accumulate failures. + +For the examples below we will use the given imaginary functions: + +```php +use Innmind\Immutable\Validation; + +/** + * @return Validation + */ +function isEmail(string $value): Validation { + if (\filter_var($value, \FILTER_VALIDATE_EMAIL)) { + return Validation::success($value); + } + + return Validation::fail(new Error("$value is not an email")); +} + +/** + * @return Validation + */ +function isLocal(string $value): Validation { + if (\str_ends_with($value, '.local')) { + return Validation::success($value); + } + + return Validation::fail(new Error('Not a local email')); +} +``` + +> **Note** +> `Error` is imaginary class. + +## `::fail()` + +This builds a `Validation` instance with the given value in the fail side. + +```php +$validation = Validation::fail($anyValue); +``` + +## `::success()` + +This builds a `Validation` instance with the given value in the success side. + +```php +$validation = Validation::success($anyValue); +``` + +## `->map()` + +This will apply the map transformation on the success value if there is one, otherwise it's only a type change. + +```php +/** @var Validation */ +$validation = isEmail('foo@example.com'); +/** @var Either */ +$email = $validation->map(fn(string $email): Email => new Email($email)); +``` + +## `->flatMap()` + +This is similar to `->map()` but instead of returning the new success value you return a new `Validation` object. + +```php +/** @var Validation */ +$validation = isEmail('foo@example.com'); +/** @var Validation */ +$localEmail = $either->flatMap(fn(string $email): Validation => isLocal($email)); +``` + +## `->match()` + +This is the only way to extract the wrapped value. + +```php +/** @var Email */ +$localEmail = isEmail($serverRequest) + ->flatMap(fn(string $email): Validation => isLocal($email)) + ->map(static fn(string $email) => new Email($email)) + ->match( + fn(Email $email) => $email, + fn(Sequence $failures) => throw new \Exception(\implode(', ', $failure->toList())), + ); +``` + +## `->otherwise()` + +This is like `->flatMap()` but is called when the instance contains failures. The callable must return a new `Validation` object. + +```php +/** @var Validation */ +$email = isEmail('invalid value') + ->otherwise(fn() => isEmail('foo@example.com')); +``` + +## `->mapFailures()` + +This is similar to the `->map()` function but will be applied on each failure. + +```php +/** @var Either */ +$email = isEmail('foo@example.com') + ->mapFailures(fn(Error $error) => new \Exception($error->toString())); +``` + +## `->and()` + +This method allows to aggregate the success values of 2 `Validation` objects or aggregates the failures if at least one of them is a failure. + +```php +$foo = isEmail('foo@example.com'); +$bar = isEmail('bar@example.com'); +$baz = isEmail('invalid value'); +$foobar = isEmail('another value'); + +$foo + ->and( + $bar, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn($value) => $value, + static fn() => null, + ); // returns ['foo@example.com', 'bar@example.com'] +$foo + ->and( + $baz, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn() => null, + static fn($failures) => $failures->toList(), + ); // returns [new Error('invalid value is not an email')] +$foobar + ->and( + $baz, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn() => null, + static fn($failures) => $failures->toList(), + ); // returns [new Error('another value is not an email'), new Error('invalid value is not an email')] +``` + +## `->maybe()` + +This returns a [`Maybe`](MAYBE.md) containing the success value, in case of failures it returns a `Maybe` with nothing inside. + +```php +Validation::success('something')->maybe()->match( + static fn($value) => $value, + static fn() => null, +); // returns 'something' +Validation::fail('something')->maybe()->match( + static fn($value) => $value, + static fn() => null, +); // returns null +``` + +## `->either()` + +This returns an [`Either`](EITHER.md) containing the success value as the right side, in case of failures it returns an `Either` with failures as the left side. + +```php +Validation::success('something')->either()->match( + static fn($value) => $value, + static fn() => null, +); // returns 'something' +Validation::fail('something')->either()->match( + static fn() => null, + static fn($value) => $value, +); // returns Sequence +``` diff --git a/proofs/validation.php b/proofs/validation.php new file mode 100644 index 0000000..771fdcd --- /dev/null +++ b/proofs/validation.php @@ -0,0 +1,264 @@ +same( + $value, + Validation::success($value)->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + [$value], + Validation::fail($value)->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + }, + ); + + yield proof( + 'Validation::map()', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $initial, $new) { + $assert->same( + [$initial, $new], + Validation::success($initial) + ->map(static fn($value) => [$value, $new]) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->null( + Validation::fail($initial) + ->map(static fn($value) => [$value, $new]) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Validation::flatMap()', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $initial, $new) { + $assert->same( + [$initial, $new], + Validation::success($initial) + ->flatMap(static fn($value) => Validation::success([$value, $new])) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + [[$initial, $new]], + Validation::success($initial) + ->flatMap(static fn($value) => Validation::fail([$value, $new])) + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + $assert->null( + Validation::fail($initial) + ->flatMap(static fn($value) => Validation::success([$value, $new])) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Validation::mapFailures()', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $initial, $new) { + $assert->same( + [[$initial, $new]], + Validation::fail($initial) + ->mapFailures(static fn($value) => [$value, $new]) + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + $assert->null( + Validation::success($initial) + ->mapFailures(static fn($value) => [$value, $new]) + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + }, + ); + + yield proof( + 'Validation::otherwise()', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $initial, $new) { + $assert->null( + Validation::success($initial) + ->otherwise(static fn($value) => Validation::success([$value, $new])) + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + $assert->same( + [[[$initial], $new]], + Validation::fail($initial) + ->otherwise(static fn($value) => Validation::fail([$value->toList(), $new])) + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + $assert->same( + [[$initial], $new], + Validation::fail($initial) + ->otherwise(static fn($value) => Validation::success([$value->toList(), $new])) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Validation::maybe()', + given(Set\Type::any()), + static function($assert, $value) { + $assert->same( + $value, + Validation::success($value) + ->maybe() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->null( + Validation::fail($value) + ->maybe() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); + + yield proof( + 'Validation::either()', + given(Set\Type::any()), + static function($assert, $value) { + $assert->same( + $value, + Validation::success($value) + ->either() + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + [$value], + Validation::fail($value) + ->either() + ->match( + static fn() => null, + static fn($value) => $value->toList(), + ), + ); + }, + ); + + yield proof( + 'Validation::and()', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $a, $b) { + $success = Validation::success($a); + $fail = Validation::fail($b); + + $assert->same( + [$a, $a], + $success + ->and( + $success, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + [$b], + $success + ->and( + $fail, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn($value) => $value, + static fn($value) => $value->toList(), + ), + ); + $assert->same( + [$b], + $fail + ->and( + $success, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn($value) => $value, + static fn($value) => $value->toList(), + ), + ); + $assert->same( + [$b, $b], + $fail + ->and( + $fail, + static fn($a, $b) => [$a, $b], + ) + ->match( + static fn($value) => $value, + static fn($value) => $value->toList(), + ), + ); + }, + ); +}; diff --git a/src/Validation.php b/src/Validation.php new file mode 100644 index 0000000..cc74549 --- /dev/null +++ b/src/Validation.php @@ -0,0 +1,146 @@ +implementation = $implementation; + } + + /** + * @template A + * @template B + * @psalm-pure + * + * @param A $value + * + * @return self + */ + public static function success($value): self + { + return new self(Validation\Success::of($value)); + } + + /** + * @template A + * @template B + * @psalm-pure + * + * @param A $value + * + * @return self + */ + public static function fail($value): self + { + return new self(Validation\Fail::of($value)); + } + + /** + * @template T + * + * @param callable(S): T $map + * + * @return self + */ + public function map(callable $map): self + { + return new self($this->implementation->map($map)); + } + + /** + * @template T + * @template V + * + * @param callable(S): self $map + * + * @return self + */ + public function flatMap(callable $map): self + { + return new self($this->implementation->flatMap( + $map, + static fn(self $self) => $self->implementation, + )); + } + + /** + * @template T + * + * @param callable(F): T $map + * + * @return self + */ + public function mapFailures(callable $map): self + { + return new self($this->implementation->mapFailures($map)); + } + + /** + * @template T + * @template V + * + * @param callable(Sequence): self $map + * + * @return self + */ + public function otherwise(callable $map): self + { + return $this->implementation->otherwise($map); + } + + /** + * @template A + * @template T + * + * @param self $other + * @param callable(S, A): T $fold + * + * @return self + */ + public function and(self $other, callable $fold): self + { + return new self($this->implementation->and( + $other->implementation, + $fold, + )); + } + + /** + * @template T + * + * @param callable(S): T $success + * @param callable(Sequence): T $failure + * + * @return T + */ + public function match(callable $success, callable $failure) + { + return $this->implementation->match($success, $failure); + } + + /** + * @return Maybe + */ + public function maybe(): Maybe + { + return $this->implementation->maybe(); + } + + /** + * @return Either, S> + */ + public function either(): Either + { + return $this->implementation->either(); + } +} diff --git a/src/Validation/Fail.php b/src/Validation/Fail.php new file mode 100644 index 0000000..1d9dc11 --- /dev/null +++ b/src/Validation/Fail.php @@ -0,0 +1,152 @@ + + * @psalm-immutable + */ +final class Fail implements Implementation +{ + /** @var Sequence */ + private Sequence $failures; + + /** + * @param Sequence $failures + */ + private function __construct(Sequence $failures) + { + $this->failures = $failures; + } + + /** + * @template A + * @template B + * @psalm-pure + * + * @param A $failure + * + * @return self + */ + public static function of($failure): self + { + return new self(Sequence::of($failure)); + } + + /** + * @template T + * + * @param callable(S): T $map + * + * @return Implementation + */ + public function map(callable $map): Implementation + { + /** @var Implementation */ + return $this; + } + + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): Implementation $exfiltrate + * + * @return Implementation + */ + public function flatMap(callable $map, callable $exfiltrate): Implementation + { + /** @var Implementation */ + return $this; + } + + /** + * @template T + * + * @param callable(F): T $map + * + * @return Implementation + */ + public function mapFailures(callable $map): Implementation + { + return new self($this->failures->map($map)); + } + + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * + * @return Validation + */ + public function otherwise(callable $map): Validation + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this->failures); + } + + /** + * @template A + * @template T + * + * @param Implementation $other + * @param callable(S, A): T $fold + * + * @return Implementation + */ + public function and(Implementation $other, callable $fold): Implementation + { + if ($other instanceof self) { + /** + * @psalm-suppress InvalidArgument + * @var Implementation + */ + return new self($this->failures->append($other->failures)); + } + + /** @var Implementation */ + return $this; + } + + /** + * @template T + * + * @param callable(S): T $success + * @param callable(Sequence): T $failure + * + * @return T + */ + public function match(callable $success, callable $failure) + { + /** @psalm-suppress ImpureFunctionCall */ + return $failure($this->failures); + } + + /** + * @return Maybe + */ + public function maybe(): Maybe + { + return Maybe::nothing(); + } + + /** + * @return Either, S> + */ + public function either(): Either + { + return Either::left($this->failures); + } +} diff --git a/src/Validation/Implementation.php b/src/Validation/Implementation.php new file mode 100644 index 0000000..a2b535c --- /dev/null +++ b/src/Validation/Implementation.php @@ -0,0 +1,89 @@ + + */ + public function map(callable $map): self; + + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): self $exfiltrate + * + * @return self + */ + public function flatMap(callable $map, callable $exfiltrate): self; + + /** + * @template T + * + * @param callable(F): T $map + * + * @return self + */ + public function mapFailures(callable $map): self; + + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * + * @return Validation + */ + public function otherwise(callable $map): Validation; + + /** + * @template A + * @template T + * + * @param self $other + * @param callable(S, A): T $fold + * + * @return self + */ + public function and(self $other, callable $fold): self; + + /** + * @template T + * + * @param callable(S): T $success + * @param callable(Sequence): T $failure + * + * @return T + */ + public function match(callable $success, callable $failure); + + /** + * @return Maybe + */ + public function maybe(): Maybe; + + /** + * @return Either, S> + */ + public function either(): Either; +} diff --git a/src/Validation/Success.php b/src/Validation/Success.php new file mode 100644 index 0000000..08cdfad --- /dev/null +++ b/src/Validation/Success.php @@ -0,0 +1,149 @@ + + * @psalm-immutable + */ +final class Success implements Implementation +{ + /** @var S */ + private $value; + + /** + * @param S $value + */ + private function __construct($value) + { + $this->value = $value; + } + + /** + * @template A + * @template B + * @psalm-pure + * + * @param A $value + * + * @return self + */ + public static function of($value): self + { + return new self($value); + } + + /** + * @template T + * + * @param callable(S): T $map + * + * @return Implementation + */ + public function map(callable $map): Implementation + { + /** @psalm-suppress ImpureFunctionCall */ + return new self($map($this->value)); + } + + /** + * @template T + * @template V + * + * @param callable(S): Validation $map + * @param pure-callable(Validation): Implementation $exfiltrate + * + * @return Implementation + */ + public function flatMap(callable $map, callable $exfiltrate): Implementation + { + /** @psalm-suppress ImpureFunctionCall */ + return $exfiltrate($map($this->value)); + } + + /** + * @template T + * + * @param callable(F): T $map + * + * @return Implementation + */ + public function mapFailures(callable $map): Implementation + { + /** @var Implementation */ + return $this; + } + + /** + * @template T + * @template V + * + * @param callable(Sequence): Validation $map + * + * @return Validation + */ + public function otherwise(callable $map): Validation + { + return Validation::success($this->value); + } + + /** + * @template A + * @template T + * + * @param Implementation $other + * @param callable(S, A): T $fold + * + * @return Implementation + */ + public function and(Implementation $other, callable $fold): Implementation + { + if ($other instanceof self) { + /** @psalm-suppress ImpureFunctionCall */ + return new self($fold($this->value, $other->value)); + } + + /** @var Implementation */ + return $other; + } + + /** + * @template T + * + * @param callable(S): T $success + * @param callable(Sequence): T $failure + * + * @return T + */ + public function match(callable $success, callable $failure) + { + /** @psalm-suppress ImpureFunctionCall */ + return $success($this->value); + } + + /** + * @return Maybe + */ + public function maybe(): Maybe + { + return Maybe::just($this->value); + } + + /** + * @return Either, S> + */ + public function either(): Either + { + return Either::right($this->value); + } +} From 4959df86a3fc6bc0f1bc57f50301646d33f1dd30 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 6 Nov 2023 18:55:52 +0100 Subject: [PATCH 3/5] update actions version --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88b99a7..9c27ebc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: name: 'BlackBox' steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -38,7 +38,7 @@ jobs: name: 'Coverage' steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -65,7 +65,7 @@ jobs: name: 'Psalm' steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -83,7 +83,7 @@ jobs: name: 'CS' steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: From f3009168d0b2a76a1afb99f79ce5ad1473d576dd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 6 Nov 2023 18:56:33 +0100 Subject: [PATCH 4/5] update action version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c27ebc..e077eff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: env: ENABLE_COVERAGE: 'true' BLACKBOX_SET_SIZE: 1 - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} psalm: From 94442fca7eaa776ea4b7e93256745b31c4f74568 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 6 Nov 2023 19:03:54 +0100 Subject: [PATCH 5/5] specify next release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4992243..d04dfb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## 5.3.0 - 2023-11-06 ### Added