diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ed1bed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [Unreleased] + +### Added + +- `Innmind\Validation\Shape::optional()` diff --git a/proofs/shape.php b/proofs/shape.php index c640451..0998173 100644 --- a/proofs/shape.php +++ b/proofs/shape.php @@ -103,4 +103,33 @@ static function($assert) { ); }, ); + + yield test( + 'Shape with optional key', + static function($assert) { + $assert->true( + Shape::of('foo', Is::int()) + ->with('bar', Is::bool()) + ->optional('bar') + ->asPredicate()([ + 'foo' => 42, + ]), + ); + $assert->same( + [ + 'foo' => 42, + ], + Shape::of('foo', Is::int()) + ->with('bar', Is::bool()) + ->optional('bar')([ + 'foo' => 42, + 'baz' => 'invalid', + ]) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + }, + ); }; diff --git a/src/Shape.php b/src/Shape.php index 0877d09..96f4eba 100644 --- a/src/Shape.php +++ b/src/Shape.php @@ -16,32 +16,51 @@ final class Shape implements Constraint { /** @var non-empty-array> */ private array $constraints; + /** @var list */ + private array $optional; /** * @param non-empty-array> $constraints + * @param list $optional */ - private function __construct(array $constraints) + private function __construct(array $constraints, array $optional) { $this->constraints = $constraints; + $this->optional = $optional; } public function __invoke(mixed $value): Validation { + $optional = new \stdClass; /** @var Validation> */ $validation = Validation::success([]); foreach ($this->constraints as $key => $constraint) { + $keyValidation = Has::key($key); + + if (\in_array($key, $this->optional, true)) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $keyValidation = $keyValidation->or(Of::callable( + static fn() => Validation::success($optional), + )); + } + $ofType = Of::callable( - static fn($value) => $constraint($value)->mapFailures( - static fn($failure) => $failure->under($key), - ), + static fn($value) => match ($value) { + $optional => Validation::success($optional), + default => $constraint($value)->mapFailures( + static fn($failure) => $failure->under($key), + ), + }, ); $validation = $validation->and( - Has::key($key)->and($ofType)($value), - static function($array, $value) use ($key) { - /** @psalm-suppress MixedAssignment */ - $array[$key] = $value; + $keyValidation->and($ofType)($value), + static function($array, $value) use ($key, $optional) { + if ($value !== $optional) { + /** @psalm-suppress MixedAssignment */ + $array[$key] = $value; + } return $array; }, @@ -58,7 +77,7 @@ static function($array, $value) use ($key) { */ public static function of(string $key, Constraint $constraint): self { - return new self([$key => $constraint]); + return new self([$key => $constraint], []); } /** @@ -69,7 +88,18 @@ public function with(string $key, Constraint $constraint): self $constraints = $this->constraints; $constraints[$key] = $constraint; - return new self($constraints); + return new self($constraints, $this->optional); + } + + /** + * @param non-empty-string $key + */ + public function optional(string $key): self + { + $optional = $this->optional; + $optional[] = $key; + + return new self($this->constraints, $optional); } public function and(Constraint $constraint): Constraint