From a6aeb6f7091bd6c36abc2d3358ec78f548ed8749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 1 Apr 2024 16:11:34 +0100 Subject: [PATCH 1/4] feat: added Collection rule --- src/ChainedValidatorInterface.php | 8 ++ src/Exception/CollectionException.php | 5 ++ src/Rule/Collection.php | 84 ++++++++++++++++++++ src/Rule/EachKey.php | 30 +++---- src/Rule/EachValue.php | 28 +++---- src/Rule/Type.php | 2 +- src/StaticValidatorInterface.php | 8 ++ tests/CollectionTest.php | 110 ++++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 src/Exception/CollectionException.php create mode 100644 src/Rule/Collection.php create mode 100644 tests/CollectionTest.php diff --git a/src/ChainedValidatorInterface.php b/src/ChainedValidatorInterface.php index 11d38f4..dfbb72c 100644 --- a/src/ChainedValidatorInterface.php +++ b/src/ChainedValidatorInterface.php @@ -18,6 +18,14 @@ public function choice( ?string $maxMessage = null ): ChainedValidatorInterface&Validator; + public function collection( + array $fields, + bool $allowExtraFields = false, + ?string $message = null, + ?string $extraFieldsMessage = null, + ?string $missingFieldsMessage = null + ): ChainedValidatorInterface&Validator; + public function count( ?int $min = null, ?int $max = null, diff --git a/src/Exception/CollectionException.php b/src/Exception/CollectionException.php new file mode 100644 index 0000000..800d25b --- /dev/null +++ b/src/Exception/CollectionException.php @@ -0,0 +1,5 @@ + $fields */ + public function __construct( + private readonly array $fields, + private readonly bool $allowExtraFields = false, + ?string $message = null, + ?string $extraFieldsMessage = null, + ?string $missingFieldsMessage = null + ) + { + $this->message = $message ?? $this->message; + $this->extraFieldsMessage = $extraFieldsMessage ?? $this->extraFieldsMessage; + $this->missingFieldsMessage = $missingFieldsMessage ?? $this->missingFieldsMessage; + } + + public function assert(mixed $value, ?string $name = null): void + { + try { + Validator::eachValue( + validator: Validator::type(Validator::class), + message: 'At field {{ key }}: {{ message }}' + )->assert($this->fields); + } + catch (ValidationException $exception) { + throw new UnexpectedValueException($exception->getMessage()); + } + + if (!\is_array($value)) { + throw new UnexpectedTypeException('array', get_debug_type($value)); + } + + foreach ($this->fields as $field => $validator) { + if (!isset($value[$field])) { + throw new CollectionException( + message: $this->missingFieldsMessage, + parameters: [ + 'field' => $field + ] + ); + } + + try { + $validator->assert($value[$field], \sprintf('"%s"', $field)); + } + catch (ValidationException $exception) { + throw new CollectionException( + message: $this->message, + parameters: [ + 'field' => $field, + 'message' => $exception->getMessage() + ] + ); + } + } + + if (!$this->allowExtraFields) { + foreach ($value as $field => $fieldValue) { + if (!isset($this->fields[$field])) { + throw new CollectionException( + message: $this->extraFieldsMessage, + parameters: [ + 'field' => $field + ] + ); + } + } + } + } +} \ No newline at end of file diff --git a/src/Rule/EachKey.php b/src/Rule/EachKey.php index 0a4a5d0..c7f2e4d 100644 --- a/src/Rule/EachKey.php +++ b/src/Rule/EachKey.php @@ -25,23 +25,23 @@ public function assert(mixed $value, ?string $name = null): void throw new UnexpectedTypeException('array|\Traversable', get_debug_type($value)); } - try { - foreach ($value as $key => $element) { + foreach ($value as $key => $element) { + try { $this->validator->assert($key, $name); } - } - catch (ValidationException $exception) { - throw new EachKeyException( - message: $this->message, - parameters: [ - 'value' => $value, - 'name' => $name, - 'key' => $key, - 'element' => $element, - // Replaces string "value" with string "key value" to get a more intuitive error message - 'message' => \str_replace(' value ', ' key value ', $exception->getMessage()) - ] - ); + catch (ValidationException $exception) { + throw new EachKeyException( + message: $this->message, + parameters: [ + 'value' => $value, + 'name' => $name, + 'key' => $key, + 'element' => $element, + // Replaces string "value" with string "key value" to get a more intuitive error message + 'message' => \str_replace(' value ', ' key value ', $exception->getMessage()) + ] + ); + } } } } \ No newline at end of file diff --git a/src/Rule/EachValue.php b/src/Rule/EachValue.php index 81b2eea..ece714f 100644 --- a/src/Rule/EachValue.php +++ b/src/Rule/EachValue.php @@ -25,22 +25,22 @@ public function assert(mixed $value, ?string $name = null): void throw new UnexpectedTypeException('array|\Traversable', get_debug_type($value)); } - try { - foreach ($value as $key => $element) { + foreach ($value as $key => $element) { + try { $this->validator->assert($element, $name); } - } - catch (ValidationException $exception) { - throw new EachValueException( - message: $this->message, - parameters: [ - 'value' => $value, - 'name' => $name, - 'key' => $key, - 'element' => $element, - 'message' => $exception->getMessage() - ] - ); + catch (ValidationException $exception) { + throw new EachValueException( + message: $this->message, + parameters: [ + 'value' => $value, + 'name' => $name, + 'key' => $key, + 'element' => $element, + 'message' => $exception->getMessage() + ] + ); + } } } } \ No newline at end of file diff --git a/src/Rule/Type.php b/src/Rule/Type.php index 56c20bf..7ca8499 100644 --- a/src/Rule/Type.php +++ b/src/Rule/Type.php @@ -93,7 +93,7 @@ public function assert(mixed $value, ?string $name = null): void } if (!isset(self::TYPE_FUNCTIONS[$constraint]) && !\class_exists($constraint) && !\interface_exists($constraint)) { - throw new UnexpectedOptionException('constraint type', \array_keys(self::TYPE_FUNCTIONS), $constraint); + throw new UnexpectedOptionException('type', \array_keys(self::TYPE_FUNCTIONS), $constraint); } } diff --git a/src/StaticValidatorInterface.php b/src/StaticValidatorInterface.php index 436b293..e17364f 100644 --- a/src/StaticValidatorInterface.php +++ b/src/StaticValidatorInterface.php @@ -17,6 +17,14 @@ public static function choice( ?string $maxMessage = null ): ChainedValidatorInterface&Validator; + public static function collection( + array $fields, + bool $allowExtraFields = false, + ?string $message = null, + ?string $extraFieldsMessage = null, + ?string $missingFieldsMessage = null + ): ChainedValidatorInterface&Validator; + public static function count( ?int $min = null, ?int $max = null, diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php new file mode 100644 index 0000000..ba2cad5 --- /dev/null +++ b/tests/CollectionTest.php @@ -0,0 +1,110 @@ + [ + new Collection(fields: ['field' => 'invalid']), + ['field' => 'value'], + $unexpectedFieldValueMessage + ]; + yield 'invalid value type' => [ + new Collection(fields: ['field' => Validator::notBlank()]), + 'invalid', + $unexpectedTypeMessage + ]; + } + + public static function provideRuleFailureConditionData(): \Generator + { + $exception = CollectionException::class; + $extraFieldsMessage = '/The (.*) field is not allowed\./'; + $missingFieldsMessage = '/The (.*) field is missing\./'; + + yield 'invalid field' => [ + new Collection(fields: ['field' => Validator::notBlank()]), + ['field' => ''], + $exception, + '/The "(.*)" value should not be blank, "" given\./' + ]; + yield 'extra fields' => [ + new Collection(fields: ['field' => Validator::notBlank()]), + ['field' => 'value', 'extrafield' => 'extravalue'], + $exception, + $extraFieldsMessage + ]; + yield 'missing fields' => [ + new Collection( + fields: [ + 'field1' => Validator::notBlank(), + 'field2' => Validator::notBlank() + ] + ), + ['field1' => 'value1'], + $exception, + $missingFieldsMessage + ]; + } + + public static function provideRuleSuccessConditionData(): \Generator + { + yield 'field' => [ + new Collection(fields: ['field' => Validator::notBlank()]), + ['field' => 'value'], + ]; + yield 'extra fields' => [ + new Collection( + fields: ['field' => Validator::notBlank()], + allowExtraFields: true + ), + ['field' => 'value', 'extrafield' => 'extravalue'] + ]; + } + + public static function provideRuleMessageOptionData(): \Generator + { + yield 'message' => [ + new Collection( + fields: ['field' => Validator::notBlank()], + message: 'There was an error: {{ message }}' + ), + ['field' => ''], + 'There was an error: The "field" value should not be blank, "" given.' + ]; + yield 'extra fields message' => [ + new Collection( + fields: ['field' => Validator::notBlank()], + extraFieldsMessage: 'The {{ field }} was not expected.' + ), + ['field' => 'value', 'extrafield' => 'extravalue'], + 'The "extrafield" was not expected.' + ]; + yield 'missing fields message' => [ + new Collection( + fields: ['field' => Validator::notBlank()], + missingFieldsMessage: 'Missing field: {{ field }}.' + ), + [], + 'Missing field: "field".' + ]; + } +} \ No newline at end of file From f94f645666423208cd08205f3652539f59287218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 1 Apr 2024 17:04:48 +0100 Subject: [PATCH 2/4] feat: allow values that implements Traversable and added the parameter name to messages --- src/Rule/Collection.php | 7 +++++-- tests/CollectionTest.php | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Rule/Collection.php b/src/Rule/Collection.php index da5d889..a308802 100644 --- a/src/Rule/Collection.php +++ b/src/Rule/Collection.php @@ -40,8 +40,8 @@ public function assert(mixed $value, ?string $name = null): void throw new UnexpectedValueException($exception->getMessage()); } - if (!\is_array($value)) { - throw new UnexpectedTypeException('array', get_debug_type($value)); + if (!\is_iterable($value)) { + throw new UnexpectedTypeException('array|\Traversable', get_debug_type($value)); } foreach ($this->fields as $field => $validator) { @@ -49,6 +49,7 @@ public function assert(mixed $value, ?string $name = null): void throw new CollectionException( message: $this->missingFieldsMessage, parameters: [ + 'name' => $name, 'field' => $field ] ); @@ -61,6 +62,7 @@ public function assert(mixed $value, ?string $name = null): void throw new CollectionException( message: $this->message, parameters: [ + 'name' => $name, 'field' => $field, 'message' => $exception->getMessage() ] @@ -74,6 +76,7 @@ public function assert(mixed $value, ?string $name = null): void throw new CollectionException( message: $this->extraFieldsMessage, parameters: [ + 'name' => $name, 'field' => $field ] ); diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index ba2cad5..92ee2ac 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -20,7 +20,7 @@ class CollectionTest extends AbstractTest public static function provideRuleUnexpectedValueData(): \Generator { $unexpectedFieldValueMessage = '/At field (.*)\: (.*)\./'; - $unexpectedTypeMessage = '/Expected value of type "array", "(.*)" given\./'; + $unexpectedTypeMessage = '/Expected value of type "array\|\\\Traversable", "(.*)" given\./'; yield 'invalid field value' => [ new Collection(fields: ['field' => 'invalid']), @@ -67,10 +67,14 @@ public static function provideRuleFailureConditionData(): \Generator public static function provideRuleSuccessConditionData(): \Generator { - yield 'field' => [ + yield 'array' => [ new Collection(fields: ['field' => Validator::notBlank()]), ['field' => 'value'], ]; + yield 'traversable' => [ + new Collection(fields: ['field' => Validator::notBlank()]), + new \ArrayIterator(['field' => 'value']) + ]; yield 'extra fields' => [ new Collection( fields: ['field' => Validator::notBlank()], From 343337262d55d9670d7e399b2e31cf24687a614f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 1 Apr 2024 17:15:14 +0100 Subject: [PATCH 3/4] docs: added Collection rule --- docs/03-rules.md | 1 + docs/03-rules_collection.md | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 docs/03-rules_collection.md diff --git a/docs/03-rules.md b/docs/03-rules.md index 489501e..214d616 100644 --- a/docs/03-rules.md +++ b/docs/03-rules.md @@ -41,5 +41,6 @@ ## Iterable Rules +- [Collection](03-rules_collection.md) - [EachValue](03-rules_each-value.md) - [EachKey](03-rules_each-key.md) \ No newline at end of file diff --git a/docs/03-rules_collection.md b/docs/03-rules_collection.md new file mode 100644 index 0000000..c5c06bd --- /dev/null +++ b/docs/03-rules_collection.md @@ -0,0 +1,123 @@ +# Collection + +Validates each key of an `array`, or object implementing `\Traversable`, with a set of validation constraints. + +```php +/** @var array $fields */ +Collection( + array $fields, + bool $allowExtraFields = false, + ?string $message = null, + ?string $extraFieldsMessage = null, + ?string $missingFieldsMessage = null +); +``` + +## Basic Usage + +```php +Validator::collection(fields: [ + 'name' => Validator::notBlank(), + 'age' => Validator::type('int')->greaterThanOrEqual(18) +])->validate([ + 'name' => 'Name', + 'age' => 25 +]); // true + +Validator::collection(fields: [ + 'name' => Validator::notBlank(), + 'age' => Validator::type('int')->greaterThanOrEqual(18) +])->validate([ + 'name' => '', + 'age' => 25 +]); // false ("name" is blank) + +// by default, unknown keys are not allowed and it will fail +Validator::collection(fields: [ + 'name' => Validator::notBlank(), + 'age' => Validator::type('int')->greaterThanOrEqual(18) +])->validate([ + 'name' => 'Name', + 'age' => 25, + 'email' => 'mail@example.com' +]); // false ("email" field is not allowed) + +// to allow extra fields, set option to true +Validator::collection( + fields: [ + 'name' => Validator::notBlank(), + 'age' => Validator::type('int')->greaterThanOrEqual(18) + ], + allowExtraFields: true +)->validate([ + 'name' => 'Name', + 'age' => 25, + 'email' => 'mail@example.com' +]); // true +``` + +> [!NOTE] +> An `UnexpectedValueException` will be thrown when a value in the `fields` associative array is not an instance of `Validator`. + +> [!NOTE] +> An `UnexpectedValueException` will be thrown when the input value is not an `array` or an object implementing `\Traversable`. + +## Options + +### `fields` + +type: `array` `required` + +Associative array with a set of validation constraints for each key. + +### `allowExtraFields` + +type: `bool` default: `false` + +By default, it is not allowed to have fields (array keys) that are not defined in the `fields` option. +If set to `true`, it will be allowed (but not validated). + +### `message` + +type: `?string` default: `{{ message }}` + +Message that will be shown when one of the fields is invalid. + +The following parameters are available: + +| Parameter | Description | +|-----------------|---------------------------------------| +| `{{ name }}` | Name of the invalid value | +| `{{ field }}` | Name of the invalid field (array key) | +| `{{ message }}` | The rule message of the invalid field | + +### `extraFieldsMessage` + +type: `?string` default: `The {{ field }} field is not allowed.` + +Message that will be shown when the input value has a field that is not defined in the `fields` option +and `allowExtraFields` is set to `false`. + +The following parameters are available: + +| Parameter | Description | +|-----------------|---------------------------------------| +| `{{ name }}` | Name of the invalid value | +| `{{ field }}` | Name of the invalid field (array key) | + +### `missingFieldsMessage` + +type: `?string` default: `The {{ field }} field is missing.` + +Message that will be shown when the input value *does not* have a field that is defined in the `fields` option. + +The following parameters are available: + +| Parameter | Description | +|-----------------|---------------------------------------| +| `{{ name }}` | Name of the invalid value | +| `{{ field }}` | Name of the invalid field (array key) | + +## Changelog + +- `1.0.0` Created \ No newline at end of file From e6bbfaa53909c46ef1b11660eeb05beb1278c350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 1 Apr 2024 17:19:02 +0100 Subject: [PATCH 4/4] docs: removed redundancy --- docs/03-rules_collection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/03-rules_collection.md b/docs/03-rules_collection.md index c5c06bd..f6746a6 100644 --- a/docs/03-rules_collection.md +++ b/docs/03-rules_collection.md @@ -32,7 +32,7 @@ Validator::collection(fields: [ 'age' => 25 ]); // false ("name" is blank) -// by default, unknown keys are not allowed and it will fail +// by default, unknown keys are not allowed Validator::collection(fields: [ 'name' => Validator::notBlank(), 'age' => Validator::type('int')->greaterThanOrEqual(18)