├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── docs ├── available-shorthands.md ├── data-validation.md ├── examples.md └── traversable-data.md └── src ├── Constraint ├── ConstraintCollectionBuilder.php ├── ConstraintMap.php ├── ConstraintMapItem.php ├── ConstraintResolver.php └── Type │ ├── BooleanValue.php │ ├── BooleanValueValidator.php │ ├── FloatNumber.php │ ├── FloatNumberValidator.php │ ├── InConstraint.php │ ├── InConstraintValidator.php │ ├── IntegerNumber.php │ └── IntegerNumberValidator.php ├── ConstraintFactory.php ├── Rule ├── InvalidRuleException.php ├── Rule.php ├── RuleList.php └── RuleParser.php └── Utility ├── Arrays.php └── InvalidArrayPathException.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 123inkt / DigitalRevolution 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF)](https://php.net/) 2 | [![Minimum Symfony Version](https://img.shields.io/badge/symfony-%3E%3D%206.2-brightgreen)](https://symfony.com/doc/current/validation.html) 3 | ![Run tests](https://github.com/123inkt/symfony-validation-shorthand/actions/workflows/test.yml/badge.svg) 4 | 5 | # Symfony Validation Shorthand 6 | A validation shorthand component for Symfony, similar to the syntax in the "illuminate/validator" package for Laravel. 7 | 8 | ## Installation 9 | Include the library as dependency in your own project via: 10 | ``` 11 | composer require "digitalrevolution/symfony-validation-shorthand" 12 | ``` 13 | 14 | ## Usage 15 | 16 | **Example** 17 | ```php 18 | $rules = [ 19 | 'name.first_name' => 'required|string|min:5', 20 | 'name.last_name' => 'string|min:6', // last name is optional 21 | 'email' => 'required|email', 22 | 'password' => 'required|string|between:7,40', 23 | 'phone_number' => 'required|regex:/^020\d+$/', 24 | 'news_letter' => 'required|bool', 25 | 'tags?.*' => 'required|string' // if tags is set, must be array of all strings with count > 0 26 | ]; 27 | 28 | // transform the rules into a Symfony Constraint tree 29 | $constraint = (new ConstraintFactory)->fromRuleDefinitions($rules); 30 | 31 | // validate the data 32 | $violations = \Symfony\Component\Validator\Validation::createValidator()->validate($data, $constraint); 33 | ``` 34 | 35 | Validates: 36 | ``` 37 | [ 38 | 'name' => [ 39 | 'first_name' => 'Peter', 40 | 'last_name' => 'Parker' 41 | ], 42 | 'email' => 'example@example.com', 43 | 'password' => 'hunter8', 44 | 'phone_number' => '0201234678', 45 | 'news_letter' => 'on', 46 | 'tags' => ['sports', 'movies', 'music'] 47 | ] 48 | ``` 49 | 50 | ## Documentation 51 | 52 | Full syntax and examples: 53 | - [Shorthands](docs/available-shorthands.md) 54 | - [Data validation](docs/data-validation.md) 55 | - [Traversable data](docs/traversable-data.md) 56 | - [Examples](docs/examples.md) 57 | 58 | ## About us 59 | 60 | At 123inkt (Part of Digital Revolution B.V.), every day more than 50 development professionals are working on improving our internal ERP 61 | and our several shops. Do you want to join us? [We are looking for developers](https://www.werkenbij123inkt.nl/zoek-op-afdeling/it). 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digitalrevolution/symfony-validation-shorthand", 3 | "description": "Validation shorthand for symfony", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "minimum-stability": "stable", 7 | "config": { 8 | "sort-packages": true, 9 | "allow-plugins": { 10 | "phpstan/extension-installer": true 11 | }, 12 | "lock": false 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "DigitalRevolution\\SymfonyValidationShorthand\\": "src/" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "DigitalRevolution\\SymfonyValidationShorthand\\Tests\\": "tests/" 22 | } 23 | }, 24 | "require": { 25 | "php": ">=8.1", 26 | "symfony/validator": "^6.2||^7.0" 27 | }, 28 | "require-dev": { 29 | "digitalrevolution/phpunit-file-coverage-inspection": "^v3.0.0", 30 | "roave/security-advisories": "dev-latest", 31 | "squizlabs/php_codesniffer": "^3.6", 32 | "phpmd/phpmd": "^2.14", 33 | "phpunit/phpunit": "^9.5", 34 | "phpstan/phpstan": "^2.0", 35 | "phpstan/phpstan-phpunit": "^2.0", 36 | "phpstan/phpstan-strict-rules": "^2.0", 37 | "phpstan/extension-installer": "^1.3" 38 | }, 39 | "scripts": { 40 | "baseline": ["@baseline:phpstan", "@baseline:phpmd"], 41 | "baseline:phpstan": "phpstan --generate-baseline", 42 | "baseline:phpmd": "phpmd src,tests xml phpmd.xml.dist --generate-baseline", 43 | "check": ["@check:phpstan", "@check:phpmd", "@check:phpcs"], 44 | "check:phpstan": "phpstan analyse", 45 | "check:phpmd": "phpmd src,tests text phpmd.xml.dist --suffixes php", 46 | "check:phpcs": "phpcs src tests", 47 | "fix": "@fix:phpcbf", 48 | "fix:phpcbf": "phpcbf src tests", 49 | "test": "phpunit", 50 | "test:integration": "phpunit --testsuite integration", 51 | "test:unit": "phpunit --testsuite unit" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/available-shorthands.md: -------------------------------------------------------------------------------- 1 | | Index | 2 | |:-------------------------------------------------- | 3 | | Shorthands | 4 | | [Array data validation](data-validation.md) | 5 | | [Traversable data validation](traversable-data.md) | 6 | | [Examples](examples.md) | 7 | 8 | # Available shorthands 9 | |General |Type |Range |Pattern |Date | 10 | |:--------------------|:------------------|:------------------|:------------------------|:--------------------------| 11 | |[filled](#filled) |[array](#array) |[between](#between)|[alpha](#alpha) |[date](#date) | 12 | |[nullable](#nullable)|[boolean](#boolean)|[max](#max) |[alpha_dash](#alpha_dash)|[datetime](#datetime) | 13 | |[required](#required)|[float](#float) |[min](#min) |[alpha_num](#alpha_num) |[date_format](#date_format)| 14 | | |[integer](#integer)| |[email](#email) | | 15 | | |[string](#string) | |[in](#in) | | 16 | | | | |[regex](#regex) | | 17 | | | | |[url](#url) | | 18 | 19 | ## alpha 20 | The field under validation must be entirely alphabetic characters. Shorthand for pattern: `[a-zA-Z]` 21 | 22 | ## alpha_dash 23 | The field under validation may have alpha-numeric characters, as well as dashes and underscores. Shorthand for pattern: `[a-zA-Z0-9_-]` 24 | 25 | ## alpha_num 26 | The field under validation must be entirely alpha-numeric characters. Shorthand for pattern: `[a-zA-Z0-9]` 27 | 28 | ## array 29 | The field under validation must be a PHP array. 30 | 31 | ## between: 32 | Arguments: `,` 33 | 34 | The constraint has different implementations based on the value type. 35 | - If the value has a date constraint (date, datetime or datetime_format), the `` arguments accepts now `DateTime` 36 | allowed formats. The value must be between than the supplied `` arguments. 37 | More information in the [Symfony Validation documentation](https://symfony.com/doc/current/reference/constraints/Range.html#date-ranges). 38 | - If the value has a numeric constraint (integer or float), it must lie between the two values. 39 | - Otherwise, the length of the value must be between the supplied values. 40 | 41 | Example: 42 | - string must have minimum length of 2 and maximum length of 6: `between:2,6` 43 | - integer must have a value between 2 and 6 or less: `integer|between:2,6` 44 | - date must be between `2010-01-01` and `2011-01-01`: `date|between:2010-01-01,2011-01-01` 45 | 46 | ## boolean 47 | The value must be bool or castable to bool. 48 | - allowed `true` values: `1, '1', 'on', true` 49 | - allowed `false` values: `0, '0', 'off', false` 50 | 51 | Note: can also be written as `bool` 52 | 53 | ## date 54 | The value must be a valid date of format `Y-m-d` 55 | 56 | ## datetime 57 | The value must be a valid date+time of format `Y-m-d H:i:s` 58 | 59 | ## date_format 60 | Argument: `` 61 | 62 | The value must match the given date pattern. See [DateTime::createFromFormat()](https://www.php.net/manual/en/datetime.createfromformat.php) for formatting options. 63 | 64 | ## email 65 | The value must be a valid email. 66 | 67 | ## filled 68 | The value must be filled and not be null (except if [nullable](#nullable) is also set). If the value is an empty string, this validation rule fails. 69 | 70 | ## float 71 | The value must be a float or castable to float. 72 | - example of allowed values: `-1, 1, -1.1, 1.1, '1.1', '-1.1', '.1', '1.', '1', '-1'` 73 | 74 | ## in 75 | Arguments: `string,string,...` 76 | 77 | The field under validation must be included in the given list of values. 78 | 79 | **Example:** 80 | ``` 81 | required|in:foo,bar 82 | ``` 83 | 84 | ## integer 85 | The value must be an integer or castable to int. 86 | - example of allowed values: `1, -1, '1', '-1'` 87 | 88 | Note: can also be written as `int` 89 | 90 | ## max: 91 | Argument: `` 92 | 93 | The constraint has different implementations based on the value type. 94 | - If the value has a date constraint (date, datetime or datetime_format), the `` argument accepts now `DateTime` 95 | allowed formats and the value must be less or equal than the supplied argument. 96 | More information in the [Symfony Validation documentation](https://symfony.com/doc/current/reference/constraints/LessThanOrEqual.html#comparing-dates). 97 | - If the value has a numeric constraint (integer or float), it must be smaller than the supplied value. 98 | - Otherwise, the length of the value must be smaller than the supplied value. 99 | 100 | 101 | Example: 102 | - string with maximum length of 6: `max:6` 103 | - integer which has to be 6 or less: `integer|max:6` 104 | - limit the given date by: `date|max:+10 days` 105 | 106 | ## min 107 | Argument: `` 108 | 109 | The constraint has different implementations based on the value type. 110 | - If the value has a date constraint (date, datetime or datetime_format), the `` argument accepts now `DateTime` 111 | allowed formats and the value must be greater or equal than the supplied `` argument. 112 | More information in the [Symfony Validation documentation](https://symfony.com/doc/current/reference/constraints/GreaterThan.html#comparing-dates). 113 | - If the value has a numeric constraint (integer or float), it must be bigger than the supplied value. 114 | - Otherwise, the length of the value must be bigger than the supplied value. 115 | 116 | Example: 117 | - string with minimum length of 6: `min:6` 118 | - integer which has to be 6 or higher: `integer|min:6` 119 | - limit the given date by: `date|min:now` 120 | 121 | ## nullable 122 | The value can be `null`. 123 | 124 | ## regex 125 | Argument: `` 126 | 127 | The value must match the supplied regex. The full string will be passed to the preg_match function. 128 | 129 | Example: 130 | - match all strings starting with 'ab': `regex:/^ab.*$/` 131 | 132 | ## required 133 | By default in a data set a key/value-pair can be left out. Add `required` to make the key/value-pair mandatory. 134 | 135 | **Example:** 136 | ``` 137 | [ 138 | 'a' => 'required|string', 139 | 'b' => 'integer' 140 | ] 141 | ``` 142 | Passes: 143 | ``` 144 | ['a' => 'a']; 145 | ``` 146 | Fails: 147 | ``` 148 | ['b' => '1']; 149 | ``` 150 | 151 | ## string 152 | The value must be a string. 153 | 154 | ## url 155 | The value must be a valid url. 156 | -------------------------------------------------------------------------------- /docs/data-validation.md: -------------------------------------------------------------------------------- 1 | | Index | 2 | |:-------------------------------------------------- | 3 | | [Shorthands](available-shorthands.md) | 4 | | Array data Validation | 5 | | [Traversable data validation](traversable-data.md) | 6 | | [Examples](examples.md) | 7 | 8 | # Array data validation 9 | 10 | ## Basic example 11 | 12 | Validating a key/value-pair data array. 13 | 14 | Rules: 15 | ```php 16 | [ 17 | 'productId' => 'required|int|min:1', 18 | 'name' => 'required|string|filled|max:255', 19 | 'description' => 'string|filled|nullable|max:10000', 20 | 'active' => 'required|bool', 21 | 'price' => 'required|float|min:0', 22 | 'discount' => 'float|nullable' 23 | ] 24 | ``` 25 | 26 | **Explanation:** 27 | 28 | | Column | Description | 29 | |:-------------|:------------------------------------------------------------------------- | 30 | | productId | required and must be integer of 1 or higher | 31 | | name | required and must be non empty string less or equal than 255 characters | 32 | | description | optional nullable or non empty string less or equal than 10000 characters | 33 | | active | required true/false | 34 | | price | required non nullable float greater or equal than zero | 35 | | discount | optional nullable float | 36 | 37 | **Successfully validates:** 38 | ```php 39 | [ 40 | 'productId' => '12345', 41 | 'name' => 'Laser printer', 42 | 'description' => null, 43 | 'active' => '1', 44 | 'price' => '4.99', 45 | 'discount' => '-1.00' 46 | ] 47 | ``` 48 | 49 | ## Advanced example 50 | ```php 51 | [ 52 | 'contact.first_name' => 'required|string|filled', 53 | 'contact.last_name' => 'required|string|between:10,100', 54 | 'contact.phone_number' => 'required|regex:/^\d{10}$/', 55 | 'contact.email' => 'required|string|email', 56 | 'contact.birth_day' => ['required|string', new \Symfony\Component\Validator\Constraints\Date()], 57 | 'address.street_name' => 'required|string', 58 | 'address.house_number' => 'required|int|between:0,999', 59 | 'address.addition' => 'string|nullable|max:3', 60 | 'address.city' => 'required|string|filled', 61 | 'address.country_code' => 'required|string|between:2,2', 62 | 'interests?.*.interestId' => 'required:int:min:1', 63 | 'interests?.*.label' => 'required|string|filled', 64 | 'tags?.*' => 'required|string|filled' 65 | ] 66 | ``` 67 | 68 | **Explanation:** 69 | 70 | The keys `contact` and `address` are mandatory, while `interests` and `tags` are optional. 71 | If there's no suitable shorthand, the rules can be supplemented with Symfony `Constraint`'s. 72 | If `interested` is given, it must be an array of `[interestId, label]` elements 73 | If `tags` is given, it must be a non-empty array (required) of strings with minimum length of 1 (filled) 74 | 75 | **Successfully validates** 76 | ```php 77 | [ 78 | 'contact' => [ 79 | 'first_name' => 'Peter', 80 | 'last_name' => 'Parker', 81 | 'phone_number' => '0123456789', 82 | 'email' => 'example@example.com', 83 | ], 84 | 'address' => [ 85 | 'street_name' => '15th Street', 86 | 'house_number' => '24', 87 | 'city' => 'New York', 88 | 'country_code' => 'US', 89 | ], 90 | 'interests' => [ 91 | ['interestId' => 5, 'label' => 'Movies'], 92 | ['interestId' => 7, 'label' => 'Photography'], 93 | ], 94 | 'tags' => ['customer', 'student', 'new'] 95 | ] 96 | ``` 97 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | | Index | 2 | |:---------------------------------------------------| 3 | | [Shorthands](available-shorthands.md) | 4 | | [Array data Validation](data-validation.md) | 5 | | [Traversable data validation](traversable-data.md) | 6 | | Examples | 7 | 8 | # Examples 9 | 10 | ### Required string 11 | ``` 12 | ['name' => 'required|string'] 13 | ``` 14 | ``` 15 | success: ['name' => 'Peter Parker'] 16 | success: ['name' => ''] 17 | fails: [] 18 | fails: ['name' => null] 19 | ``` 20 | 21 | ### Required nullable string 22 | ``` 23 | ['name' => 'required|string|nullable'] 24 | ``` 25 | ``` 26 | success: ['name' => 'Peter Parker'] 27 | success: ['name' => ''] 28 | success: ['name' => null] 29 | fails: [] 30 | ``` 31 | 32 | ### Required nullable non-empty string 33 | ``` 34 | ['name' => 'required|string|nullable|filled'] 35 | ``` 36 | ``` 37 | success: ['name' => 'Peter Parker'] 38 | success: ['name' => null] 39 | fails: ['name' => ''] 40 | fails: [] 41 | ``` 42 | 43 | ### Optional nullable non-empty string 44 | ``` 45 | ['name' => 'string|nullable|filled'] 46 | ``` 47 | ``` 48 | success: ['name' => 'Peter Parker'] 49 | success: ['name' => null] 50 | success: [] 51 | fails: ['name' => ''] 52 | ``` 53 | 54 | ### int array 55 | ``` 56 | ['*' => 'int'] 57 | ``` 58 | ``` 59 | success: [] 60 | success: ['1', 2] 61 | fails: null 62 | ``` 63 | 64 | ### non-empty int array 65 | ``` 66 | ['*' => 'required|int'] 67 | ``` 68 | ``` 69 | success: ['1', 2] 70 | fails: [] 71 | fails: null 72 | ``` 73 | 74 | ### non-empty null or int array 75 | ``` 76 | ['*' => 'required|int|null'] 77 | ``` 78 | ``` 79 | success: ['1', 2, null] 80 | fails: [] 81 | fails: null 82 | ``` 83 | 84 | ### custom constraints 85 | ``` 86 | ['createdAt' => ['required|string', new \Symfony\Component\Validator\Constraints\Date()] 87 | ``` 88 | ``` 89 | success: ['createdAt' => '2020-01-01'] 90 | fails: [ 100 ] 91 | fails: [] 92 | ``` 93 | -------------------------------------------------------------------------------- /docs/traversable-data.md: -------------------------------------------------------------------------------- 1 | | Index | 2 | |:------------------------------------------------- | 3 | | [Shorthands](available-shorthands.md) | 4 | | [Array data Validation](data-validation.md) | 5 | | Traversable data validation | 6 | | [Examples](examples.md) | 7 | 8 | # Traversable data validation 9 | 10 | Symfony's `All` Constraint allows you to validate `\Traversable` data. For example data read from a csv or Excel provided via a yield function. The `*` 11 | notation marks the set as iterable and internally the `All` constraint will be used instead of `Collection`. 12 | 13 | # ArrayIterator example 14 | 15 | **Rules:** 16 | For a csv with 4 columns. 17 | 18 | ```php 19 | [ 20 | '*.0' => 'required|int|min:1', 21 | '*.1' => 'required|string|email', 22 | '*.2' => 'required|float', 23 | '*.3' => 'required|string|max:2000' 24 | ] 25 | ``` 26 | 27 | **Validates:** 28 | ```php 29 | $iterator = new ArrayIterator( 30 | [ 31 | ['4', 'exampleA@example.com', '2.59', 'apples'], 32 | ['9', 'exampleB@example.com', '3.06', 'raspberries'], 33 | ['3', 'exampleC@example.com', '115.99', 'pineapple'], 34 | ] 35 | ); 36 | ``` 37 | 38 | ## Non empty value set 39 | By default the `*` will allow the set to be empty. If you validate a set of values, you can mark the set as non-empty with the `required` rule. 40 | 41 | **Rules:** 42 | ``` 43 | ['*' => 'required|int'] 44 | ``` 45 | 46 | **Validates:** 47 | ``` 48 | success: [1, 2, '3'] 49 | fails: [] 50 | fails: [1, 'a'] 51 | ``` 52 | -------------------------------------------------------------------------------- /src/Constraint/ConstraintCollectionBuilder.php: -------------------------------------------------------------------------------- 1 | allowExtraFields = $allowExtraFields; 23 | 24 | return $this; 25 | } 26 | 27 | /** 28 | * @return Constraint|Constraint[] 29 | * @throws InvalidRuleException 30 | */ 31 | public function build(ConstraintMap $constraintsMap) 32 | { 33 | $constraintTreeMap = []; 34 | foreach ($constraintsMap as $key => $constraints) { 35 | try { 36 | Arrays::assignToPath($constraintTreeMap, explode('.', $key), $constraints); 37 | } catch (InvalidArrayPathException $e) { 38 | throw new InvalidRuleException( 39 | sprintf("'%s' can't be assigned as this path already contains a non-array value.", $key), 40 | 0, 41 | $e 42 | ); 43 | } 44 | } 45 | 46 | return $this->createConstraintTree($constraintTreeMap); 47 | } 48 | 49 | /** 50 | * @param array> $constraintTreeMap 51 | * 52 | * @return Constraint|Constraint[] 53 | * @throws InvalidRuleException 54 | */ 55 | private function createConstraintTree(array $constraintTreeMap) 56 | { 57 | if (count($constraintTreeMap) === 1 && isset($constraintTreeMap['*'])) { 58 | return $this->createAllConstraint($constraintTreeMap['*']); 59 | } 60 | 61 | return $this->createCollectionConstraint($constraintTreeMap); 62 | } 63 | 64 | /** 65 | * @param ConstraintMapItem|array $node 66 | * 67 | * @return Constraint|Constraint[] 68 | * @throws InvalidRuleException 69 | */ 70 | private function createAllConstraint($node) 71 | { 72 | $required = false; 73 | if ($node instanceof ConstraintMapItem) { 74 | $constraints = $node->getConstraints(); 75 | $required = $node->isRequired(); 76 | } else { 77 | $constraints = $this->createConstraintTree($node); 78 | } 79 | 80 | if ($required) { 81 | return [new Assert\Count(['min' => 1]), new Assert\All($constraints)]; 82 | } 83 | 84 | return new Assert\All($constraints); 85 | } 86 | 87 | /** 88 | * @param array> $constraintTreeMap 89 | * 90 | * @throws InvalidRuleException 91 | */ 92 | private function createCollectionConstraint(array $constraintTreeMap): Assert\Collection 93 | { 94 | $constraintMap = []; 95 | 96 | // array contains arrays, recursively resolve 97 | foreach ($constraintTreeMap as $key => $node) { 98 | $optional = false; 99 | // key is marked as optional 100 | if (is_string($key) && str_ends_with($key, '?')) { 101 | $key = substr($key, 0, -1); 102 | $optional = true; 103 | } 104 | 105 | $constraintMap[$key] = $this->getNodeConstraint($node, $optional); 106 | } 107 | 108 | return new Assert\Collection(['fields' => $constraintMap, 'allowExtraFields' => $this->allowExtraFields]); 109 | } 110 | 111 | /** 112 | * @param ConstraintMapItem|array $node 113 | * 114 | * @return Constraint|Constraint[] 115 | * @throws InvalidRuleException 116 | */ 117 | private function getNodeConstraint($node, bool $optional) 118 | { 119 | if ($node instanceof ConstraintMapItem === false) { 120 | // recursively resolve 121 | $constraint = $this->createConstraintTree($node); 122 | } else { 123 | // leaf node, check for required. It should overrule any optional indicators in the key 124 | $constraint = $node->getConstraints(); 125 | $optional = $node->isRequired() === false; 126 | } 127 | 128 | // optional key 129 | if ($optional && $constraint instanceof Assert\Required === false && $constraint instanceof Assert\Optional === false) { 130 | return new Assert\Optional($constraint); 131 | } 132 | 133 | return $constraint; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Constraint/ConstraintMap.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ConstraintMap implements IteratorAggregate 13 | { 14 | /** @var array */ 15 | private $map = []; 16 | 17 | public function set(string $key, ConstraintMapItem $item): self 18 | { 19 | $this->map[$key] = $item; 20 | 21 | return $this; 22 | } 23 | 24 | /** 25 | * @return ArrayIterator 26 | */ 27 | public function getIterator(): ArrayIterator 28 | { 29 | return new ArrayIterator($this->map); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Constraint/ConstraintMapItem.php: -------------------------------------------------------------------------------- 1 | constraints = $constraints; 22 | $this->required = $required; 23 | } 24 | 25 | /** 26 | * @return Constraint[] 27 | */ 28 | public function getConstraints(): array 29 | { 30 | return $this->constraints; 31 | } 32 | 33 | public function isRequired(): bool 34 | { 35 | return $this->required; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Constraint/ConstraintResolver.php: -------------------------------------------------------------------------------- 1 | hasRules() === false) { 22 | /** @var Constraint[] $constraints */ 23 | $constraints = $ruleList->getRules(); 24 | 25 | return $constraints; 26 | } 27 | 28 | $nullable = false; 29 | $constraints = []; 30 | foreach ($ruleList->getRules() as $rule) { 31 | if ($rule instanceof Constraint) { 32 | $constraints[] = $rule; 33 | continue; 34 | } 35 | 36 | /** @var Rule $rule */ 37 | if ($rule->getName() === Rule::RULE_REQUIRED) { 38 | continue; 39 | } 40 | 41 | if ($rule->getName() === Rule::RULE_NULLABLE) { 42 | $nullable = true; 43 | continue; 44 | } 45 | 46 | $constraints[] = $this->resolveConstraint($ruleList, $rule); 47 | } 48 | 49 | if ($nullable === false) { 50 | $constraints[] = new Assert\NotNull(); 51 | } 52 | 53 | return $constraints; 54 | } 55 | 56 | /** 57 | * @throws InvalidRuleException 58 | */ 59 | private function resolveConstraint(RuleList $ruleList, Rule $rule): Constraint 60 | { 61 | switch ($rule->getName()) { 62 | case Rule::RULE_BOOLEAN: 63 | return new Type\BooleanValue(); 64 | case Rule::RULE_INTEGER: 65 | return new Type\IntegerNumber(); 66 | case Rule::RULE_FLOAT: 67 | return new Type\FloatNumber(); 68 | case Rule::RULE_STRING: 69 | return new Assert\Type('string'); 70 | case Rule::RULE_ARRAY: 71 | return new Assert\Type('array'); 72 | case Rule::RULE_ALPHA: 73 | return new Assert\Regex(['pattern' => '/^[a-zA-Z]*$/']); 74 | case Rule::RULE_ALPHA_DASH: 75 | return new Assert\Regex(['pattern' => '/^[\w-]*$/']); 76 | case Rule::RULE_ALPHA_NUM: 77 | return new Assert\Regex(['pattern' => '/^[a-zA-Z0-9]*$/']); 78 | case Rule::RULE_IN: 79 | return new Type\InConstraint(['values' => $rule->getParameters()]); 80 | case Rule::RULE_DATE: 81 | return new Assert\Date(); 82 | case Rule::RULE_DATETIME: 83 | return new Assert\DateTime(); 84 | case Rule::RULE_DATE_FORMAT: 85 | return new Assert\DateTime(['format' => $rule->getParameter(0)]); 86 | case Rule::RULE_EMAIL: 87 | return new Assert\Email(); 88 | case Rule::RULE_URL: 89 | return new Assert\Url(); 90 | case Rule::RULE_REGEX: 91 | return new Assert\Regex(['pattern' => $rule->getParameter(0)]); 92 | case Rule::RULE_FILLED: 93 | return new Assert\NotBlank(['allowNull' => $ruleList->hasRule(Rule::RULE_NULLABLE)]); 94 | case Rule::RULE_MIN: 95 | return $this->resolveMinConstraint($rule, $ruleList); 96 | case Rule::RULE_MAX: 97 | return $this->resolveMaxConstraint($rule, $ruleList); 98 | case Rule::RULE_BETWEEN: 99 | return $this->resolveBetweenConstraint($rule, $ruleList); 100 | } 101 | 102 | throw new InvalidRuleException( 103 | "Unable to resolve rule: '" . $rule->getName() . "'. Supported rules: " . implode(", ", Rule::ALLOWED_RULES) 104 | ); 105 | } 106 | 107 | /** 108 | * @throws InvalidRuleException 109 | */ 110 | private function resolveMinConstraint(Rule $rule, RuleList $ruleList): Constraint 111 | { 112 | if ($ruleList->hasRule([Rule::RULE_DATE, Rule::RULE_DATETIME, Rule::RULE_DATE_FORMAT])) { 113 | return new Assert\GreaterThanOrEqual($rule->getParameter(0)); 114 | } 115 | 116 | if ($ruleList->hasRule([Rule::RULE_INTEGER, Rule::RULE_FLOAT])) { 117 | return new Assert\GreaterThanOrEqual($rule->getIntParam(0)); 118 | } 119 | 120 | return new Assert\Length(['min' => $rule->getIntParam(0)]); 121 | } 122 | 123 | /** 124 | * @throws InvalidRuleException 125 | */ 126 | private function resolveMaxConstraint(Rule $rule, RuleList $ruleList): Constraint 127 | { 128 | if ($ruleList->hasRule([Rule::RULE_DATE, Rule::RULE_DATETIME, Rule::RULE_DATE_FORMAT])) { 129 | return new Assert\LessThanOrEqual($rule->getParameter(0)); 130 | } 131 | 132 | if ($ruleList->hasRule([Rule::RULE_INTEGER, Rule::RULE_FLOAT])) { 133 | return new Assert\LessThanOrEqual($rule->getIntParam(0)); 134 | } 135 | 136 | return new Assert\Length(['max' => $rule->getIntParam(0)]); 137 | } 138 | 139 | /** 140 | * @throws InvalidRuleException 141 | */ 142 | private function resolveBetweenConstraint(Rule $rule, RuleList $ruleList): Constraint 143 | { 144 | if ($ruleList->hasRule([Rule::RULE_DATE, Rule::RULE_DATETIME, Rule::RULE_DATE_FORMAT])) { 145 | return new Assert\Range(['min' => $rule->getParameter(0), 'max' => $rule->getParameter(1)]); 146 | } 147 | 148 | if ($ruleList->hasRule([Rule::RULE_INTEGER, Rule::RULE_FLOAT])) { 149 | return new Assert\Range(['min' => $rule->getIntParam(0), 'max' => $rule->getIntParam(1)]); 150 | } 151 | 152 | return new Assert\Length(['min' => $rule->getIntParam(0), 'max' => $rule->getIntParam(1)]); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Constraint/Type/BooleanValue.php: -------------------------------------------------------------------------------- 1 | 'INVALID_BOOLEAN_ERROR', 15 | ]; 16 | 17 | public string $message = '{{ value }} is not a valid boolean.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Constraint/Type/BooleanValueValidator.php: -------------------------------------------------------------------------------- 1 | FILTER_NULL_ON_FAILURE | FILTER_REQUIRE_SCALAR]); 27 | if ($filtered === null) { 28 | $this->context->buildViolation($constraint->message) 29 | ->setParameter('{{ value }}', $this->formatValue($value)) 30 | ->setCode($constraint::INVALID_BOOLEAN_ERROR) 31 | ->addViolation(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Constraint/Type/FloatNumber.php: -------------------------------------------------------------------------------- 1 | 'INVALID_DECIMAL_ERROR', 17 | self::INVALID_VALUE_TYPE => 'INVALID_VALUE_TYPE', 18 | ]; 19 | 20 | public string $message = '{{ value }} is not a valid decimal.'; 21 | } 22 | -------------------------------------------------------------------------------- /src/Constraint/Type/FloatNumberValidator.php: -------------------------------------------------------------------------------- 1 | context->buildViolation($constraint->message) 29 | ->setParameter('{{ value }}', $this->formatValue($value)) 30 | ->setCode($constraint::INVALID_VALUE_TYPE) 31 | ->addViolation(); 32 | return; 33 | } 34 | 35 | // value can't be cast to float 36 | if ($value === '' || $value === '-' || preg_match('/^-?(?:[1-9]\d*|0)?(?:\.\d*)?$/', $value) !== 1) { 37 | $this->context->buildViolation($constraint->message) 38 | ->setParameter('{{ value }}', $this->formatValue($value)) 39 | ->setCode($constraint::INVALID_DECIMAL_ERROR) 40 | ->addViolation(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Constraint/Type/InConstraint.php: -------------------------------------------------------------------------------- 1 | 'NOT_IN_ERROR' 15 | ]; 16 | 17 | public string $message = '{{ value }} is not contained in `{{ values }}`.'; 18 | 19 | /** @var string[] */ 20 | public array $values = []; 21 | 22 | /** 23 | * @inheritDoc 24 | */ 25 | public function getRequiredOptions(): array 26 | { 27 | return ['values']; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Constraint/Type/InConstraintValidator.php: -------------------------------------------------------------------------------- 1 | values, true) === false) { 28 | $this->context->buildViolation($constraint->message) 29 | ->setParameter('{{ value }}', $this->formatValue($value)) 30 | ->setParameter('{{ values }}', implode(',', $constraint->values)) 31 | ->setCode($constraint::NOT_IN_ERROR) 32 | ->addViolation(); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Constraint/Type/IntegerNumber.php: -------------------------------------------------------------------------------- 1 | 'INVALID_NUMBER_ERROR', 17 | self::INVALID_VALUE_TYPE => 'INVALID_VALUE_TYPE', 18 | ]; 19 | 20 | public string $message = '{{ value }} is not a valid number.'; 21 | } 22 | -------------------------------------------------------------------------------- /src/Constraint/Type/IntegerNumberValidator.php: -------------------------------------------------------------------------------- 1 | context->buildViolation($constraint->message) 29 | ->setParameter('{{ value }}', $this->formatValue($value)) 30 | ->setCode($constraint::INVALID_VALUE_TYPE) 31 | ->addViolation(); 32 | return; 33 | } 34 | 35 | // value can't be cast to int 36 | if (((string)(int)$value) !== (string)$value) { 37 | $this->context->buildViolation($constraint->message) 38 | ->setParameter('{{ value }}', $this->formatValue($value)) 39 | ->setCode($constraint::INVALID_NUMBER_ERROR) 40 | ->addViolation(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ConstraintFactory.php: -------------------------------------------------------------------------------- 1 | parser = $parser ?? new RuleParser(); 28 | $this->resolver = $resolver ?? new ConstraintResolver(); 29 | $this->collectionBuilder = $collectionBuilder ?? new ConstraintCollectionBuilder(); 30 | } 31 | 32 | /** 33 | * @param Constraint|array> $ruleDefinitions 34 | * @param bool $allowExtraFields Allow for extra, unvalidated, fields to be 35 | * 36 | * @return Constraint|Constraint[] 37 | * @throws InvalidRuleException 38 | */ 39 | public function fromRuleDefinitions($ruleDefinitions, bool $allowExtraFields = false) 40 | { 41 | if ($ruleDefinitions instanceof Constraint || self::isConstraintList($ruleDefinitions)) { 42 | return $ruleDefinitions; 43 | } 44 | 45 | // transform rule definitions to ConstraintMap 46 | $constraintMap = new ConstraintMap(); 47 | foreach ($ruleDefinitions as $key => $rules) { 48 | // transform rules to RuleList 49 | $ruleList = $this->parser->parseRules($rules); 50 | 51 | // transform RuleList to ConstraintMap 52 | $constraints = $this->resolver->resolveRuleList($ruleList); 53 | 54 | // add to set 55 | $constraintMap->set($key, new ConstraintMapItem($constraints, $ruleList->hasRule('required'))); 56 | } 57 | 58 | // transform ConstraintMap to ConstraintCollection 59 | return $this->collectionBuilder->setAllowExtraFields($allowExtraFields)->build($constraintMap); 60 | } 61 | 62 | /** 63 | * Check if `definition` is of type `array` 64 | * 65 | * @param array> $ruleDefinitions 66 | * 67 | * @phpstan-assert-if-true Constraint[] $ruleDefinitions 68 | */ 69 | private static function isConstraintList(array $ruleDefinitions): bool 70 | { 71 | foreach ($ruleDefinitions as $key => $definition) { 72 | if (is_int($key) === false || $definition instanceof Constraint === false) { 73 | return false; 74 | } 75 | } 76 | 77 | return true; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Rule/InvalidRuleException.php: -------------------------------------------------------------------------------- 1 | name = $name; 66 | $this->parameters = $parameters; 67 | } 68 | 69 | public function getName(): string 70 | { 71 | return $this->name; 72 | } 73 | 74 | /** 75 | * @throws InvalidRuleException 76 | */ 77 | public function getParameter(int $offset): string 78 | { 79 | if (isset($this->parameters[$offset]) === false) { 80 | throw new InvalidRuleException("Rule '" . $this->getName() . "' expects at least " . $offset . ' parameter(s)'); 81 | } 82 | 83 | return $this->parameters[$offset]; 84 | } 85 | 86 | /** 87 | * @throws InvalidRuleException 88 | */ 89 | public function getIntParam(int $offset): int 90 | { 91 | $argument = $this->getParameter($offset); 92 | if ((string)(int)$argument !== $argument) { 93 | throw new InvalidRuleException( 94 | "Rule '" . $this->getName() . "' expects parameter #" . $offset . " to be an int. Encountered: '" . $argument . "'" 95 | ); 96 | } 97 | 98 | return (int)$argument; 99 | } 100 | 101 | /** 102 | * @return string[] 103 | */ 104 | public function getParameters(): array 105 | { 106 | return $this->parameters; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Rule/RuleList.php: -------------------------------------------------------------------------------- 1 | */ 12 | private $rules = []; 13 | 14 | /** 15 | * Rule type lookup index 16 | * @var array 17 | */ 18 | private $ruleTypes = []; 19 | 20 | /** 21 | * @param string|string[] $names 22 | */ 23 | public function hasRule($names): bool 24 | { 25 | $names = is_array($names) ? $names : [$names]; 26 | foreach ($names as $name) { 27 | if (isset($this->ruleTypes[$name]) === true) { 28 | return true; 29 | } 30 | } 31 | 32 | return false; 33 | } 34 | 35 | /** 36 | * @return bool false if all rules are of type Constraint. true otherwise. 37 | */ 38 | public function hasRules(): bool 39 | { 40 | return count($this->ruleTypes) > 0; 41 | } 42 | 43 | /** 44 | * @return array 45 | */ 46 | public function getRules(): array 47 | { 48 | return $this->rules; 49 | } 50 | 51 | /** 52 | * @param Rule|Constraint $rule 53 | */ 54 | public function addRule($rule): self 55 | { 56 | $this->rules[] = $rule; 57 | if ($rule instanceof Rule) { 58 | $this->ruleTypes[$rule->getName()] = true; 59 | } 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * @param array $rules 66 | */ 67 | public function addAll(array $rules): self 68 | { 69 | foreach ($rules as $rule) { 70 | $this->addRule($rule); 71 | } 72 | 73 | return $this; 74 | } 75 | 76 | public function count(): int 77 | { 78 | return count($this->rules); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Rule/RuleParser.php: -------------------------------------------------------------------------------- 1 | $rules 14 | * @throws InvalidRuleException 15 | */ 16 | public function parseRules($rules): RuleList 17 | { 18 | if (is_array($rules) === false) { 19 | $rules = [$rules]; 20 | } 21 | 22 | $ruleSet = new RuleList(); 23 | foreach ($rules as $rule) { 24 | if ($rule instanceof Constraint) { 25 | $ruleSet->addRule($rule); 26 | } else { 27 | $ruleSet->addAll($this->explodeExplicitRule($rule)); 28 | } 29 | } 30 | 31 | return $ruleSet; 32 | } 33 | 34 | /** 35 | * Explode a string rule 36 | * 37 | * @param mixed $rule 38 | * @return Rule[] 39 | * @throws InvalidRuleException 40 | */ 41 | protected function explodeExplicitRule($rule): array 42 | { 43 | if (is_string($rule)) { 44 | return array_map([$this, 'parseStringRule'], explode('|', $rule)); 45 | } 46 | throw new InvalidRuleException('Invalid rule definition type. Expecting string or Symfony\Component\Validator\Constraint'); 47 | } 48 | 49 | /** 50 | * Parse a string based rule. 51 | */ 52 | protected function parseStringRule(string $rule): Rule 53 | { 54 | $parameters = []; 55 | if (strpos($rule, ':') !== false) { 56 | [$rule, $parameter] = explode(':', $rule, 2); 57 | 58 | $parameters = static::parseParameters($rule, $parameter); 59 | } 60 | $rule = self::normalizeRuleName(strtolower($rule)); 61 | return new Rule($rule, $parameters); 62 | } 63 | 64 | /** 65 | * Parse a parameter list. 66 | * 67 | * @return string[] 68 | */ 69 | protected static function parseParameters(string $rule, string $parameter): array 70 | { 71 | $rule = strtolower($rule); 72 | if ($rule === Rule::RULE_REGEX) { 73 | return [$parameter]; 74 | } 75 | 76 | return str_getcsv($parameter); 77 | } 78 | 79 | /** 80 | * Normalize some shorthand notations 81 | */ 82 | private static function normalizeRuleName(string $name): string 83 | { 84 | switch ($name) { 85 | case 'int': 86 | return Rule::RULE_INTEGER; 87 | case 'bool': 88 | return Rule::RULE_BOOLEAN; 89 | default: 90 | return $name; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Utility/Arrays.php: -------------------------------------------------------------------------------- 1 | ['b' => 'c']] 21 | * 22 | * @param array $array The array to be assigned. Intentionally by reference for internal recursion 23 | * @param string[] $path The string array path to which to assign the value 24 | * @param mixed $value The value to be assigned to the array 25 | * @return array For convenience return the same array that was given 26 | * @throws InvalidArrayPathException Thrown when the given path will result in overwriting an existing non array value. 27 | */ 28 | public static function assignToPath(array &$array, array $path, $value): array 29 | { 30 | if (count($path) === 0) { 31 | throw new InvalidArgumentException("\$path can't be empty"); 32 | } 33 | 34 | // reached the tail, try to assign the value 35 | /** @var string $key */ 36 | $key = array_shift($path); 37 | if (count($path) === 0) { 38 | if (array_key_exists($key, $array)) { 39 | throw new InvalidArrayPathException(sprintf(self::ERROR_ALREADY_ASSIGNED, gettype($array), implode('.', $path))); 40 | } 41 | $array[$key] = $value; 42 | return $array; 43 | } 44 | 45 | // create child array if necessary to build the path 46 | if (array_key_exists($key, $array) === false) { 47 | $array[$key] = []; 48 | } elseif (is_array($array[$key]) === false) { 49 | throw new InvalidArrayPathException(sprintf(self::ERROR_NOT_ARRAY, gettype($array), implode('.', $path))); 50 | } 51 | 52 | // continue assigned the value 53 | self::assignToPath($array[$key], $path, $value); 54 | return $array; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Utility/InvalidArrayPathException.php: -------------------------------------------------------------------------------- 1 |