diff --git a/docs/validators.md b/docs/validators.md index 2879218a5..c4dff743c 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -9,7 +9,7 @@ SPDX-FileContributor: Henrique Moody In this page you will find a list of validators by their category. -**Arrays**: [ArrayType][] - [ArrayVal][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Each][] - [EndsWith][] - [In][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Sorted][] - [StartsWith][] - [Subset][] - [Unique][] +**Arrays**: [ArrayType][] - [ArrayVal][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Each][] - [EachKey][] - [EndsWith][] - [In][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Sorted][] - [StartsWith][] - [Subset][] - [Unique][] **Banking**: [CreditCard][] - [Iban][] @@ -43,7 +43,7 @@ In this page you will find a list of validators by their category. **Miscellaneous**: [Blank][] - [Falsy][] - [Named][] - [Templated][] - [Undef][] -**Nesting**: [After][] - [AllOf][] - [AnyOf][] - [Each][] - [Factory][] - [Given][] - [Key][] - [KeySet][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [ShortCircuit][] - [UndefOr][] - [When][] +**Nesting**: [After][] - [AllOf][] - [AnyOf][] - [Each][] - [EachKey][] - [Factory][] - [Given][] - [Key][] - [KeySet][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [ShortCircuit][] - [UndefOr][] - [When][] **Numbers**: [Base][] - [Decimal][] - [Digit][] - [Even][] - [Factor][] - [Finite][] - [FloatType][] - [FloatVal][] - [Infinite][] - [IntType][] - [IntVal][] - [Multiple][] - [Negative][] - [Number][] - [NumericVal][] - [Odd][] - [Positive][] - [Roman][] @@ -100,6 +100,7 @@ In this page you will find a list of validators by their category. - [Directory][] - `v::directory()->assert(__DIR__);` - [Domain][] - `v::domain()->assert('google.com');` - [Each][] - `v::each(v::dateTime())->assert($releaseDates);` +- [EachKey][] - `v::eachKey(v::stringType())->assert($releaseDates);` - [Email][] - `v::email()->assert('alganet@gmail.com');` - [Emoji][] - `v::emoji()->assert('🍕');` - [EndsWith][] - `v::endsWith('ipsum')->assert('lorem ipsum');` @@ -259,6 +260,7 @@ In this page you will find a list of validators by their category. [Directory]: validators/Directory.md "Validates if the given path is a directory." [Domain]: validators/Domain.md "Validates whether the input is a valid domain name or not." [Each]: validators/Each.md "Validates whether each value in the input is valid according to another validator." +[EachKey]: validators/EachKey.md "Validates whether each key in the input is valid according to another validator." [Email]: validators/Email.md "Validates an email address." [Emoji]: validators/Emoji.md "Validates if the input is an emoji or a sequence of emojis." [EndsWith]: validators/EndsWith.md "This validator is similar to `Contains()`, but validates" diff --git a/docs/validators/Each.md b/docs/validators/Each.md index eb179467b..8aa516a32 100644 --- a/docs/validators/Each.md +++ b/docs/validators/Each.md @@ -92,6 +92,7 @@ v::length(v::greaterThan(0))->each(v::equals(10))->assert([]); - [After](After.md) - [All](All.md) - [ArrayVal](ArrayVal.md) +- [EachKey](EachKey.md) - [Falsy](Falsy.md) - [IterableType](IterableType.md) - [IterableVal](IterableVal.md) diff --git a/docs/validators/EachKey.md b/docs/validators/EachKey.md new file mode 100644 index 000000000..819a6498b --- /dev/null +++ b/docs/validators/EachKey.md @@ -0,0 +1,111 @@ + + +# EachKey + +- `EachKey(Validator $validator)` + +Validates whether each key in the input is valid according to another validator. + +```php +$releaseDates = [ + 'validation' => '2010-01-01', + 'template' => '2011-01-01', + 'relational' => '2011-02-05', +]; + +v::eachKey(v::stringType())->assert($releaseDates); +// Validation passes successfully +``` + +This validator is the key-type counterpart to [Each](Each.md). While `Each` validates +values, `EachKey` validates keys. This allows composable validation of array key types: + +```php +v::eachKey(v::stringType())->assert(['a' => 1, 'b' => 2]); +// Validation passes successfully + +v::eachKey(v::stringType())->assert([0 => 'x', 1 => 'y']); +// → - Each key of `["x", "y"]` must be valid +// → - Key `.0` must be a string +// → - Key `.1` must be a string +``` + +You can combine `EachKey` with [Each](Each.md) via [AllOf](AllOf.md) to validate both +keys and values independently: + +```php +v::allOf(v::eachKey(v::stringType()), v::each(v::intType()))->assert(['a' => 1, 'b' => 2]); +// Validation passes successfully +``` + +## Note + +This validator will pass if the input is empty. Use [Length](Length.md) with [GreaterThan](GreaterThan.md) to perform a stricter check: + +```php +v::eachKey(v::stringType())->assert([]); +// Validation passes successfully + +v::length(v::greaterThan(0))->eachKey(v::stringType())->assert([]); +// → The length of `[]` must be greater than 0 +``` + +## Templates + +### `EachKey::TEMPLATE_STANDARD` + +| Mode | Template | +| ---------: | :-------------------------------------- | +| `default` | Each key of {{subject}} must be valid | +| `inverted` | Each key of {{subject}} must be invalid | + +### `EachKey::TEMPLATE_NESTED` + +| Mode | Template | +| ---------: | :------- | +| `default` | Key | +| `inverted` | Key | + +### `EachKey::TEMPLATE_SHORT_CIRCUITED` + +| Mode | Template | +| ---------: | :---------- | +| `default` | Each key of | +| `inverted` | Each key of | + +## Template placeholders + +- **TEMPLATE_STANDARD**: Uses `{{subject}}` — the validated input or the custom + validator name (if specified). +- **TEMPLATE_NESTED**: Does not use placeholders. Composes with the inner + validator's template via `asAdjacentOf` to produce messages like + "Key `.0` must be a string". + +## Categorization + +- Arrays +- Nesting + +## Changelog + +| Version | Description | +| ------: | :---------- | +| 3.2.0 | Created | + +## See Also + +- [After](After.md) +- [All](All.md) +- [AllOf](AllOf.md) +- [Each](Each.md) +- [IterableType](IterableType.md) +- [IterableVal](IterableVal.md) +- [Key](Key.md) +- [KeyExists](KeyExists.md) +- [KeyOptional](KeyOptional.md) +- [KeySet](KeySet.md) +- [Length](Length.md) diff --git a/src/Mixins/AllBuilder.php b/src/Mixins/AllBuilder.php index 202c86313..ea6f9af97 100644 --- a/src/Mixins/AllBuilder.php +++ b/src/Mixins/AllBuilder.php @@ -99,6 +99,8 @@ public static function allDomain(bool $tldCheck = true): Chain; public static function allEach(Validator $validator): Chain; + public static function allEachKey(Validator $validator): Chain; + public static function allEmail(): Chain; public static function allEmoji(): Chain; diff --git a/src/Mixins/AllChain.php b/src/Mixins/AllChain.php index c451e1a32..521337b09 100644 --- a/src/Mixins/AllChain.php +++ b/src/Mixins/AllChain.php @@ -99,6 +99,8 @@ public function allDomain(bool $tldCheck = true): Chain; public function allEach(Validator $validator): Chain; + public function allEachKey(Validator $validator): Chain; + public function allEmail(): Chain; public function allEmoji(): Chain; diff --git a/src/Mixins/Builder.php b/src/Mixins/Builder.php index 49e387fa9..78055de11 100644 --- a/src/Mixins/Builder.php +++ b/src/Mixins/Builder.php @@ -104,6 +104,8 @@ public static function domain(bool $tldCheck = true): Chain; public static function each(Validator $validator): Chain; + public static function eachKey(Validator $validator): Chain; + public static function email(): Chain; public static function emoji(): Chain; diff --git a/src/Mixins/Chain.php b/src/Mixins/Chain.php index 3f76c609b..da5ff31ea 100644 --- a/src/Mixins/Chain.php +++ b/src/Mixins/Chain.php @@ -106,6 +106,8 @@ public function domain(bool $tldCheck = true): Chain; public function each(Validator $validator): Chain; + public function eachKey(Validator $validator): Chain; + public function email(): Chain; public function emoji(): Chain; diff --git a/src/Mixins/KeyBuilder.php b/src/Mixins/KeyBuilder.php index 5e250d05f..9a7288671 100644 --- a/src/Mixins/KeyBuilder.php +++ b/src/Mixins/KeyBuilder.php @@ -101,6 +101,8 @@ public static function keyDomain(int|string $key, bool $tldCheck = true): Chain; public static function keyEach(int|string $key, Validator $validator): Chain; + public static function keyEachKey(int|string $key, Validator $validator): Chain; + public static function keyEmail(int|string $key): Chain; public static function keyEmoji(int|string $key): Chain; diff --git a/src/Mixins/KeyChain.php b/src/Mixins/KeyChain.php index 3b5622058..43db07d08 100644 --- a/src/Mixins/KeyChain.php +++ b/src/Mixins/KeyChain.php @@ -101,6 +101,8 @@ public function keyDomain(int|string $key, bool $tldCheck = true): Chain; public function keyEach(int|string $key, Validator $validator): Chain; + public function keyEachKey(int|string $key, Validator $validator): Chain; + public function keyEmail(int|string $key): Chain; public function keyEmoji(int|string $key): Chain; diff --git a/src/Mixins/NotBuilder.php b/src/Mixins/NotBuilder.php index 2b586dfdb..7163584e3 100644 --- a/src/Mixins/NotBuilder.php +++ b/src/Mixins/NotBuilder.php @@ -101,6 +101,8 @@ public static function notDomain(bool $tldCheck = true): Chain; public static function notEach(Validator $validator): Chain; + public static function notEachKey(Validator $validator): Chain; + public static function notEmail(): Chain; public static function notEmoji(): Chain; diff --git a/src/Mixins/NotChain.php b/src/Mixins/NotChain.php index 1cd5942ba..083e86eac 100644 --- a/src/Mixins/NotChain.php +++ b/src/Mixins/NotChain.php @@ -101,6 +101,8 @@ public function notDomain(bool $tldCheck = true): Chain; public function notEach(Validator $validator): Chain; + public function notEachKey(Validator $validator): Chain; + public function notEmail(): Chain; public function notEmoji(): Chain; diff --git a/src/Mixins/NullOrBuilder.php b/src/Mixins/NullOrBuilder.php index 5ff71d146..cb6fdcd27 100644 --- a/src/Mixins/NullOrBuilder.php +++ b/src/Mixins/NullOrBuilder.php @@ -101,6 +101,8 @@ public static function nullOrDomain(bool $tldCheck = true): Chain; public static function nullOrEach(Validator $validator): Chain; + public static function nullOrEachKey(Validator $validator): Chain; + public static function nullOrEmail(): Chain; public static function nullOrEmoji(): Chain; diff --git a/src/Mixins/NullOrChain.php b/src/Mixins/NullOrChain.php index 504556856..cdfba80a0 100644 --- a/src/Mixins/NullOrChain.php +++ b/src/Mixins/NullOrChain.php @@ -101,6 +101,8 @@ public function nullOrDomain(bool $tldCheck = true): Chain; public function nullOrEach(Validator $validator): Chain; + public function nullOrEachKey(Validator $validator): Chain; + public function nullOrEmail(): Chain; public function nullOrEmoji(): Chain; diff --git a/src/Mixins/PropertyBuilder.php b/src/Mixins/PropertyBuilder.php index 14f369227..8de4b7053 100644 --- a/src/Mixins/PropertyBuilder.php +++ b/src/Mixins/PropertyBuilder.php @@ -101,6 +101,8 @@ public static function propertyDomain(string $propertyName, bool $tldCheck = tru public static function propertyEach(string $propertyName, Validator $validator): Chain; + public static function propertyEachKey(string $propertyName, Validator $validator): Chain; + public static function propertyEmail(string $propertyName): Chain; public static function propertyEmoji(string $propertyName): Chain; diff --git a/src/Mixins/PropertyChain.php b/src/Mixins/PropertyChain.php index b51206ba1..604017878 100644 --- a/src/Mixins/PropertyChain.php +++ b/src/Mixins/PropertyChain.php @@ -101,6 +101,8 @@ public function propertyDomain(string $propertyName, bool $tldCheck = true): Cha public function propertyEach(string $propertyName, Validator $validator): Chain; + public function propertyEachKey(string $propertyName, Validator $validator): Chain; + public function propertyEmail(string $propertyName): Chain; public function propertyEmoji(string $propertyName): Chain; diff --git a/src/Mixins/UndefOrBuilder.php b/src/Mixins/UndefOrBuilder.php index 41fac257e..23d466997 100644 --- a/src/Mixins/UndefOrBuilder.php +++ b/src/Mixins/UndefOrBuilder.php @@ -99,6 +99,8 @@ public static function undefOrDomain(bool $tldCheck = true): Chain; public static function undefOrEach(Validator $validator): Chain; + public static function undefOrEachKey(Validator $validator): Chain; + public static function undefOrEmail(): Chain; public static function undefOrEmoji(): Chain; diff --git a/src/Mixins/UndefOrChain.php b/src/Mixins/UndefOrChain.php index 260a3d17b..15f9e9015 100644 --- a/src/Mixins/UndefOrChain.php +++ b/src/Mixins/UndefOrChain.php @@ -99,6 +99,8 @@ public function undefOrDomain(bool $tldCheck = true): Chain; public function undefOrEach(Validator $validator): Chain; + public function undefOrEachKey(Validator $validator): Chain; + public function undefOrEmail(): Chain; public function undefOrEmoji(): Chain; diff --git a/src/Validators/EachKey.php b/src/Validators/EachKey.php new file mode 100644 index 000000000..1a7ae6087 --- /dev/null +++ b/src/Validators/EachKey.php @@ -0,0 +1,85 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; +use Respect\Validation\Message\Template; +use Respect\Validation\Path; +use Respect\Validation\Result; +use Respect\Validation\Validators\Core\FilteredArray; +use Respect\Validation\Validators\Core\ShortCircuitable; + +use function array_keys; +use function array_reduce; + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +#[Template( + 'Each key of {{subject}} must be valid', + 'Each key of {{subject}} must be invalid', + self::TEMPLATE_STANDARD, +)] +#[Template('Key', 'Key', self::TEMPLATE_NESTED)] +#[Template('Each key of', 'Each key of', self::TEMPLATE_SHORT_CIRCUITED)] +final class EachKey extends FilteredArray implements ShortCircuitable +{ + use CanEvaluateShortCircuit; + + public const string TEMPLATE_NESTED = '__nested__'; + public const string TEMPLATE_SHORT_CIRCUITED = '__short_circuited__'; + + public function evaluateShortCircuit(mixed $input): Result + { + $iterableResult = (new IterableType())->evaluate($input); + if (!$iterableResult->hasPassed) { + return $iterableResult->withIdFrom($this); + } + + $result = null; + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable -- only keys are validated + foreach ($input as $key => $value) { + $result = $this->evaluateShortCircuitWith($this->validator, $key); + if (!$result->hasPassed) { + return $result->asAdjacentOf( + Result::failed($key, $this, [], self::TEMPLATE_NESTED), + 'eachKey', + )->withPath(new Path($key)); + } + } + + if ($result === null) { + return Result::passed($input, $this)->asIndeterminate(); + } + + return $result->asAdjacentOf(Result::passed($input, $this, [], self::TEMPLATE_SHORT_CIRCUITED), 'eachKey'); + } + + /** @param non-empty-array $input */ + protected function evaluateArray(array $input): Result + { + $children = []; + foreach (array_keys($input) as $key) { + $validatorResult = $this->validator->evaluate($key); + $children[] = $validatorResult->asAdjacentOf( + Result::of($validatorResult->hasPassed, $key, $this, [], self::TEMPLATE_NESTED), + 'eachKey', + )->withPath(new Path($key)); + } + + $hasPassed = array_reduce( + $children, + static fn($carry, $childResult) => $carry && $childResult->hasPassed, + true, + ); + + return Result::of($hasPassed, $input, $this)->withChildren(...$children); + } +} diff --git a/tests/feature/Validators/EachKeyTest.php b/tests/feature/Validators/EachKeyTest.php new file mode 100644 index 000000000..413b3862b --- /dev/null +++ b/tests/feature/Validators/EachKeyTest.php @@ -0,0 +1,284 @@ + + */ + +declare(strict_types=1); + +test('Non-iterable', catchAll( + fn() => v::eachKey(v::stringType())->assert(null), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`null` must be iterable') + ->and($fullMessage)->toBe('- `null` must be iterable') + ->and($messages)->toBe(['eachKey' => '`null` must be iterable']), +)); + +test('Default', catchAll( + fn() => v::eachKey(v::stringType())->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of `["a", "b", "c"]` must be valid + - Key `.0` must be a string + - Key `.1` must be a string + - Key `.2` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of `["a", "b", "c"]` must be valid', + 0 => 'Key `.0` must be a string', + 1 => 'Key `.1` must be a string', + 2 => 'Key `.2` must be a string', + ]), +)); + +test('Inverted', catchAll( + fn() => v::not(v::eachKey(v::stringType()))->assert(['a' => 1, 'b' => 2, 'c' => 3]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.a` must not be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of `["a": 1, "b": 2, "c": 3]` must be invalid + - Key `.a` must not be a string + - Key `.b` must not be a string + - Key `.c` must not be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of `["a": 1, "b": 2, "c": 3]` must be invalid', + 'a' => 'Key `.a` must not be a string', + 'b' => 'Key `.b` must not be a string', + 'c' => 'Key `.c` must not be a string', + ]), +)); + +test('With name, default', catchAll( + fn() => v::named('Outer', v::eachKey(v::named('Inner', v::stringType())))->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key Inner must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of Outer must be valid + - Key `.0` must be a string + - Key `.1` must be a string + - Key `.2` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of Outer must be valid', + 0 => 'Key `.0` must be a string', + 1 => 'Key `.1` must be a string', + 2 => 'Key `.2` must be a string', + ]), +)); + +test('With name, inverted', catchAll( + fn() => v::named('Not', v::not(v::named('Outer', v::eachKey(v::named('Inner', v::stringType())))))->assert(['a' => 1, 'b' => 2, 'c' => 3]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key Inner must not be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of Outer must be invalid + - Key `.a` must not be a string + - Key `.b` must not be a string + - Key `.c` must not be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of Outer must be invalid', + 'a' => 'Key `.a` must not be a string', + 'b' => 'Key `.b` must not be a string', + 'c' => 'Key `.c` must not be a string', + ]), +)); + +test('With wrapper name, default', catchAll( + fn() => v::named('Wrapper', v::eachKey(v::stringType()))->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` (<- Wrapper) must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of Wrapper must be valid + - Key `.0` must be a string + - Key `.1` must be a string + - Key `.2` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of Wrapper must be valid', + 0 => 'Key `.0` must be a string', + 1 => 'Key `.1` must be a string', + 2 => 'Key `.2` must be a string', + ]), +)); + +test('With Not name, inverted', catchAll( + fn() => v::named('Not', v::not(v::eachKey(v::stringType())))->assert(['a' => 1, 'b' => 2, 'c' => 3]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.a` (<- Not) must not be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of Not must be invalid + - Key `.a` must not be a string + - Key `.b` must not be a string + - Key `.c` must not be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of Not must be invalid', + 'a' => 'Key `.a` must not be a string', + 'b' => 'Key `.b` must not be a string', + 'c' => 'Key `.c` must not be a string', + ]), +)); + +test('With template, non-iterable', catchAll( + fn() => v::templated('You should have passed an iterable', v::eachKey(v::stringType()))->assert(null), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('You should have passed an iterable') + ->and($fullMessage)->toBe('- You should have passed an iterable') + ->and($messages)->toBe(['eachKey' => 'You should have passed an iterable']), +)); + +test('With template, default', catchAll( + fn() => v::templated('All keys should have been strings', v::eachKey(v::stringType())) + ->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('All keys should have been strings') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - All keys should have been strings + - Key `.0` must be a string + - Key `.1` must be a string + - Key `.2` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'All keys should have been strings', + 0 => 'Key `.0` must be a string', + 1 => 'Key `.1` must be a string', + 2 => 'Key `.2` must be a string', + ]), +)); + +test('with template, inverted', catchAll( + fn() => v::templated('All keys should not have been strings', v::not(v::eachKey(v::stringType()))) + ->assert(['a' => 1, 'b' => 2, 'c' => 3]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('All keys should not have been strings') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - All keys should not have been strings + - Key `.a` must not be a string + - Key `.b` must not be a string + - Key `.c` must not be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'All keys should not have been strings', + 'a' => 'Key `.a` must not be a string', + 'b' => 'Key `.b` must not be a string', + 'c' => 'Key `.c` must not be a string', + ]), +)); + +test('With array template, default', catchAll( + fn() => v::eachKey(v::stringType()) + ->assert([0 => 'a', 1 => 'b', 2 => 'c'], [ + '__root__' => 'Here the keys that did not pass the validation', + 0 => 'First key should have been a string', + 1 => 'Second key should have been a string', + 2 => 'Third key should have been a string', + ]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('First key should have been a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Here the keys that did not pass the validation + - First key should have been a string + - Second key should have been a string + - Third key should have been a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Here the keys that did not pass the validation', + 0 => 'First key should have been a string', + 1 => 'Second key should have been a string', + 2 => 'Third key should have been a string', + ]), +)); + +test('short-circuit: first key fails', catchAll( + fn() => v::shortCircuit(v::eachKey(v::stringType()))->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` must be a string') + ->and($fullMessage)->toBe('- Key `.0` must be a string') + ->and($messages)->toBe([0 => 'Key `.0` must be a string']), +)); + +test('short-circuit: all keys pass', function (): void { + $validator = v::shortCircuit(v::eachKey(v::stringType())); + expect($validator->isValid(['a' => 1, 'b' => 2]))->toBeTrue(); +}); + +test('short-circuit: empty array', function (): void { + $validator = v::shortCircuit(v::eachKey(v::stringType())); + expect($validator->isValid([]))->toBeTrue(); +}); + +test('short-circuit: non-iterable input', catchAll( + fn() => v::shortCircuit(v::eachKey(v::stringType()))->assert(null), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`null` must be iterable') + ->and($fullMessage)->toBe('- `null` must be iterable') + ->and($messages)->toBe(['eachKey' => '`null` must be iterable']), +)); + +test('short-circuit: with wrapper name', catchAll( + fn() => v::named('Wrapper', v::shortCircuit(v::eachKey(v::stringType())))->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` (<- Wrapper) must be a string') + ->and($fullMessage)->toBe('- Key `.0` (<- Wrapper) must be a string') + ->and($messages)->toBe([0 => 'Key `.0` (<- Wrapper) must be a string']), +)); + +test('short-circuit: with inner name', catchAll( + fn() => v::shortCircuit(v::eachKey(v::named('Inner', v::stringType())))->assert([0 => 'a', 1 => 'b', 2 => 'c']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key Inner must be a string') + ->and($fullMessage)->toBe('- Key Inner must be a string') + ->and($messages)->toBe([0 => 'Key Inner must be a string']), +)); + +test('short-circuit: with key() composition', catchAll( + fn() => v::key('data', v::shortCircuit(v::eachKey(v::stringType())))->assert(['data' => [0 => 'a', 1 => 'b']]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.data.0` must be a string') + ->and($fullMessage)->toBe('- Key `.data.0` must be a string') + ->and($messages)->toBe([0 => 'Key `.data.0` must be a string']), +)); + +test('short-circuit: SplObjectStorage', catchAll( + fn() => v::shortCircuit(v::eachKey(v::stringType()))->assert((function () { + $storage = new SplObjectStorage(); + $storage[new stdClass()] = 'a'; + $storage[new stdClass()] = 'b'; + + return $storage; + })()), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` must be a string') + ->and($fullMessage)->toBe('- Key `.0` must be a string') + ->and($messages)->toBe([0 => 'Key `.0` must be a string']), +)); + +test('SplObjectStorage keys are integers', catchAll( + fn() => v::eachKey(v::stringType())->assert((function () { + $storage = new SplObjectStorage(); + $storage[new stdClass()] = 'a'; + $storage[new stdClass()] = 'b'; + $storage[new stdClass()] = 'c'; + + return $storage; + })()), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Key `.0` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each key of `[stdClass {}, stdClass {}, stdClass {}]` must be valid + - Key `.0` must be a string + - Key `.1` must be a string + - Key `.2` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each key of `[stdClass {}, stdClass {}, stdClass {}]` must be valid', + 0 => 'Key `.0` must be a string', + 1 => 'Key `.1` must be a string', + 2 => 'Key `.2` must be a string', + ]), +)); diff --git a/tests/src/SmokeTestProvider.php b/tests/src/SmokeTestProvider.php index 76e8cb023..6dc3250e4 100644 --- a/tests/src/SmokeTestProvider.php +++ b/tests/src/SmokeTestProvider.php @@ -66,6 +66,7 @@ public static function provideValidatorInput(): Generator yield 'Directory' => [new vs\Directory(), 'tests/fixtures']; yield 'Domain' => [new vs\Domain(), 'example.com']; yield 'Each' => [new vs\Each(new vs\StringType()), ['a', 'b']]; + yield 'EachKey' => [new vs\EachKey(new vs\StringType()), ['a' => 1, 'b' => 2]]; yield 'Email' => [new vs\Email(), 'bob@example.com']; yield 'Emoji' => [new vs\Emoji(), '😀']; yield 'EndsWith' => [new vs\EndsWith('.com'), 'example.com']; diff --git a/tests/unit/Validators/EachKeyTest.php b/tests/unit/Validators/EachKeyTest.php new file mode 100644 index 000000000..a1d1cc2eb --- /dev/null +++ b/tests/unit/Validators/EachKeyTest.php @@ -0,0 +1,137 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use ArrayIterator; +use ArrayObject; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use Respect\Validation\Test\TestCase; +use Respect\Validation\Test\Validators\Stub; + +#[Group('validator')] +#[CoversClass(EachKey::class)] +final class EachKeyTest extends TestCase +{ + /** @return iterable */ + public static function providerForValidInput(): iterable + { + yield 'all keys pass with array' => [Stub::pass(3), ['a' => 1, 'b' => 2, 'c' => 3]]; + yield 'all keys pass with ArrayObject' => [Stub::pass(3), new ArrayObject(['a' => 1, 'b' => 2, 'c' => 3])]; + yield 'single key that passes' => [Stub::pass(1), ['a' => 1]]; + yield 'integer keys pass with array' => [Stub::pass(5), [1, 2, 3, 4, 5]]; + yield 'empty array' => [Stub::daze(), []]; + } + + /** @return iterable */ + public static function providerForInvalidInput(): iterable + { + yield 'some keys fail with array' => [Stub::fail(3), ['a' => 1, 'b' => 2, 'c' => 3]]; + yield 'all keys fail with array' => [Stub::fail(3), ['a' => 1, 'b' => 2, 'c' => 3]]; + yield 'mixed pass/fail with array' => [new Stub(true, false, true), ['a' => 1, 'b' => 2, 'c' => 3]]; + yield 'some keys fail with ArrayObject' => [Stub::fail(3), new ArrayObject(['a' => 1, 'b' => 2, 'c' => 3])]; + yield 'non-array input' => [Stub::daze(), 'not an array']; + yield 'string input' => [Stub::daze(), 'string']; + yield 'integer input' => [Stub::daze(), 123]; + yield 'null input' => [Stub::daze(), null]; + yield 'boolean input' => [Stub::daze(), true]; + yield 'object input' => [Stub::daze(), (object) ['foo' => 'bar']]; + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldValidateValidInput(Stub $stub, mixed $input): void + { + $validator = new EachKey($stub); + self::assertValidInput($validator, $input); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldValidateInvalidInput(Stub $stub, mixed $input): void + { + $validator = new EachKey($stub); + self::assertInvalidInput($validator, $input); + } + + #[Test] + public function shouldShortCircuitOnFirstFailure(): void + { + $stub = new Stub(true, false, true); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertFalse($result->hasPassed); + self::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitPassWhenAllKeysPass(): void + { + $stub = Stub::pass(3); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertTrue($result->hasPassed); + self::assertCount(3, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitFailForNonIterableInput(): void + { + $stub = Stub::daze(); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit('not an array'); + + self::assertFalse($result->hasPassed); + } + + #[Test] + public function shouldShortCircuitReturnIndeterminateForEmptyArray(): void + { + $stub = Stub::daze(); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit([]); + + self::assertTrue($result->hasPassed); + self::assertTrue($result->isIndeterminate); + } + + #[Test] + public function shouldShortCircuitWorkWithIterator(): void + { + $stub = new Stub(true, false, true); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit(new ArrayIterator(['a' => 1, 'b' => 2, 'c' => 3])); + + self::assertFalse($result->hasPassed); + self::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitIncludePathOnFailure(): void + { + $stub = new Stub(true, false, true); + $validator = new EachKey($stub); + + $result = $validator->evaluateShortCircuit(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertFalse($result->hasPassed); + self::assertSame('b', $result->path?->value); + } +}