├── .gitattributes ├── .githooks └── pre-commit ├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Parsing ├── Compound │ ├── CompoundRuleParser.php │ └── Tokens │ │ ├── Operand.php │ │ ├── TokenCompoundOperand.php │ │ ├── TokenOperator.php │ │ └── TokenSimpleOperand.php ├── Parser.php ├── Problems │ ├── Eof.php │ ├── NonEmptyPostfix.php │ ├── NotLexeme.php │ ├── ParsingFailed.php │ └── UnexpectedCharacter.php └── Simple │ ├── SimpleRuleParser.php │ └── Tokens │ ├── TokenNullableRule.php │ ├── TokenQuantifier.php │ ├── TokenRule.php │ └── TokenSubRule.php └── Validation ├── ArrayValidator.php ├── Matcher ├── Result.php └── TokensMatcher.php ├── Problems ├── ArrayFailedValidation.php ├── DataKeyMatchedNoPatternKey.php ├── DataValueMatchedNoPattern.php └── StringValidationFailed.php ├── Rules ├── Library │ ├── RuleAny.php │ ├── RuleArray.php │ ├── RuleBool.php │ ├── RuleExact.php │ ├── RuleNumber.php │ ├── RuleObject.php │ └── RuleString.php ├── Problems │ ├── RuleFailed.php │ ├── RuleNotRecognized.php │ └── SubRuleNotRecognized.php ├── Rule.php └── RuleLocator.php ├── StringValidator.php ├── Validator.php └── ValidatorBuilder.php /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/ export-ignore 2 | Example/ export-ignore 3 | docker/ export-ignore 4 | develop export-ignore 5 | docker-compose.yml export-ignore 6 | .travis.yml export-ignore 7 | pasvl.jpg export-ignore 8 | phpcs.xml export-ignore 9 | phpunit.xml export-ignore 10 | composer-unused.phar export-ignore 11 | .phan export-ignore 12 | phan.phar export-ignore -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "[PRE-COMMIT] Started..." 4 | 5 | ######################### 6 | # # 7 | # Initializing # 8 | # # 9 | ######################### 10 | PHPCS_BIN="./develop php ./vendor/bin/phpcs" 11 | PHPCS_LOG=./.qa-report.txt 12 | PHPCBF_BIN="./develop php ./vendor/bin/phpcbf" 13 | UNUSED_COMPOSER="./develop php composer-unused.phar --no-progress --no-cache" 14 | 15 | ######################### 16 | # # 17 | # Starting # 18 | # # 19 | ######################### 20 | 21 | # CHECK COMPOSER 22 | ${UNUSED_COMPOSER} 23 | if [ $? != 0 ] 24 | then 25 | echo "[PRE-COMMIT] Composer has unused deps" 26 | exit 1 27 | fi 28 | 29 | 30 | # All files in staging area (no deletions) 31 | 32 | PROJECT=$(git rev-parse --show-toplevel) 33 | FILES=$(git diff --cached --name-only --diff-filter=ACMR HEAD | grep .php) 34 | 35 | if [ "$FILES" != "" ] 36 | then 37 | # Coding Standards 38 | 39 | echo "[PRE-COMMIT] Checking PHPCS..." 40 | 41 | # You can change your PHPCS command here 42 | ${PHPCS_BIN} &> /dev/null 43 | 44 | if [ $? != 0 ] 45 | then 46 | echo "[PRE-COMMIT] Coding standards errors have been detected." 47 | echo "[PRE-COMMIT] Running PHP Code Beautifier and Fixer..." 48 | 49 | # Attempt to fix issues automatically 50 | ${PHPCBF_BIN} &> /dev/null 51 | 52 | echo "[PRE-COMMIT] Checking PHPCS again..." 53 | 54 | # Check again if all issues are resolved 55 | ${PHPCS_BIN} --report-file=${PHPCS_LOG} 56 | 57 | if [ $? != 0 ] 58 | then 59 | echo "[PRE-COMMIT] PHP Code Beautifier and Fixer wasn't able to solve all problems." 60 | echo "[PRE-COMMIT] See log at ${PHPCS_LOG}" 61 | exit 1 62 | fi 63 | 64 | echo "[PRE-COMMIT] All errors are fixed automatically." 65 | 66 | # stage and commit any changed files 67 | STAGED_FILES=$(git diff --name-only --diff-filter=ACMR HEAD) 68 | git add ${STAGED_FILES} 69 | else 70 | echo "[PRE-COMMIT] No errors found." 71 | fi 72 | else 73 | echo "[PRE-COMMIT] No files changed." 74 | fi 75 | 76 | exit $? 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | .idea/ 4 | /notes 5 | .qa-report.txt 6 | /storage 7 | .phpunit* 8 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dmitriy Lezhnev 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 | [![Latest Stable Version](https://poser.pugx.org/lezhnev74/pasvl/v/stable)](https://packagist.org/packages/lezhnev74/pasvl) 2 | [![Build Status](https://travis-ci.org/lezhnev74/pasvl.svg?branch=master)](https://travis-ci.org/lezhnev74/pasvl) 3 | [![Total Downloads](https://poser.pugx.org/lezhnev74/pasvl/downloads)](https://packagist.org/packages/lezhnev74/pasvl) 4 | [![License](https://poser.pugx.org/lezhnev74/pasvl/license)](https://packagist.org/packages/lezhnev74/pasvl) 5 | 6 | # PASVL - PHP Array Structure Validation Library 7 | 8 | Think of a regular expression `[ab]+` which matches a string `abab`. Now imaging the same for arrays. 9 | 10 | The purpose of this library is to validate an existing (nested) array against a template and report a mismatch. 11 | It has the object-oriented extendable architecture to write and add custom validators. 12 | 13 | **Note to current users**: this version is not backwards compatible with the previous 0.5.6. 14 | 15 | ## Installation 16 | ``` 17 | composer require lezhnev74/pasvl 18 | ``` 19 | 20 | ## Example 21 | 22 | Refer to files in `Example` folder. 23 | 24 | ## Usage 25 | 26 | ### Array Validation 27 | ```php 28 | // Define the pattern of the data, define keys and values separately 29 | $pattern = [ 30 | '*' => [ 31 | 'type' => 'book', 32 | 'title' => ':string :contains("book")', 33 | 'chapters' => [ 34 | ':string :len(2) {1,3}' => [ 35 | 'title' => ':string', 36 | ':exact("interesting") ?' => ':bool', 37 | ], 38 | ], 39 | ], 40 | ]; 41 | 42 | // Provide the data to match against the above pattern. 43 | $data = [ 44 | [ 45 | 'type' => 'book', 46 | 'title' => 'Geography book', 47 | 'chapters' => [ 48 | 'eu' => ['title' => 'Europe', 'interesting' => true], 49 | 'as' => ['title' => 'America', 'interesting' => false], 50 | ], 51 | ], 52 | [ 53 | 'type' => 'book', 54 | 'title' => 'Foreign languages book', 55 | 'chapters' => [ 56 | 'de' => ['title' => 'Deutsch'], 57 | ], 58 | ], 59 | ]; 60 | 61 | $builder = \PASVL\Validation\ValidatorBuilder::forArray($pattern); 62 | $validator = $builder->build(); 63 | try { 64 | $validator->validate($data); 65 | } catch (ArrayFailedValidation $e) { 66 | // If data cannot be matched against the pattern, then exception is thrown. 67 | // It is not always easy to detect why the data failed matching, the exception MAY sometimes give you extra hints. 68 | echo "failed: " . $e->getMessage() . "\n"; 69 | } 70 | ``` 71 | 72 | ### Optional String Validation 73 | ```php 74 | $pattern = ":string :regexp('#^[ab]+$#')"; 75 | $builder = \PASVL\Validation\ValidatorBuilder::forString($pattern); 76 | $validator = $builder->build(); 77 | $validator->validate("abab"); // the string is valid 78 | $validator->validate("abc"); // throws RuleFailed exception with the message: "string does not match regular expression ^[ab]+$" 79 | ``` 80 | 81 | ## Validation Language 82 | This package supports a special dialect for validation specification. 83 | It looks like this: 84 | 85 | ![](pasvl.jpg) 86 | 87 | #### Short language reference: 88 | - **Rule Name** 89 | Specify zero or one Rule Name to apply to the data. Optinal postfix `?` allows data to be `null`. 90 | Refer to the set of built-in rules in `src/Validation/Rules/Library`. For custom rules read below under `Custom Rules`. 91 | For example, `:string?` describes strings and `null`. 92 | - **Sub-Rule Name** 93 | Specify zero or more Sub-Rule Names to apply to the data AFTER the Rule is applied. Sub Rules are extra methods of the main Rule. 94 | For example, `:number :float` describes floats. 95 | - **Quantifier** 96 | Specify quantity expectations for data keys. If none is set then default is assumed - `!`. 97 | Available quantifiers: 98 | - `!` - one key required (default) 99 | - `?` - optional key 100 | - `*` - any count of keys 101 | - `{2}` - strict keys count 102 | - `{2,4}` - range of keys count 103 | 104 | For example: 105 | ```php 106 | $pattern = [":string *" => ":number"]; 107 | // the above pattern matches data: 108 | $data = ["june"=>10, "aug" => "11"]; 109 | ``` 110 | 111 | #### Pattern Definitions 112 | - as exact value 113 | ```php 114 | $pattern = ["name" => ":any"]; // here the key is the exact value 115 | $pattern = ["name?" => ":any"]; // here the key is the exact value, can be absent as well 116 | $pattern = [":exact('name')" => ":any"]; // this is the same 117 | ``` 118 | - as nullable rule 119 | ```php 120 | $pattern = ["name" => ":string?"]; // the value must be a string or null 121 | ``` 122 | - as rule with subrules 123 | ```php 124 | $pattern = ["name" => ":string :regexp('#\d*#')"]; // the value must be a string which contains only digits 125 | ``` 126 | - as rule with quantifiers 127 | ```php 128 | $pattern = [":string {2}" => ":any"]; // data must have exactly two string keys 129 | ``` 130 | 131 | #### Compound Definitions 132 | This package supports combinations of rules, expressed in a natural language. 133 | Examples: 134 | - `:string or :number` 135 | - `:string and :number` 136 | - `(:string and :number) or :array` 137 | 138 | There are two combination operators: `and`, `or`. 139 | `and` operator has precedence. 140 | Both are left-associative. 141 | 142 | ## Custom Rules 143 | By default, the system uses only the built-in rules. However you can extend them with your own implementations. 144 | To add new custom rules, follow these steps: 145 | - implement your new rule as a class and extend it from `\PASVL\Validation\Rules\Rule` 146 | - implement a new rule locator by extending a class `\PASVL\Validation\Rules\RuleLocator` 147 | - configure your validator like this: 148 | ```php 149 | $builder = ValidatorBuilder::forArray($pattern)->withLocator(new MyLocator()); // set your new locator 150 | $validator = $builder->build(); 151 | ``` 152 | 153 | ## Built-in Rules 154 | This package comes with a few built-in rules and their corresponding sub-rules (see in folder `src/Validation/Rules/Library`): 155 | - `:string` - the value must be string 156 | - `:regexp()` - provide a regular expression(the same as for `preg_match()`) 157 | - `:url` 158 | - `:email` 159 | - `:uuid` 160 | - `:contains()` 161 | - `:starts()` 162 | - `:ends()` 163 | - `:in(,,...)` 164 | - `:len()` 165 | - `:max()` 166 | - `:min()` 167 | - `:between(,)` 168 | - `:number` 169 | - `:max()` 170 | - `:min()` 171 | - `:between(, )` 172 | - `:int` - the number must be an integer 173 | - `:float` - the number must be a float 174 | - `:positive` 175 | - `:negative` 176 | - `:in(,,)` - the number must be within values (type coercion possible) 177 | - `:inStrict(,,)` - the number must be within values (type coercion disabled) 178 | - `:exact()` 179 | - `:bool()` - the value must be boolean, if optional argument is given the value must be exactly it 180 | - `:object` 181 | - `:instance()` 182 | - `:propertyExists()` 183 | - `:methodExists()` 184 | - `:array` 185 | - `:count()` 186 | - `:keys(,,...)` 187 | - `:min()` - min count 188 | - `:max()` - max count 189 | - `:between(, )` - count must be within 190 | - `:any` - a placeholder, any value will match 191 | 192 | ## Hints 193 | - PHP casts "1" to 1 for array keys: 194 | ```php 195 | $data = ["12" => ""]; 196 | $pattern_invalid = [":string" => ""]; 197 | $pattern_valid = [":number :int" => ""]; 198 | ``` 199 | - Technically speaking PASVL is a non-deterministic backtracking parser, and thus it can't always show you what exact key did not match the pattern. That is because, say, a key can match different patterns and there is no way of knowing which one was meant to be correct. In such cases it returns a message like "no matches found at X level". 200 | 201 | ## 🏆 Contributors 202 | - **[Greg Corrigan](https://github.com/corrigang)**. Greg spotted a problem with nullable values reported as invalid. 203 | - **Henry Combrinck**. Henry tested the library extensively on real data and found tricky bugs and edge cases. Awesome contribution to make the package valuable to the community. 204 | - **[@Averor](https://github.com/Averor)**. Found a bug in parentheses parsing. 205 | - **[Julien Gidel](https://github.com/JuGid)**. Improved `regexp` sub-rule. 206 | 207 | ## License 208 | This project is licensed under the terms of the MIT license. 209 | 210 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lezhnev74/pasvl", 3 | "description": "Array Validator (regular expressions for nested array, sort of)", 4 | "keywords": [ 5 | "validation", 6 | "validator", 7 | "array", 8 | "structure", 9 | "pattern" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Dmitriy Lezhnev", 14 | "email": "lezhnev.work@gmail.com" 15 | } 16 | ], 17 | "license": "MIT", 18 | "require": { 19 | "ext-mbstring": "*" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "^7.0|^8.0|^9.0", 23 | "roave/security-advisories": "dev-master", 24 | "squizlabs/php_codesniffer": "^3.5" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "PASVL\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "PASVL\\Tests\\": "tests/" 34 | } 35 | }, 36 | "scripts": { 37 | "init-git-hook": [ 38 | "ln -s -f ../../.githooks/pre-commit .git/hooks/pre-commit", 39 | "chmod +x .git/hooks/pre-commit" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Parsing/Compound/CompoundRuleParser.php: -------------------------------------------------------------------------------- 1 | ) or ()", "() and (() or ())" and "()" 20 | */ 21 | class CompoundRuleParser extends Parser 22 | { 23 | const STATE_OPERAND = 2; 24 | const STATE_OPERATOR = 3; 25 | 26 | public function parse(string $text, int $strategy = self::STRATEGY_STRICT): array 27 | { 28 | $tokens = parent::parse($text, $strategy); 29 | return count($tokens) > 1 ? [TokenCompoundOperand::make($tokens)] : $tokens; 30 | } 31 | 32 | protected function getNextToken() 33 | { 34 | switch ($this->state) { 35 | case self::STATE_START: 36 | case self::STATE_OPERAND: 37 | $token = $this->buildOperandToken(); 38 | $this->state = self::STATE_OPERATOR; 39 | return $token; 40 | case self::STATE_OPERATOR: 41 | try { 42 | $token = $this->buildOperatorToken(); 43 | $this->state = self::STATE_OPERAND; 44 | return $token; 45 | } catch (NotLexeme $e) { 46 | // no operator means the operand is unary 47 | $this->state = self::STATE_FINISH; 48 | return $this->getNextToken(); 49 | } 50 | case self::STATE_FINISH: 51 | if (strlen($this->remainder())) { 52 | throw new NonEmptyPostfix($this->pos); 53 | } 54 | throw new Eof(); 55 | default: 56 | throw new ParsingFailed("unexpected state reached"); 57 | } 58 | } 59 | 60 | private function buildOperatorToken(): TokenOperator 61 | { 62 | $this->skipSpaces(); 63 | try { 64 | [, $lexeme] = $this->expectAny(["or", "and"]); 65 | } catch (UnexpectedCharacter $e) { 66 | throw new NotLexeme($this->pos); 67 | } 68 | if ($lexeme == "or") { 69 | return TokenOperator::make(TokenOperator::OPERATOR_OR); 70 | } 71 | return TokenOperator::make(TokenOperator::OPERATOR_AND); 72 | } 73 | 74 | private function buildOperandToken() 75 | { 76 | $token = null; 77 | $this->skipSpaces(); 78 | $nextSymbol = $this->select([ 79 | "\(", // compound start 80 | ":", // simple rule start 81 | // quantifiers start: 82 | '\+', 83 | '!', 84 | '\*', 85 | '\?', 86 | '{', 87 | ]); 88 | switch ($nextSymbol) { 89 | case ":": 90 | case "\+": 91 | case "!": 92 | case "\*": 93 | case "\?": 94 | case "{": 95 | $p = new SimpleRuleParser(); 96 | $ruleTokens = $p->parse($this->remainder(), SimpleRuleParser::STRATEGY_ALLOW_POSTFIX); 97 | $token = TokenSimpleOperand::make($ruleTokens); 98 | $this->move($p->pos); 99 | break; 100 | case "\(": 101 | $this->move(); 102 | $p = new CompoundRuleParser(); 103 | $tokens = $p->parse($this->remainder(), CompoundRuleParser::STRATEGY_ALLOW_POSTFIX); 104 | $token = count($tokens) > 1 ? TokenCompoundOperand::make($tokens) : $tokens[0]; 105 | $this->move($p->pos); 106 | $this->skipSpaces(); 107 | $this->expect("\)"); 108 | break; 109 | } 110 | 111 | return $token; 112 | } 113 | } -------------------------------------------------------------------------------- /src/Parsing/Compound/Tokens/Operand.php: -------------------------------------------------------------------------------- 1 | tokens = $tokens; 15 | } 16 | 17 | public static function make(array $tokens): self 18 | { 19 | // normalize 20 | if (count($tokens) === 1 && $tokens[0] instanceof self) { 21 | return $tokens[0]; 22 | } 23 | 24 | return new self($tokens); 25 | } 26 | 27 | public function tokens(): array 28 | { 29 | return $this->tokens; 30 | } 31 | 32 | public function equals($other): bool 33 | { 34 | if (!$other instanceof $this) { 35 | return false; 36 | } 37 | 38 | if (count($this->tokens) !== count($other->tokens)) { 39 | return false; 40 | } 41 | 42 | foreach ($this->tokens as $i => $t) { 43 | if (!$t->equals($other->tokens[$i])) { 44 | return false; 45 | } 46 | } 47 | 48 | return true; 49 | } 50 | } -------------------------------------------------------------------------------- /src/Parsing/Compound/Tokens/TokenOperator.php: -------------------------------------------------------------------------------- 1 | operator = $operator; 22 | } 23 | 24 | public static function make(int $o): self 25 | { 26 | return new self($o); 27 | } 28 | 29 | public function isOr(): bool 30 | { 31 | return $this->operator === self::OPERATOR_OR; 32 | } 33 | 34 | public function isAnd(): bool 35 | { 36 | return $this->operator === self::OPERATOR_AND; 37 | } 38 | 39 | public function equals($other): bool 40 | { 41 | if (!$other instanceof $this) { 42 | return false; 43 | } 44 | 45 | return $this->operator === $other->operator; 46 | } 47 | } -------------------------------------------------------------------------------- /src/Parsing/Compound/Tokens/TokenSimpleOperand.php: -------------------------------------------------------------------------------- 1 | tokens = $tokens; } 13 | 14 | public static function make(array $tokens): self 15 | { 16 | return new self($tokens); 17 | } 18 | 19 | public function equals($other): bool 20 | { 21 | if (!$other instanceof $this) { 22 | return false; 23 | } 24 | 25 | if (count($this->tokens) !== count($other->tokens)) { 26 | return false; 27 | } 28 | 29 | foreach ($this->tokens as $i => $t) { 30 | if (!$t->equals($other->tokens[$i])) { 31 | return false; 32 | } 33 | } 34 | 35 | return true; 36 | } 37 | 38 | public function tokens(): array 39 | { 40 | return $this->tokens; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Parsing/Parser.php: -------------------------------------------------------------------------------- 1 | text = $text; 40 | $this->pos = 0; 41 | $this->state = self::STATE_START; 42 | $this->strategy = $strategy; 43 | 44 | $tokens = []; 45 | try { 46 | while (true) { 47 | $tokens[] = $this->getNextToken(); 48 | } 49 | } catch (Eof $e) { 50 | } catch (NonEmptyPostfix $e) { 51 | // Sometimes we want to parse until possible, and return what was parsed 52 | if ($this->strategy & self::STRATEGY_STRICT) throw $e; 53 | } 54 | 55 | return $tokens; 56 | } 57 | 58 | /** 59 | * Returns the next token instance or null of no more tokens available 60 | * 61 | * @throws UnexpectedCharacter 62 | * @throws NonEmptyPostfix 63 | * @throws ParsingFailed 64 | * @throws Eof when no more tokens available 65 | */ 66 | abstract protected function getNextToken(); 67 | 68 | /** 69 | * Matches the prefix of the input text to the $pattern and if matched: 70 | * - returns the matched prefix of the text 71 | * - advances the position to the end of the matched prefix 72 | * 73 | * @param string[] $pattern 74 | * @throws UnexpectedCharacter 75 | * @returns array [$matchedPattern, $matchedPrefix], if $pattern has groups in it, $lexeme 76 | * will be an array of captured groups: pattern: "a(b)", lexeme: [0=>ab, 1=>b] 77 | */ 78 | protected function expectAny(array $patterns): array 79 | { 80 | $matchedPattern = null; 81 | foreach ($patterns as $p) { 82 | if (preg_match("#^$p#u", $this->remainder(), $match)) { 83 | $matchedPattern = $p; 84 | break; 85 | } 86 | } 87 | 88 | if (!$matchedPattern) $this->fail(); 89 | 90 | $this->move(mb_strlen($match[0])); 91 | 92 | $lexeme = $match[0]; 93 | if (count($match) > 1) { 94 | $lexeme = $match; 95 | } 96 | return [$matchedPattern, $lexeme]; 97 | } 98 | 99 | /** 100 | * Matches the prefix of the input text to the $pattern and if matched: 101 | * - returns the matched prefix of the text 102 | * - advances the position to the end of the matched prefix 103 | * 104 | * @throws UnexpectedCharacter 105 | * @returns string of matched prefix (empty string means nothing to match) 106 | */ 107 | protected function expect(string $pattern): string 108 | { 109 | if (!preg_match("#^$pattern#u", $this->remainder(), $match)) $this->fail(); 110 | 111 | $this->move(mb_strlen($match[0])); 112 | return $match[0]; 113 | } 114 | 115 | /** 116 | * return the first matching pattern against the current position, 117 | * it DOES NOT move the cursor 118 | * @var string[] $patterns 119 | */ 120 | protected function select(array $patterns): string 121 | { 122 | $matchedPattern = null; 123 | foreach ($patterns as $p) { 124 | if (preg_match("#^$p#u", $this->remainder(), $match)) return $p; 125 | } 126 | 127 | $this->fail(); 128 | } 129 | 130 | protected function cur(): string 131 | { 132 | if (!isset($this->text[$this->pos])) { 133 | throw new Eof(); 134 | } 135 | return $this->text[$this->pos]; 136 | } 137 | 138 | /** Read next $n symbols, if less symbols are available, then return everything from the cur pos to the end of text */ 139 | protected function nextSymbols(int $n): string { return substr($this->remainder(), 0, $n); } 140 | 141 | protected function move(int $chars = 1): void 142 | { 143 | $this->pos += $chars; 144 | } 145 | 146 | protected function skipSpaces(): void 147 | { 148 | try { 149 | $this->expect(self::PATTERN_SPACES); 150 | } catch (UnexpectedCharacter $e) { 151 | // no spaces found? good :) 152 | } 153 | } 154 | 155 | /** 156 | * @return string literal 157 | */ 158 | protected function readStringLiteralUntil(string $stopSymbol): string 159 | { 160 | $argumentValue = ""; 161 | 162 | try { 163 | while ($this->cur() !== $stopSymbol) { 164 | if ($this->nextSymbols(2) === "\\" . $stopSymbol) { 165 | // escape sequence found 166 | $this->move(); // \ 167 | $argumentValue .= $stopSymbol; 168 | } else { 169 | $argumentValue .= $this->cur(); 170 | } 171 | $this->move(); 172 | } 173 | } catch (Eof $e) { 174 | // literal should not end until stop symbol found 175 | $this->fail(); 176 | } 177 | $this->move(); 178 | 179 | return $argumentValue; 180 | } 181 | 182 | protected function remainder(): string 183 | { 184 | return substr($this->text, $this->pos); 185 | } 186 | 187 | /** @throws UnexpectedCharacter */ 188 | protected function fail(string $message = ""): void 189 | { 190 | throw new UnexpectedCharacter($this->pos, $this->remainder(), $this->text); 191 | } 192 | } -------------------------------------------------------------------------------- /src/Parsing/Problems/Eof.php: -------------------------------------------------------------------------------- 1 | pos = $pos; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Parsing/Problems/NotLexeme.php: -------------------------------------------------------------------------------- 1 | pos = $pos; 19 | } 20 | } -------------------------------------------------------------------------------- /src/Parsing/Problems/ParsingFailed.php: -------------------------------------------------------------------------------- 1 | pos = $pos; 18 | $this->characters = $characters; 19 | } 20 | } -------------------------------------------------------------------------------- /src/Parsing/Simple/SimpleRuleParser.php: -------------------------------------------------------------------------------- 1 | state) { 40 | case self::STATE_RULE: 41 | try { 42 | $token = $this->buildRuleToken(self::TOKEN_RULE); 43 | $this->state = self::STATE_SUB_RULE; 44 | return $token; 45 | } catch (NotLexeme | Eof $e) { 46 | $this->state = self::STATE_SUB_RULE; 47 | return $this->getNextToken(); 48 | } 49 | case self::STATE_SUB_RULE: 50 | try { 51 | return $this->buildRuleToken(self::TOKEN_SUBRULE); 52 | } catch (NotLexeme | Eof $e) { 53 | $this->state = self::STATE_QUANTIFIER; 54 | return $this->getNextToken(); 55 | } 56 | case self::STATE_QUANTIFIER: 57 | try { 58 | $token = $this->buildQuantifierToken(); 59 | $this->state = self::STATE_FINISH; 60 | return $token; 61 | } catch (NotLexeme | Eof $e) { 62 | $this->state = self::STATE_FINISH; 63 | return $this->getNextToken(); 64 | } 65 | case self::STATE_FINISH: 66 | if (isset($this->text[$this->pos])) { 67 | throw new NonEmptyPostfix($this->pos); 68 | } 69 | throw new Eof(); 70 | default: 71 | throw new ParsingFailed("unexpected state reached"); 72 | } 73 | } 74 | 75 | /** 76 | * TokenRule and TokenSubRule are parsed identically 77 | * @return TokenRule 78 | */ 79 | private function buildRuleToken(bool $buildRule = true) 80 | { 81 | $tokenType = $buildRule ? TokenRule::class : TokenSubRule::class; 82 | 83 | // 1. read Identifier lexeme 84 | $this->skipSpaces(); 85 | try { 86 | $this->expect(":"); 87 | } catch (UnexpectedCharacter $e) { 88 | throw new NotLexeme($this->pos); 89 | } 90 | 91 | $idLexeme = $this->expect(self::PATTERN_IDENTIFIER); 92 | try { 93 | $this->expect('\?'); 94 | $tokenType = TokenNullableRule::class; 95 | } catch (UnexpectedCharacter $e) { 96 | // 97 | } finally { 98 | if (($tokenType === TokenNullableRule::class)) { 99 | if (!$buildRule) $this->fail(); 100 | if ($this->strategy & self::STRATEGY_DISABLE_NULLABLE) $this->fail(); 101 | } 102 | } 103 | 104 | $token = call_user_func([$tokenType, 'make'], $idLexeme, []); 105 | 106 | // 2. read optional Arguments lexeme 107 | try { 108 | if ($this->cur() !== "(") return $token; 109 | } catch (Eof $e) { 110 | return $token; 111 | } 112 | 113 | $this->expect("\("); 114 | $this->skipSpaces(); 115 | 116 | $expectArgument = true; 117 | 118 | while ( 119 | [$matchedPattern, $lexeme] = $this->expectAny([ 120 | "\)", 121 | ",", 122 | '"', 123 | "'", 124 | self::PATTERN_NUMBER, 125 | ]) 126 | ) { 127 | switch ($matchedPattern) { 128 | case "\)": 129 | if ($expectArgument) $this->fail(); 130 | return $token; 131 | case ",": 132 | if ($expectArgument) $this->fail(); 133 | $this->skipSpaces(); 134 | $expectArgument = true; 135 | break; 136 | case '"': 137 | case "'": 138 | if (!$expectArgument) $this->fail(); 139 | $lexeme = $this->readStringLiteralUntil($matchedPattern); 140 | $token = call_user_func( 141 | [$tokenType, 'make'], 142 | $token->name(), 143 | array_merge($token->arguments(), [$lexeme]) 144 | ); 145 | $expectArgument = false; 146 | break; 147 | case self::PATTERN_NUMBER: 148 | if (!$expectArgument) $this->fail(); 149 | //convert the number to proper type 150 | $number = (float)$lexeme; 151 | if ($number == intval($number)) $number = intval($number); 152 | 153 | $token = call_user_func( 154 | [$tokenType, 'make'], 155 | $token->name(), 156 | array_merge($token->arguments(), [$number]) 157 | ); 158 | $expectArgument = false; 159 | break; 160 | default: 161 | $this->fail(); 162 | } 163 | } 164 | } 165 | 166 | private function buildQuantifierToken(): TokenQuantifier 167 | { 168 | try { 169 | [$matchedPattern, $lexeme] = $this->expectAny([ 170 | '\+', 171 | '!', 172 | '\*', 173 | '\?', 174 | '{(\d+)}', 175 | '{,(\d+)}', 176 | '{(\d+),}', 177 | '{(\d+),(\d+)}', 178 | ]); 179 | switch ($matchedPattern) { 180 | case '\+': 181 | $min = 1; 182 | $max = PHP_INT_MAX; 183 | break; 184 | case '!': 185 | $min = 1; 186 | $max = 1; 187 | break; 188 | case '\*': 189 | $min = 0; 190 | $max = PHP_INT_MAX; 191 | break; 192 | case '\?': 193 | $min = 0; 194 | $max = 1; 195 | break; 196 | case '{(\d+)}': 197 | $min = $max = (int)$lexeme[1]; 198 | break; 199 | case '{,(\d+)}': 200 | $min = 0; 201 | $max = (int)$lexeme[1]; 202 | break; 203 | case '{(\d+),}': 204 | $min = (int)$lexeme[1]; 205 | $max = PHP_INT_MAX; 206 | break; 207 | case '{(\d+),(\d+)}': 208 | $min = (int)$lexeme[1]; 209 | $max = (int)$lexeme[2]; 210 | break; 211 | default: 212 | $this->fail(); 213 | } 214 | return TokenQuantifier::make($min, $max); 215 | } catch (UnexpectedCharacter $e) { 216 | throw new NotLexeme($this->pos); 217 | } 218 | } 219 | } -------------------------------------------------------------------------------- /src/Parsing/Simple/Tokens/TokenNullableRule.php: -------------------------------------------------------------------------------- 1 | min = $min; 17 | $this->max = $max; 18 | } 19 | 20 | public static function make(int $min, int $max): self 21 | { 22 | return new self($min, $max); 23 | } 24 | 25 | public function min(): int 26 | { 27 | return $this->min; 28 | } 29 | 30 | public function max(): int 31 | { 32 | return $this->max; 33 | } 34 | 35 | public function equals($other): bool 36 | { 37 | if (!$other instanceof $this) { 38 | return false; 39 | } 40 | 41 | return 42 | $this->min === $other->min && 43 | $this->max === $other->max; 44 | } 45 | } -------------------------------------------------------------------------------- /src/Parsing/Simple/Tokens/TokenRule.php: -------------------------------------------------------------------------------- 1 | name = $name; 17 | $this->arguments = $arguments; 18 | } 19 | 20 | public static function make(string $name, array $arguments): self 21 | { 22 | return new static($name, $arguments); 23 | } 24 | 25 | public function name(): string 26 | { 27 | return $this->name; 28 | } 29 | 30 | public function arguments(): array 31 | { 32 | return $this->arguments; 33 | } 34 | 35 | public function equals($other): bool 36 | { 37 | if (!$other instanceof $this) { 38 | return false; 39 | } 40 | 41 | return 42 | $this->name === $other->name && 43 | $this->arguments === $other->arguments; 44 | } 45 | } -------------------------------------------------------------------------------- /src/Parsing/Simple/Tokens/TokenSubRule.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 35 | } 36 | 37 | public function validate($data): void 38 | { 39 | if (!is_array($data)) throw new \RuntimeException('Data must be an array'); 40 | 41 | $this->matchValue($this->pattern, $data); 42 | } 43 | 44 | /** Match one value against the pattern */ 45 | private function matchValue($pattern, $value): void 46 | { 47 | if (is_array($pattern)) { 48 | // Compound matching 49 | if (!is_array($value)) { 50 | throw ArrayFailedValidation::make( 51 | $this->level, 52 | sprintf("Pattern describes an array while given data is %s", gettype($value)) 53 | ); 54 | } 55 | $this->validateArrayLevel($pattern, $value); 56 | } else { 57 | // Scalar matching 58 | $operand = $this->tokenize($pattern); 59 | $matcher = new TokensMatcher([$operand], $this->locator()); 60 | $result = $matcher->match($value); 61 | if (!$result->isPassed()) throw $result->reason(); 62 | } 63 | } 64 | 65 | private function validateArrayLevel(array $patternLevel, array $dataLevel): void 66 | { 67 | $matchedKeys = []; // [dataKey1 => [patternKey1,...], ...] 68 | foreach ($dataLevel as $dataKey => $dataValue) { 69 | // 1. Find pattern keys that correctly describe the given $dataKey 70 | $matchedKeys[$dataKey] = array_filter( 71 | array_keys($patternLevel), 72 | function ($patternKey) use ($dataKey) { 73 | try { 74 | $this->matchValue($patternKey, $dataKey); 75 | return true; 76 | } catch (RuleFailed $e) { 77 | return false; 78 | } 79 | } 80 | ); 81 | if (!$matchedKeys[$dataKey]) throw DataKeyMatchedNoPatternKey::fromData($dataKey, $this->level); 82 | 83 | // 2. Filter out pattern keys which corresponding pattern values do not match the given $dataValue 84 | $matchedKeys[$dataKey] = array_filter( 85 | $matchedKeys[$dataKey], 86 | function ($patternKey) use ($dataKey, $dataLevel, $patternLevel) { 87 | try { 88 | if (is_array($dataLevel[$dataKey])) $this->pushPatternLevel($patternKey); 89 | $this->matchValue($patternLevel[$patternKey], $dataLevel[$dataKey]); 90 | if (is_array($dataLevel[$dataKey])) $this->revertOneLevel(); 91 | return true; 92 | } catch (RuleFailed $e) { 93 | return false; 94 | } 95 | } 96 | ); 97 | if (!$matchedKeys[$dataKey]) throw DataValueMatchedNoPattern::fromData($dataLevel[$dataKey], $this->level); 98 | } 99 | 100 | // 3. Make combinations of data keys and pattern keys and filter out those that don't match pattern quantifiers 101 | $qualifiedCombinations = array_filter( 102 | $this->getCombinations($matchedKeys), 103 | function ($combination) { 104 | // validate this combination against patterns' quantifiers 105 | foreach (array_count_values($combination) as $patternKey => $count) { 106 | if (!$this->quantityMatch($patternKey, $count)) return false; 107 | } 108 | return true; 109 | } 110 | ); 111 | 112 | // 4. So far we checked that all given data matches given patterns, but if data is absent, we need to check the 113 | // other way around. Test that all given patterns describe data that is present. 114 | $patternKeyFulfillmentMap = array_fill_keys(array_keys($patternLevel), false); 115 | foreach ($patternKeyFulfillmentMap as $patternKey => $fulfillmentValue) { 116 | // if a patternKey was not selected for data key (within combinations) 117 | // and it does not have 0-quantity expectations, then this patternKey has unfulfilled expectations 118 | $patternKeyIsInCombination = false; 119 | foreach ($qualifiedCombinations as $combination) { 120 | foreach ($combination as $dataKey => $validPatternKey) { 121 | $patternKeyIsInCombination = ($validPatternKey == $patternKey); 122 | if ($patternKeyIsInCombination) break 2; 123 | } 124 | } 125 | 126 | $patternKeyWasPerspective = false; 127 | foreach ($matchedKeys as $dataKey => $perspectivePatternKeys) { 128 | if ($patternKeyWasPerspective) break; 129 | $patternKeyWasPerspective = in_array($patternKey, $perspectivePatternKeys); 130 | } 131 | 132 | $patternKeyFulfillmentMap[$patternKey] = 133 | $patternKeyIsInCombination || (!$patternKeyWasPerspective && $this->quantityMatch($patternKey, 0)); 134 | } 135 | if (array_sum($patternKeyFulfillmentMap) != count($patternKeyFulfillmentMap)) { 136 | throw ArrayFailedValidation::make($this->level, "There are pattern keys that match no data"); 137 | } 138 | } 139 | 140 | private function tokenize($pattern): Operand 141 | { 142 | if (isset($this->tokenizedPatterns[$pattern])) return $this->tokenizedPatterns[$pattern]; 143 | 144 | try { 145 | $operands = (new CompoundRuleParser())->parse((string)$pattern); 146 | if (!$operands) { 147 | $operand = $operand = TokenSimpleOperand::make([TokenRule::make('exact', [''])]); 148 | } else $operand = $operands[0]; 149 | } catch (UnexpectedCharacter $e) { 150 | if ($e->pos === 0) { 151 | // edge case, if unexpected character found in the first position, then treat this an the exact value 152 | // otherwise, user should use the pattern ":exact(':whatever :exact :value')" 153 | $operand = TokenSimpleOperand::make([TokenRule::make('exact', [$pattern])]); 154 | // extra edge case: "exact?", which is optional exact token 155 | if (is_string($pattern) && $pattern[strlen($pattern) - 1] === "?") { 156 | $operand = TokenSimpleOperand::make([ 157 | TokenRule::make('exact', [mb_substr($pattern, 0, -1)]), 158 | TokenQuantifier::make(0, 1), 159 | ]); 160 | } 161 | } else throw $e; 162 | } 163 | 164 | $this->tokenizedPatterns[$pattern] = $operand; 165 | return $operand; 166 | } 167 | 168 | private function pushPatternLevel($patternKey): void 169 | { 170 | if (is_null($patternKey)) { 171 | if (!$this->level) { 172 | // initial case, root level 173 | // do nothing; 174 | return; 175 | } 176 | throw ArrayFailedValidation::make($this->level, sprintf("Unable to go to the next level")); 177 | } 178 | 179 | $this->level[] = $patternKey; 180 | } 181 | 182 | private function revertOneLevel(): void 183 | { 184 | if (!count($this->level)) throw new ArrayFailedValidation($this->level, "Out of levels"); 185 | array_pop($this->level); 186 | } 187 | 188 | /** 189 | * Get all combinations of multiple arrays (preserves keys) 190 | * 191 | * @link https://gist.github.com/cecilemuller/4688876 192 | * 193 | * @param array $source [dataKey => [dataPatternKey,...]] 194 | * @return array 195 | */ 196 | private function getCombinations(array $source): array 197 | { 198 | $result = [[]]; 199 | foreach ($source as $property => $property_values) { 200 | $tmp = []; 201 | foreach ($result as $result_item) { 202 | foreach ($property_values as $property_value) { 203 | $tmp[] = array_merge($result_item, [$property => $property_value]); 204 | } 205 | } 206 | $result = $tmp; 207 | } 208 | return $result == [[]] ? [] : $result; 209 | } 210 | 211 | private function quantityMatch(string $pattern, int $count): bool 212 | { 213 | $operand = $this->tokenize($pattern); 214 | foreach ($operand->tokens() as $token) { 215 | if (!$token instanceof TokenQuantifier) continue; 216 | return $count >= $token->min() && $count <= $token->max(); 217 | } 218 | return $count === 1; // expected exactly one by default 219 | } 220 | } -------------------------------------------------------------------------------- /src/Validation/Matcher/Result.php: -------------------------------------------------------------------------------- 1 | value = $value; 23 | $this->causeException = $causeException; 24 | } 25 | 26 | public static function passed(): self { return new self(true, null); } 27 | 28 | public static function failed(RuleFailed $reason): self { return new self(false, $reason); } 29 | 30 | public function isPassed(): bool { return $this->value === true; } 31 | 32 | public function reason(): RuleFailed { return $this->causeException; } 33 | } -------------------------------------------------------------------------------- /src/Validation/Matcher/TokensMatcher.php: -------------------------------------------------------------------------------- 1 | tokens = $tokens; 35 | $this->rulesLocator = $rulesLocator; 36 | } 37 | 38 | /** 39 | * Test if the given $input can be described with the given tokens 40 | */ 41 | public function match($input): Result 42 | { 43 | switch ($this->state) { 44 | case self::STATE_START: 45 | $this->reduce($input); 46 | $this->state = self::STATE_AND; 47 | return $this->match($input); 48 | case self::STATE_AND: 49 | $this->evaluateOperators(TokenOperator::OPERATOR_AND); 50 | $this->state = self::STATE_OR; 51 | return $this->match($input); 52 | case self::STATE_OR: 53 | $this->evaluateOperators(TokenOperator::OPERATOR_OR); 54 | $this->state = self::STATE_FINISH; 55 | return $this->match($input); 56 | case self::STATE_FINISH: 57 | if (!count($this->tokens)) return Result::passed(); // nothing to do, ex: "" or "*" 58 | if (count($this->tokens) !== 1) throw new \RuntimeException('Unmatched count of results'); 59 | return $this->tokens[0]; 60 | default: 61 | throw new \RuntimeException('Unexpected state'); 62 | } 63 | } 64 | 65 | /** 66 | * Reduce all TokenSimpleOperand to Result recursively 67 | */ 68 | private function reduce($input): void 69 | { 70 | foreach ($this->tokens as $i => $token) { 71 | switch (get_class($token)) { 72 | case TokenSimpleOperand::class: 73 | $this->tokens[$i] = $this->matchSimpleToken($token, $input); 74 | break; 75 | case TokenCompoundOperand::class: 76 | /** @var TokenCompoundOperand $token */ 77 | $newMatcher = new self($token->tokens(), $this->rulesLocator); 78 | $this->tokens[$i] = $newMatcher->match($input); 79 | break; 80 | case TokenOperator::class: 81 | case Result::class: 82 | // noting to simplify, continue 83 | break; 84 | default: 85 | throw new \RuntimeException('Unexpected token'); 86 | } 87 | } 88 | } 89 | 90 | private function matchSimpleToken(TokenSimpleOperand $simpleOperand, $input): Result 91 | { 92 | // Simple operand can contain different sets of tokens: 93 | // 1. Rule + subrule + optional quantifier 94 | // 2. Rule + quantifier 95 | // 3. Quantifier only 96 | // We are only interested in matching against rule and subrules (if present) 97 | 98 | if ($simpleOperand->tokens()[0] instanceof TokenQuantifier) return Result::passed(); // no rule given? then do nothing (example: "*") 99 | 100 | // Prepare tokens 101 | /** @var TokenRule $ruleToken */ 102 | $ruleToken = $simpleOperand->tokens()[0]; 103 | if (is_null($input) && $ruleToken instanceof TokenNullableRule) return Result::passed(); 104 | 105 | // Locate the rule 106 | /** @var TokenSubRule[] $subRuleTokens */ 107 | $subRuleTokens = array_filter($simpleOperand->tokens(), function ($t) { return $t instanceof TokenSubRule; }); 108 | /** @var Rule $rule */ 109 | $rule = $this->rulesLocator->locate($ruleToken->name()); 110 | 111 | // Use the rule against the input 112 | try { 113 | $rule->putValue($input); 114 | call_user_func_array([$rule, 'test'], $ruleToken->arguments()); 115 | 116 | foreach ($subRuleTokens as $subRuleToken) { 117 | if (!method_exists($rule, $subRuleToken->name())) { 118 | throw new SubRuleNotRecognized($ruleToken->name(), $subRuleToken->name()); 119 | } 120 | call_user_func_array([$rule, $subRuleToken->name()], $subRuleToken->arguments()); 121 | } 122 | return Result::passed(); 123 | } catch (RuleFailed $e) { 124 | return Result::failed($e); 125 | } 126 | } 127 | 128 | private function evaluateOperators(int $opType): void 129 | { 130 | do { 131 | $this->tokens = array_values($this->tokens); 132 | $reiterate = false; 133 | foreach ($this->tokens as $i => $token) { 134 | if (!$token instanceof TokenOperator) continue; 135 | if (!$token->equals(TokenOperator::make($opType))) continue; 136 | if ($i % 2 === 0) throw new \RuntimeException('Only binary operators supported'); 137 | 138 | /** @var Result $lOperand */ 139 | $lOperand = $this->tokens[$i - 1]; 140 | /** @var Result $rOperand */ 141 | $rOperand = $this->tokens[$i + 1]; 142 | unset($this->tokens[$i - 1]); 143 | unset($this->tokens[$i + 1]); 144 | 145 | if ($token->isAnd()) { 146 | $r = $lOperand->isPassed() && $rOperand->isPassed(); 147 | } else { 148 | $r = $lOperand->isPassed() || $rOperand->isPassed(); 149 | } 150 | $this->tokens[$i] = $r ? Result::passed() : Result::failed(new RuleFailed('Compound rule has been evaluated to FAIL')); 151 | $reiterate = true; 152 | break; 153 | } 154 | } while ($reiterate); 155 | } 156 | } -------------------------------------------------------------------------------- /src/Validation/Problems/ArrayFailedValidation.php: -------------------------------------------------------------------------------- 1 | level = $level; 16 | return $i; 17 | } 18 | 19 | protected static function getLevelPostfix(array $level): string 20 | { 21 | if (!$level) return "at root level"; 22 | return sprintf("at level [%s]", implode("->", $level)); 23 | } 24 | 25 | protected static function serialize($data) 26 | { 27 | if (is_array($data)) return var_export($data, true); 28 | return $data; 29 | } 30 | } -------------------------------------------------------------------------------- /src/Validation/Problems/DataKeyMatchedNoPatternKey.php: -------------------------------------------------------------------------------- 1 | 'John']; 11 | * $pattern = ['full_name' => ':string'] 12 | */ 13 | class DataKeyMatchedNoPatternKey extends ArrayFailedValidation 14 | { 15 | /** @var mixed data that matched no given pattern */ 16 | public $failedData; 17 | 18 | public static function fromData($data, array $level) 19 | { 20 | $i = parent::make($level, sprintf("Data key [%s] matched no pattern key", $data)); 21 | $i->failedData = $data; 22 | return $i; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Validation/Problems/DataValueMatchedNoPattern.php: -------------------------------------------------------------------------------- 1 | 'John']; 11 | * $pattern = ['name' => ':integer'] 12 | */ 13 | class DataValueMatchedNoPattern extends ArrayFailedValidation 14 | { 15 | /** @var mixed data that matched no given pattern */ 16 | public $failedData; 17 | 18 | public static function fromData($data, array $level) 19 | { 20 | $i = parent::make( 21 | $level, 22 | sprintf("Data value [%s] matched no pattern", static::serialize($data)) 23 | ); 24 | $i->failedData = $data; 25 | return $i; 26 | } 27 | } -------------------------------------------------------------------------------- /src/Validation/Problems/StringValidationFailed.php: -------------------------------------------------------------------------------- 1 | value)) throw new RuleFailed("the value is not an array"); 15 | } 16 | 17 | public function count(...$args): void 18 | { 19 | if (count($args) !== 1) throw new RuleFailed("Sub-rule [%s] expects one argument", __METHOD__); 20 | $count = $args[0]; 21 | if (count($this->value) !== $count) { 22 | throw new RuleFailed( 23 | sprintf("given array must have length [%s] but it has [%s]", $count, count($this->value)) 24 | ); 25 | } 26 | } 27 | 28 | public function keys(...$args): void 29 | { 30 | foreach ($args as $expectedKey) { 31 | if (!array_key_exists($expectedKey, $this->value)) { 32 | throw new RuleFailed(sprintf("array must have keys [%s]", implode(",", $args))); 33 | } 34 | } 35 | } 36 | 37 | public function min(int $min): void 38 | { 39 | if (count($this->value) < $min) { 40 | throw new RuleFailed(sprintf("array must have at least %d items", $min)); 41 | } 42 | } 43 | 44 | public function max(int $max): void 45 | { 46 | if (count($this->value) > $max) { 47 | throw new RuleFailed(sprintf("array must have no more than %d items", $max)); 48 | } 49 | } 50 | 51 | public function between(int $min, int $max): void 52 | { 53 | $this->min($min); 54 | $this->max($max); 55 | } 56 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Library/RuleBool.php: -------------------------------------------------------------------------------- 1 | value)) throw new RuleFailed("the value is not a boolean"); 15 | 16 | // optional exact match 17 | if (count($args)) { 18 | $exact = $args[0]; 19 | if ($exact !== $this->value) { 20 | throw new RuleFailed(sprintf("the boolean does not match the exact value: %s", $this->value)); 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Library/RuleExact.php: -------------------------------------------------------------------------------- 1 | value) { 18 | throw new RuleFailed(sprintf("it does not match the exact value: %s", $this->value)); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Library/RuleNumber.php: -------------------------------------------------------------------------------- 1 | value)) throw new RuleFailed("the value is not a number"); 15 | 16 | // optional exact match 17 | if (count($args)) { 18 | $exact = $args[0]; 19 | if ($exact !== $this->value) { 20 | throw new RuleFailed(sprintf("number does not match the exact value: %s", $this->value)); 21 | } 22 | } 23 | } 24 | 25 | public function max($x): void 26 | { 27 | if ($this->value > $x) throw new RuleFailed(sprintf("the number is greater than %d", $x)); 28 | } 29 | 30 | public function int(): void 31 | { 32 | if (!is_int($this->value)) throw new RuleFailed("the number must be integer"); 33 | } 34 | 35 | public function float(): void 36 | { 37 | if (!is_float($this->value) && !is_int($this->value)) throw new RuleFailed("the number must be float"); 38 | } 39 | 40 | public function positive(): void 41 | { 42 | if ($this->value < 0) throw new RuleFailed("the number must be positive"); 43 | } 44 | 45 | public function in(...$args): void 46 | { 47 | if (!in_array($this->value, $args)) { 48 | throw new RuleFailed(sprintf("the number must within: %s", implode(',', $args))); 49 | } 50 | } 51 | 52 | public function inStrict(...$args): void 53 | { 54 | if (!in_array($this->value, $args, true)) { 55 | throw new RuleFailed(sprintf("the number must within: %s", implode(',', $args))); 56 | } 57 | } 58 | 59 | public function negative(): void 60 | { 61 | if ($this->value >= 0) throw new RuleFailed("the number must be negative"); 62 | } 63 | 64 | public function min($x): void 65 | { 66 | if ($this->value < $x) throw new RuleFailed(sprintf("the number is less than %d", $x)); 67 | } 68 | 69 | public function between(int $min, int $max): void 70 | { 71 | $this->min($min); 72 | $this->max($max); 73 | } 74 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Library/RuleObject.php: -------------------------------------------------------------------------------- 1 | value)) throw new RuleFailed("the value is not an object"); 15 | } 16 | 17 | public function instance(...$args): void 18 | { 19 | if (!count($args)) throw new RuleFailed("Sub-rule [%s] expects one argument", __METHOD__); 20 | $fqcn = $args[0]; 21 | if (!$this->value instanceof $fqcn) { 22 | throw new RuleFailed( 23 | sprintf("Object of type [%s] is not an instance of [%s]", gettype($this->value), $fqcn) 24 | ); 25 | } 26 | } 27 | 28 | public function propertyExists(string $propertyName): void 29 | { 30 | if (!property_exists($this->value, $propertyName)) { 31 | throw new RuleFailed(sprintf("object must have property %s", $propertyName)); 32 | } 33 | } 34 | 35 | public function methodExists(string $propertyName): void 36 | { 37 | if (!method_exists($this->value, $propertyName)) { 38 | throw new RuleFailed(sprintf("object must have property %s", $propertyName)); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Library/RuleString.php: -------------------------------------------------------------------------------- 1 | value)) { 15 | throw new RuleFailed("the value is not a string"); 16 | } 17 | 18 | // optional exact match 19 | if (count($args)) { 20 | $exact = $args[0]; 21 | if ($exact !== $this->value) { 22 | throw new RuleFailed(sprintf("string does not match the exact value: %s", $this->value)); 23 | } 24 | } 25 | } 26 | 27 | public function json(): void 28 | { 29 | // must have ext-json enabled 30 | json_decode($this->value); 31 | if (json_last_error()) { 32 | throw new RuleFailed(sprintf("the string is not valid JSON: [%s]", $this->value)); 33 | } 34 | } 35 | 36 | public function email(): void 37 | { 38 | if (filter_var($this->value, FILTER_VALIDATE_EMAIL) === false) { 39 | throw new RuleFailed(sprintf("the string is not valid email: [%s]", $this->value)); 40 | } 41 | } 42 | 43 | public function uuid(): void 44 | { 45 | if (!preg_match('/^[a-f\d]{8}(-[a-f\d]{4}){4}[a-f\d]{8}$/i', $this->value)) { 46 | throw new RuleFailed(sprintf("the string is not valid uuid: [%s]", $this->value)); 47 | } 48 | } 49 | 50 | public function contains(string $substring): void 51 | { 52 | if (mb_strstr($this->value, $substring) === false) { 53 | throw new RuleFailed(sprintf("the substring [%s] not found in string [%s]", $substring, $this->value)); 54 | } 55 | } 56 | 57 | public function starts(string $substring): void 58 | { 59 | if (mb_substr($this->value, 0, strlen($substring)) !== $substring) { 60 | throw new RuleFailed(sprintf("string [%s] does not start with [%s]", $this->value, $substring)); 61 | } 62 | } 63 | 64 | public function in(...$args): void 65 | { 66 | if (!in_array($this->value, $args)) { 67 | throw new RuleFailed(sprintf("string [%s] must be one of [%s]", $this->value, implode(',', $args))); 68 | } 69 | } 70 | 71 | public function ends(string $substring): void 72 | { 73 | if (mb_substr($this->value, -strlen($substring)) !== $substring) { 74 | throw new RuleFailed(sprintf("string [%s] does not end with [%s]", $this->value, $substring)); 75 | } 76 | } 77 | 78 | public function len(int $len): void 79 | { 80 | if (mb_strlen($this->value) !== $len) { 81 | throw new RuleFailed(sprintf("string must be %d characters long", $len)); 82 | } 83 | } 84 | 85 | public function max(int $len): void 86 | { 87 | if (mb_strlen($this->value) > $len) { 88 | throw new RuleFailed(sprintf("string is longer than %d characters", $len)); 89 | } 90 | } 91 | 92 | public function min(int $len): void 93 | { 94 | if (mb_strlen($this->value) < $len) { 95 | throw new RuleFailed(sprintf("string is shorter than %d characters", $len)); 96 | } 97 | } 98 | 99 | public function regexp(string $expr): void 100 | { 101 | error_clear_last(); 102 | if (@preg_match($expr, '') === false) { 103 | $lastError = error_get_last() ? error_get_last()['message'] : ''; 104 | throw new RuleFailed(sprintf("regexp is not a valid regular expression %s: %s", $expr, $lastError)); 105 | } 106 | 107 | if (!preg_match($expr, $this->value)) { 108 | throw new RuleFailed(sprintf("string does not match regular expression %s", $expr)); 109 | } 110 | } 111 | 112 | public function url(): void 113 | { 114 | if (filter_var($this->value, FILTER_VALIDATE_URL) === false) { 115 | throw new RuleFailed("string must be url"); 116 | } 117 | } 118 | 119 | public function between(int $min, int $max): void 120 | { 121 | $this->min($min); 122 | $this->max($max); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Validation/Rules/Problems/RuleFailed.php: -------------------------------------------------------------------------------- 1 | rule = $rule; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Problems/SubRuleNotRecognized.php: -------------------------------------------------------------------------------- 1 | rule = $rule; 18 | } 19 | } -------------------------------------------------------------------------------- /src/Validation/Rules/Rule.php: -------------------------------------------------------------------------------- 1 | value = $value; 17 | } 18 | 19 | /** @throws RuleFailed */ 20 | abstract public function test(...$args): void; 21 | } -------------------------------------------------------------------------------- /src/Validation/Rules/RuleLocator.php: -------------------------------------------------------------------------------- 1 | makeRule($fqcn); 18 | } 19 | 20 | private function makeRule(string $fqcn): Rule 21 | { 22 | return new $fqcn(); 23 | } 24 | } -------------------------------------------------------------------------------- /src/Validation/StringValidator.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 26 | 27 | // Edge case: exact match 28 | if (!in_array(trim($pattern)[0], [":", "("])) { 29 | $this->tokenizedPattern = TokenSimpleOperand::make([TokenRule::make('exact', [$pattern])]); 30 | } else { 31 | $this->tokenizedPattern = (new CompoundRuleParser())->parse($pattern)[0]; 32 | } 33 | } 34 | 35 | public function validate($data): void 36 | { 37 | $matcher = new TokensMatcher([$this->tokenizedPattern], $this->locator()); 38 | $r = $matcher->match($data); 39 | if (!$r->isPassed()) { 40 | throw new StringValidationFailed($r->reason()->getMessage(), $r->reason()->getCode(), $r->reason()); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/Validation/Validator.php: -------------------------------------------------------------------------------- 1 | locator = $locator; } 15 | 16 | /** @throws \InvalidArgumentException */ 17 | abstract public function validate($data): void; 18 | 19 | protected function locator(): RuleLocator { return $this->locator; } 20 | } -------------------------------------------------------------------------------- /src/Validation/ValidatorBuilder.php: -------------------------------------------------------------------------------- 1 | ruleLocator = new RuleLocator(); 23 | } 24 | 25 | public function withLocator(RuleLocator $locator): self 26 | { 27 | $this->ruleLocator = $locator; 28 | return $this; 29 | } 30 | 31 | public function build(): Validator 32 | { 33 | switch ($this->mode) { 34 | case self::MODE_STRING: 35 | return new StringValidator($this->ruleLocator, $this->pattern); 36 | case self::MODE_ARRAY: 37 | return new ArrayValidator($this->ruleLocator, $this->pattern); 38 | default: 39 | throw new \RuntimeException('Unsupported mode'); 40 | } 41 | } 42 | 43 | public static function forString(string $pattern): self 44 | { 45 | $i = new self(); 46 | $i->pattern = $pattern; 47 | $i->mode = self::MODE_STRING; 48 | return $i; 49 | } 50 | 51 | public static function forArray(array $pattern): self 52 | { 53 | $i = new self(); 54 | $i->pattern = $pattern; 55 | $i->mode = self::MODE_ARRAY; 56 | return $i; 57 | } 58 | } --------------------------------------------------------------------------------