├── .github └── workflows │ └── test.yml ├── .gitignore ├── .php_cs ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src └── ArrayStructureValidator.php └── tests ├── ArrayStructureValidatorTest.php ├── TestCase.php └── bootstrap.php /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: yii2-array-structure-validator 2 | on: 3 | push: 4 | branches: [ master, dev ] 5 | pull_request: 6 | branches: [ master ] 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | 11 | jobs: 12 | test: 13 | if: "!contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, 'ci skip')" 14 | name: yii2-array-structure-validator (PHP ${{ matrix.php-versions }}) 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: true 18 | matrix: 19 | php-versions: ['7.4','7.3', '7.2', '7.1'] 20 | phpunit-versions: ['latest'] 21 | include: 22 | - php-versions: '8.0' 23 | phpunit-versions: '9.5.7' 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - name: Setup PHP, with composer and extensions 29 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php 30 | with: 31 | php-version: ${{ matrix.php-versions }} 32 | extensions: mbstring, intl, gd, imagick, zip, dom, pgsql 33 | tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }} 34 | env: 35 | update: ${{ matrix.php-version == '8.0' }} 36 | - name: Get composer cache directory 37 | id: composercache 38 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 39 | 40 | - name: Cache Composer packages 41 | id: composer-cache 42 | uses: actions/cache@v2 43 | with: 44 | path: ${{ steps.composercache.outputs.dir }} 45 | key: ${{ runner.os }}-php-${{ matrix.php-versions }}--${{ hashFiles('**/composer.json') }} 46 | restore-keys: | 47 | ${{ runner.os }}-php-${{ matrix.php-versions }}- 48 | 49 | - name: Install deps 50 | run: composer install --prefer-dist --no-progress --optimize-autoloader 51 | 52 | - name: Unit tests 53 | run: php vendor/bin/phpunit 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor/ 3 | /composer.lock 4 | /phpunit.xml 5 | /.php_cs.cache -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | in(['src']); 4 | return PhpCsFixer\Config::create() 5 | ->setFinder($finder) 6 | ->setRules([ 7 | '@PSR2' => true, 8 | 'array_syntax' => ['syntax' => 'short'], 9 | 'general_phpdoc_annotation_remove' => ['annotations' => ['author']], 10 | 'header_comment' => [ 11 | 'comment_type' => 'PHPDoc', 12 | 'header' => << and contributors 14 | @license https://github.com/insolita/yii2-array-structure-validator/blob/master/LICENSE 15 | COMMENT 16 | ] 17 | ]) 18 | ; 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #### Yii2 validator for complex array structures 2 | 3 | ![yii2-array-structure-validator](https://github.com/Insolita/yii2-array-structure-validator/workflows/yii2-array-structure-validator/badge.svg?branch=master) 4 | 5 | Validator for array attributes, unlike builtin "each" validator, that support only one rule, this validator can 6 | * validate multiple array attributes and even nested data structures 7 | * All keys that should be present in array must be described, for optional keys default value should be set 8 | * When input array not contains key defined in rules, this key added automatically with null value 9 | * When input array contains key not defined in rules, "unexpected item" error will be defined 10 | 11 | #### Installation 12 | 13 | ```composer require insolita/yii2-array-structure-validator ``` 14 | 15 | #### Usage 16 | 17 | For a simple array with known keys like `['id'=>1, 'name'=>'John Doe']`; 18 | 19 | ```php 20 | 21 | public function rules() 22 | { 23 | return [ 24 | //... 25 | ['simpleArray', ArrayStructureValidator::class, 26 | 'rules'=>[ 27 | 'id'=>[['required'], ['integer','min'=>0]], 28 | 'name'=>[['required'], ['string', 'max'=>100]], 29 | 'sex'=>[['default', 'value'=>'male'], ['in','range'=>['male','female']] 30 | ]] 31 | ], 32 | ]; 33 | } 34 | ``` 35 | 36 | For multidimensional arrays like 37 | ` 38 | [ 39 | ['id'=>1, 'name'=>'John Doe'], 40 | ['id'=>2, 'name'=>'Jane Doe','sex'=>'female'], 41 | ... 42 | ]` 43 | set each = true 44 | 45 | ```php 46 | 47 | public function rules() 48 | { 49 | return [ 50 | //... 51 | [['multiArray', 'some', 'attrs'], 'required'], 52 | ['multiArray', ArrayStructureValidator::class, 53 | 'each'=>true, 54 | 'rules'=>[ 55 | 'id'=>[['required'], ['integer','min'=>0]], 56 | 'name'=>[['required'], ['string', 'max'=>100]], 57 | 'sex'=>[['default', 'value'=>'male'], ['in','range'=>['male','female']] 58 | ]] 59 | ] 60 | ]; 61 | } 62 | ``` 63 | 64 | For nested structures like 65 | ``` 66 | [ 67 | 'user'=>['id'=>1, 'name'=>'John Doe'], 68 | 'coords'=>[['x'=>1, 'y'=>2],['x'=>3,'y'=>4]] 69 | ] 70 | ``` 71 | ```php 72 | 73 | public function rules() 74 | { 75 | return [ 76 | //... 77 | ['complexArray', ArrayStructureValidator::class, 78 | 'rules'=>[ 79 | 'user'=>[[ArrayStructureValidator::class, 80 | 'rules'=>[ 81 | 'id'=>[['required'], ['integer','min'=>0]], 82 | 'name'=>[['required'], ['string', 'max'=>100]], 83 | ]]], 84 | 'coords'=>[[ArrayStructureValidator::class, 85 | 'each'=>true, 86 | 'rules'=>[ 87 | 'x'=>[['required'], ['integer','min'=>0]], 88 | 'y'=>[['required'], ['integer','min'=>0]], 89 | ], 'min'=>1, 'max'=>5]], 90 | ], 'min'=>2, 'max'=>2] 91 | ]; 92 | } 93 | ``` 94 | 95 | Model scenarios supported 96 | 97 | ```php 98 | public function rules() 99 | { 100 | return [ 101 | //... 102 | ['conditional', ArrayStructureValidator::class, 103 | 'rules'=>[ 104 | 'a'=>[['integer','min'=>0]], //will be checked on any scenario 105 | 'b'=>[ 106 | ['default', 'value'=>1, 'on'=>['create']], 107 | ['integer', 'max'=>10, 'except'=>['create']], 108 | ['required', 'on'=>['update']], 109 | ['integer', 'max'=>1000, 'on'=>['update']], 110 | ] 111 | ] 112 | ] 113 | ]; 114 | } 115 | ``` 116 | 117 | Closure and Inline validators supported, but with signature different from default 118 | 119 | Inline method in model class 120 | 121 | ```php 122 | public function rules() 123 | { 124 | return [ 125 | ['array', ArrayStructureValidator::class, 'rules'=>[ 126 | 'item'=>[['required'], ['customValidator']] 127 | ]] 128 | ]; 129 | } 130 | 131 | public function customValidator($attribute, $model, $index, $baseModel, $baseAttribute){ 132 | /** 133 | * $model - Dynamic model with attributes equals value data, or value row, if used with each=>true 134 | * $attribute - current keyName 135 | * $index - current array index for multidimensional arrays, or null 136 | * $baseModel - instance of initial model, where validator was attached 137 | * $baseAttribute - name of initial attributed, where validator was attached 138 | 139 | * access to validated value - $model->$attribute 140 | * access to whole validated array $baseModel->$baseAttribute 141 | * $model->addError($attribute, '[{index}][{attribute}] Error message', ['index'=>$index]); 142 | */ 143 | } 144 | ``` 145 | 146 | When conditions supported (But not whenClient!) 147 | 148 | ```php 149 | 150 | public function rules() 151 | { 152 | return [ 153 | ['conditional', ArrayStructureValidator::class, 154 | 'rules'=>[ 155 | 'x'=>[['safe']], 156 | 'y'=>[ 157 | ['default', 'value'=>1, 'when'=>fn(DynamicModel $model) => $model->x < 10], 158 | [ 159 | 'default', 160 | 'value'=>5, 161 | 'when'=>function($model, $attribute, $index, $baseModel, $baseAttribute){ 162 | return count($baseModel->$baseAttribute) > 5; 163 | }], 164 | ] 165 | ]] 166 | ]; 167 | } 168 | ``` 169 | 170 | #### Note: 171 | Database related validators (exists, unique) not covered by tests yet and not supported -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "insolita/yii2-array-structure-validator", 3 | "description": "Validate array with complex structure", 4 | "type": "library", 5 | "keywords": [ 6 | "yii2", 7 | "validator" 8 | ], 9 | "require": { 10 | "php": "^7.1|^8.0", 11 | "yiisoft/yii2": "~2.0.15" 12 | }, 13 | "require-dev": { 14 | "phpunit/phpunit": "^7.0|^8.0|^9.0", 15 | "cebe/indent": "*" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "insolita\\ArrayStructureValidator\\": "src/" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "tests\\": "tests/" 25 | } 26 | }, 27 | "license": "MIT", 28 | "authors": [ 29 | { 30 | "name": "insolita", 31 | "email": "webmaster100500@ya.ru" 32 | } 33 | ], 34 | "config": { 35 | "platform": { 36 | "php": "7.1.3" 37 | } 38 | }, 39 | "repositories": [ 40 | { 41 | "type": "composer", 42 | "url": "https://asset-packagist.org" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ArrayStructureValidator.php: -------------------------------------------------------------------------------- 1 | and contributors 5 | * @license https://github.com/insolita/yii2-array-structure-validator/blob/master/LICENSE 6 | */ 7 | 8 | namespace insolita\ArrayStructureValidator; 9 | 10 | use ArrayAccess; 11 | use Closure; 12 | use Yii; 13 | use yii\base\DynamicModel; 14 | use yii\base\Model; 15 | use yii\base\NotSupportedException; 16 | use yii\validators\Validator; 17 | use function array_diff; 18 | use function array_keys; 19 | use function array_map; 20 | use function array_slice; 21 | use function compact; 22 | use function count; 23 | use function implode; 24 | use function in_array; 25 | use function is_array; 26 | use function is_object; 27 | 28 | /** 29 | * Validator for array attributes, unlike builtin "each" validator, that support only one rule, this validator can 30 | * validate multiple array attributes and even nested data structures 31 | * All keys that should be present in array must be described, for optional keys default value should be set 32 | * When input array not contains key defined in rules, this key added automatically with null value 33 | * When input array contains key not defined in rules, "unexpected item" error will be defined 34 | * @example 35 | * For a simple array with known keys like ['id'=>1, 'name'=>'John Doe']; 36 | * ['simpleArray', ArrayStructureValidator::class, 'rules'=>[ 37 | * 'id'=>[['required'], ['integer','min'=>0]], 38 | * 'name'=>[['required'], ['string', 'max'=>100]], 39 | * 'sex'=>[['default', 'value'=>'male'], ['in','range'=>['male','female']] 40 | * ]] 41 | * For multidimensional arrays like [['id'=>1, 'name'=>'John Doe'],['id'=>2, 'name'=>'Jane Doe','sex'=>'female'],..] 42 | * ['multiArray', ArrayStructureValidator::class, 'each'=>true, 'rules'=>[ 43 | * 'id'=>[['required'], ['integer','min'=>0]], 44 | * 'name'=>[['required'], ['string', 'max'=>100]], 45 | * 'sex'=>[['default', 'value'=>'male'], ['in','range'=>['male','female']] 46 | * ]] 47 | * For nested structures like ['user'=>['id'=>1, 'name'=>'John Doe'], 'coords'=>[['x'=>1, 'y'=>2],['x'=>3,'y'=>4]]] 48 | * ['complexArray', ArrayStructureValidator::class, 'rules'=>[ 49 | * 'user'=>[[ArrayStructureValidator::class, 'rules'=>[ 50 | * 'id'=>[['required'], ['integer','min'=>0]], 51 | * 'name'=>[['required'], ['string', 'max'=>100]], 52 | * ]]], 53 | * 'coords'=>[[ArrayStructureValidator::class, 'each'=>true, 'rules'=>[ 54 | * 'x'=>[['required'], ['integer','min'=>0]], 55 | * 'y'=>[['required'], ['integer','min'=>0]], 56 | * ], 'min'=>1, 'max'=>5]], 57 | * ], 'min'=>2, 'max'=>2] 58 | * Model scenarios supported 59 | * ['conditional', ArrayStructureValidator::class, 'rules'=>[ 60 | * 'a'=>[['integer','min'=>0]], //will be checked on any scenario 61 | * 'b'=>[ 62 | * ['default', 'value'=>1, 'on'=>['create']], 63 | * ['integer', 'max'=>10, 'except'=>['create']], 64 | * ['required', 'on'=>['update']], 65 | * ['integer', 'max'=>1000, 'on'=>['update']], 66 | * ] 67 | * ]] 68 | * Closure and Inline validators supported, but with another input arguments 69 | * ['array', ArrayStructureValidator::class, 'rules'=>[ 70 | * 'item'=>[['required'], ['customValidator'], 71 | * 'item2'=>[['default','value'=>''], [function($attribute, $model, $index, $baseModel, $baseAttribute){ 72 | * $model - Dynamic model with attributes equals value data, or value row, if used with each=>true 73 | * $attribute - current keyName 74 | * $index - current array index for multidimensional arrays, or null 75 | * $baseModel - instance of initial model, where validator was attached 76 | * $baseAttribute - name of initial attributed, where validator was attached 77 | * access to validated value - $model->$attribute 78 | * access to whole validated array $baseModel->$baseAttribute 79 | * $model->addError($attribute, '[{index}][{attribute}] Error message', ['index'=>$index]); 80 | * }]] 81 | * ]], 82 | * Inline method in model class 83 | * public function customValidator($attribute, $model, $index, $baseModel, $baseAttribute){ 84 | * //same as in closure 85 | * } 86 | * When conditions supported (But not whenClient!) 87 | * ['conditional', ArrayStructureValidator::class, 'rules'=>[ 88 | * 'x'=>[['safe']], 89 | * 'y'=>[ 90 | * ['default', 'value'=>1, 'when'=>function($model, $attribute){ 91 | * return $model->x > 10; 92 | * }], 93 | * ['default', 'value'=>5, 'when'=>function($model, $attribute, $index, $baseModel, $baseAttribute){ 94 | * return count($baseModel->$baseAttribute) > 5; 95 | * }], 96 | * ] 97 | * ]] 98 | **/ 99 | class ArrayStructureValidator extends Validator 100 | { 101 | public $skipOnEmpty = false; 102 | 103 | /** 104 | * Max array items number, if null - checking will be skipped 105 | * @var int 106 | */ 107 | public $max; 108 | 109 | /** 110 | * Min array items number, if null - checking will be skipped 111 | * @var int 112 | */ 113 | public $min; 114 | 115 | /** 116 | * Array ['keyName'=>[[validator],[validator2, options], ...]] 117 | * @var array 118 | * @example 119 | * [ 120 | * 'keyA'=>[['integer', 'allowEmpty'=>true]], 121 | * 'keyB'=>[['required'], ['trim'], ['string', 'min'=>5]], 122 | * 'keyC'=>[['filter', 'filter'=>function($v){ 123 | * return str_rev($v); 124 | * } 125 | * ]], 126 | * 'keyD'=>[function($attribute, $model, $index, $baseModel, $baseAttribute){ 127 | * if($model->keyA > $model->$attribute){ 128 | * $model->addError($attribute, 'Custom message'); 129 | * } 130 | * }], 131 | * ] 132 | */ 133 | public $rules = []; 134 | 135 | /** 136 | * if false, structure rules will be applied directly for array keys, if true, structure rules will be applied 137 | * for each array item 138 | * @var bool 139 | * @example 140 | * use "false", when input array should be as ['key1'=>'value1', 'key2'=>'value2'], 141 | * use "true", when input array like [['key1'=>'value1', 'key2'=>'value2'],['key1'=>'value3', 'key2'=>'value4']] 142 | */ 143 | public $each = false; 144 | 145 | /** 146 | * Allow value mutations 147 | * If false, filter, trim, default validators will be applied only inside validator layers, but value of attribute 148 | * will be not changed; Set false for keep input value as is 149 | * @var boolean 150 | */ 151 | public $mutable = true; 152 | 153 | /** 154 | * @var bool whether to stop validation once first error is detected. 155 | */ 156 | public $stopOnFirstError = true; 157 | 158 | /** 159 | * @var bool When enabled, validation will produce single error message on attribute, when disabled - multiple 160 | * error messages mya appear: one per each invalid value. 161 | */ 162 | public $compactErrors = false; 163 | 164 | /** 165 | * @var string separator for implode errors 166 | */ 167 | public $errorSeparator = "\n"; 168 | 169 | /** 170 | * @var bool 171 | * If true, each error message for multidimensional arrays will be prefixed with index of row that contains that 172 | * error, also nested keys and indexes for complex array with nested structure 173 | */ 174 | public $prefixErrors = true; 175 | 176 | /** 177 | * @var string 178 | */ 179 | public $message; 180 | 181 | /** 182 | * @var string 183 | */ 184 | public $tooSmallMessage; 185 | 186 | /** 187 | * @var string 188 | */ 189 | public $tooLargeMessage; 190 | 191 | /** 192 | * @var string 193 | */ 194 | public $invalidStructureMessage; 195 | 196 | /** 197 | * @var string 198 | */ 199 | public $unexpectedItemsMessage; 200 | 201 | /** 202 | * @internal 203 | * @var Model 204 | */ 205 | public $nested = ['baseModel' => '', 'baseAttribute' => '', 'path' => []]; 206 | 207 | public function init(): void 208 | { 209 | parent::init(); 210 | if ($this->message === null) { 211 | $this->message = Yii::t('app', '{attribute} is not array.'); 212 | } 213 | if ($this->tooSmallMessage === null) { 214 | $this->tooSmallMessage = Yii::t('app', '{attribute} is too small, it should contains at least {min} items'); 215 | } 216 | if ($this->tooLargeMessage === null) { 217 | $this->tooLargeMessage = 218 | Yii::t('app', '{attribute} is too large, it should contains more than {max} items'); 219 | } 220 | if ($this->invalidStructureMessage === null) { 221 | $this->invalidStructureMessage = Yii::t('app', '{attribute} contains invalid data'); 222 | } 223 | if ($this->unexpectedItemsMessage === null) { 224 | $this->unexpectedItemsMessage = Yii::t('app', '{attribute} contains unexpected items {items}'); 225 | } 226 | } 227 | 228 | /** 229 | * @param \yii\base\Model $model 230 | * @param string $attribute 231 | * @throws \yii\base\NotSupportedException 232 | */ 233 | public function validateAttribute($model, $attribute): void 234 | { 235 | $result = $this->validateValue($model->$attribute); 236 | if (!empty($result)) { 237 | $this->addError($model, $attribute, $result[0], $result[1] ?? []); 238 | return; 239 | } 240 | if (empty($this->rules)) { 241 | return; 242 | } 243 | $filteredValue = $model->$attribute; 244 | $baseModel = $this->nested['baseModel'] ?: $model; 245 | $baseAttribute = $this->nested['baseAttribute'] ?: $attribute; 246 | $path = $this->nested['path'] ?: []; 247 | if ($this->each === false) { 248 | $this->processHashValue($model, $attribute, $filteredValue, $baseModel, $baseAttribute, $path); 249 | } else { 250 | $this->processEachValue($model, $attribute, $filteredValue, $baseModel, $baseAttribute, $path); 251 | } 252 | } 253 | 254 | /** 255 | * @param mixed $value 256 | * @param null $error 257 | * @return bool 258 | * @throws \yii\base\NotSupportedException 259 | */ 260 | public function validate($value, &$error = null): bool 261 | { 262 | $result = $this->validateValue($value); 263 | if (!empty($result)) { 264 | $error = $this->prepareError($value, ...$result); 265 | return false; 266 | } 267 | if (empty($this->rules)) { 268 | return true; 269 | } 270 | $model = new DynamicModel(['value' => $value]); 271 | $baseModel = $this->nested['baseModel'] ?: $model; 272 | $baseAttribute = $this->nested['baseAttribute'] ?: 'value'; 273 | $path = $this->nested['path'] ?: []; 274 | if ($this->each === false) { 275 | $this->processHashValue($model, 'value', $value, $baseModel, $baseAttribute, $path); 276 | } else { 277 | $this->processEachValue($model, 'value', $value, $baseModel, $baseAttribute, $path); 278 | } 279 | if ($baseModel->hasErrors()) { 280 | $error = $this->prepareError($value, implode($this->errorSeparator, $baseModel->getErrorSummary(true)), []); 281 | return false; 282 | } 283 | return true; 284 | } 285 | 286 | protected function validateValue($value):?array 287 | { 288 | if (!is_array($value) && !$value instanceof ArrayAccess) { 289 | return [$this->message, []]; 290 | } 291 | $size = count($value); 292 | if ($this->min && $size < $this->min) { 293 | return [$this->tooSmallMessage, ['min' => $this->min, 'count' => $size]]; 294 | } 295 | if ($this->max && $size > $this->max) { 296 | return [$this->tooLargeMessage, ['max' => $this->max, 'count' => $size]]; 297 | } 298 | return null; 299 | } 300 | 301 | /** 302 | * @param string $baseAttribute 303 | * @param DynamicModel $model 304 | * @param null $index 305 | * @param \yii\base\Model|null $baseModel 306 | * @param $path 307 | * @return bool 308 | * @throws \yii\base\NotSupportedException 309 | */ 310 | private function applyValidators(string $baseAttribute, DynamicModel $model, $index, $baseModel, $path):bool 311 | { 312 | foreach ($this->rules as $attribute => $rules) { 313 | foreach ($rules as $rule) { 314 | $validator = $rule[0]; 315 | $ruleOptions = array_slice($rule, 1); 316 | $on = $ruleOptions['on'] ?? []; 317 | $except = $ruleOptions['except'] ?? []; 318 | if (!empty($except) && in_array($baseModel->scenario, $except, true)) { 319 | continue; 320 | } 321 | if (!empty($on) && !in_array($baseModel->scenario, $on, true)) { 322 | continue; 323 | } 324 | $when = $ruleOptions['when'] ?? null; 325 | if ($when !== null) { 326 | $ruleOptions['when'] = 327 | function ($model, $attribute) use ($when, $index, $baseAttribute, $baseModel) { 328 | return $when($model, $attribute, $index, $baseModel, $baseAttribute); 329 | }; 330 | } 331 | unset($ruleOptions['except'], $ruleOptions['on'], $ruleOptions['whenClient']); 332 | if ($validator instanceof Closure) { 333 | $originRule = $rule[0]; 334 | $validator = function ($attribute) use ($model, $originRule, $index, $baseAttribute, $baseModel) { 335 | $originRule($attribute, $model, $index, $baseModel, $baseAttribute); 336 | }; 337 | } elseif (!isset(static::$builtInValidators[$validator]) && $baseModel->hasMethod($validator)) { 338 | $originRule = $rule[0]; 339 | $validator = function ($attribute) use ($model, $originRule, $index, $baseAttribute, $baseModel) { 340 | $baseModel->$originRule($attribute, $model, $index, $baseModel, $baseAttribute); 341 | }; 342 | } elseif ($validator === self::class) { 343 | $ruleOptions['nested'] = compact('baseModel', 'baseAttribute', 'path'); 344 | } elseif ($validator === 'unique') { 345 | throw new NotSupportedException('Unique rule not supported with current validator'); 346 | } elseif ($this->each === true && $validator === 'exist') { 347 | throw new NotSupportedException('Avoid exist validator usage with multidimensional array'); 348 | } 349 | $model->addRule($attribute, $validator, $ruleOptions); 350 | } 351 | } 352 | $model->validate(); 353 | if (!$model->hasErrors()) { 354 | return true; 355 | } 356 | return false; 357 | } 358 | 359 | private function prepareError($value, $message, $params):string 360 | { 361 | $params['attribute'] = Yii::t('yii', 'the input value'); 362 | if (is_array($value)) { 363 | $params['value'] = 'array()'; 364 | } elseif (is_object($value)) { 365 | $params['value'] = 'object'; 366 | } else { 367 | $params['value'] = $value; 368 | } 369 | return $this->formatMessage($message, $params); 370 | } 371 | 372 | /** 373 | * @param $model 374 | * @param $attribute 375 | * @param $filteredValue 376 | * @param \yii\base\Model $baseModel 377 | * @param string $baseAttribute 378 | * @param $path 379 | * @throws \yii\base\NotSupportedException 380 | */ 381 | private function processEachValue($model, $attribute, $filteredValue, Model $baseModel, $baseAttribute, $path):void 382 | { 383 | foreach ($filteredValue as $index => $value) { 384 | $keyPath = $path; 385 | if ($attribute !== $baseAttribute) { 386 | $keyPath[] = "[$attribute]"; 387 | } 388 | $keyPath[] = "[$index]"; 389 | if (!is_array($value) && !$value instanceof ArrayAccess) { 390 | $this->addError( 391 | $baseModel, 392 | $baseAttribute, 393 | ($this->prefixErrors ? '{keyPath}' : '') . $this->message, 394 | ['keyPath' => implode('', $keyPath)] 395 | ); 396 | } 397 | $attributes = array_keys($this->rules); 398 | $existedAttributes = array_keys($value); 399 | $unexpectedItems = array_diff($existedAttributes, $attributes); 400 | 401 | if (!empty($this->rules) && !empty($unexpectedItems)) { 402 | $this->addError( 403 | $baseModel, 404 | $baseAttribute, 405 | ($this->prefixErrors ? '{keyPath}' : '') . $this->unexpectedItemsMessage, 406 | ['items' => implode(',', $unexpectedItems), 'keyPath' => implode('', $keyPath)] 407 | ); 408 | if ($this->stopOnFirstError) { 409 | break; 410 | } 411 | continue; 412 | } 413 | $missingItems = array_diff(array_keys($this->rules), array_keys($value)); 414 | foreach ($missingItems as $item) { 415 | $value[$item] = null; 416 | } 417 | $dynamicModel = new DynamicModel($value); 418 | //$dynamicModel->setScenario($baseModel->scenario); 419 | $isValid = $this->applyValidators($attribute, $dynamicModel, $index, $baseModel, $keyPath); 420 | if ($this->mutable === true) { 421 | $filteredValue[$index] = $dynamicModel->getAttributes(); 422 | $model->$attribute = $filteredValue; 423 | } 424 | if ($isValid === true) { 425 | continue; 426 | } 427 | $params = ['keyPath' => implode('', $keyPath)]; 428 | if ($this->compactErrors) { 429 | $errors = array_map( 430 | function ($err) { 431 | return ($this->prefixErrors ? '{keyPath}' : '') . $err; 432 | }, 433 | $dynamicModel->getErrorSummary(false) 434 | ); 435 | $this->addError($baseModel, $baseAttribute, implode($this->errorSeparator, $errors), $params); 436 | } else { 437 | $errors = array_map( 438 | function ($err) { 439 | return ($this->prefixErrors ? '{keyPath}' : '') . $err; 440 | }, 441 | $dynamicModel->getErrorSummary(true) 442 | ); 443 | foreach ($errors as $message) { 444 | $this->addError($baseModel, $baseAttribute, $message, $params); 445 | } 446 | } 447 | if ($this->stopOnFirstError) { 448 | break; 449 | } 450 | } 451 | } 452 | 453 | /** 454 | * @param $model 455 | * @param $attribute 456 | * @param $filteredValue 457 | * @param \yii\base\Model $baseModel 458 | * @param $baseAttribute 459 | * @param $path 460 | * @throws \yii\base\NotSupportedException 461 | */ 462 | private function processHashValue($model, $attribute, $filteredValue, Model $baseModel, $baseAttribute, $path):void 463 | { 464 | $attributes = array_keys($this->rules); 465 | $existedAttributes = array_keys($filteredValue); 466 | $unexpectedItems = array_diff($existedAttributes, $attributes); 467 | $keyPath = $path; 468 | if ($attribute !== $baseAttribute) { 469 | $keyPath[] = "[$attribute]"; 470 | } 471 | if (!empty($this->rules) && !empty($unexpectedItems)) { 472 | $this->addError( 473 | $baseModel, 474 | $baseAttribute, 475 | ($this->prefixErrors ? '{keyPath}' : '') . $this->unexpectedItemsMessage, 476 | ['items' => implode(',', $unexpectedItems), 'keyPath' => implode('', $keyPath)] 477 | ); 478 | return; 479 | } 480 | $missingItems = array_diff(array_keys($this->rules), array_keys($filteredValue)); 481 | foreach ($missingItems as $item) { 482 | $filteredValue[$item] = null; 483 | } 484 | $dynamicModel = new DynamicModel($filteredValue); 485 | //$dynamicModel->setScenario($baseModel->scenario); 486 | $isValid = $this->applyValidators($attribute, $dynamicModel, null, $baseModel, $keyPath); 487 | if ($this->mutable === true) { 488 | $model->$attribute = $dynamicModel->getAttributes(); 489 | } 490 | if ($isValid === true) { 491 | return; 492 | } 493 | $params = ['keyPath' => implode('', $keyPath)]; 494 | if ($this->compactErrors) { 495 | $errors = array_map( 496 | function ($err) { 497 | return ($this->prefixErrors ? '{keyPath}' : '') . $err; 498 | }, 499 | $dynamicModel->getErrorSummary(false) 500 | ); 501 | $this->addError($baseModel, $baseAttribute, implode($this->errorSeparator, $errors), $params); 502 | } else { 503 | $errors = array_map( 504 | function ($err) { 505 | return ($this->prefixErrors ? '{keyPath}' : '') . $err; 506 | }, 507 | $dynamicModel->getErrorSummary(true) 508 | ); 509 | foreach ($errors as $message) { 510 | $this->addError($baseModel, $baseAttribute, $message, $params); 511 | } 512 | } 513 | } 514 | } 515 | -------------------------------------------------------------------------------- /tests/ArrayStructureValidatorTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 14 | 'emptyArray' => [], 15 | 'emptyArray2' => [], 16 | 'hash' => ['a' => 1, 'b' => 2, 'c' => 'foo'], 17 | 'hash2' => ['a' => 1, 'b' => 2, 'c' => 'foo'], 18 | 'indexed' => [['x' => 'foo'], ['x' => 'bar'], ['x' => 'baz']], 19 | ]; 20 | $rules = [ 21 | ['notArray', ArrayStructureValidator::class, ['message' => 'notArray']], 22 | ['emptyArray', ArrayStructureValidator::class, []], 23 | ['emptyArray2', ArrayStructureValidator::class, ['min' => 1]], 24 | ['hash', ArrayStructureValidator::class, ['min' => 4]], 25 | ['hash2', ArrayStructureValidator::class, ['min' => 3, 'max' => 3]], 26 | ['indexed', ArrayStructureValidator::class, ['max' => 2]], 27 | ]; 28 | $model = new DynamicModel($data); 29 | foreach ($rules as $rule) { 30 | $model->addRule(...$rule); 31 | } 32 | $model->validate(); 33 | self::assertTrue($model->hasErrors()); 34 | self::assertEquals($model->getFirstError('notArray'), 'notArray'); 35 | self::assertNull($model->getFirstError('emptyArray')); 36 | self::assertNull($model->getFirstError('hash2')); 37 | self::assertContains('Empty Array2 is too small', $model->getFirstError('emptyArray2')); 38 | self::assertContains('Hash is too small', $model->getFirstError('hash')); 39 | self::assertContains('Indexed is too large', $model->getFirstError('indexed')); 40 | } 41 | 42 | public function testArraySizeValidationWithEachRule():void 43 | { 44 | $data = [ 45 | 'a' => [['foo', 'bar'], ['foo', 'bar'], ['foo', 'bar']], 46 | 'b' => [['foo'], ['foo', 'bar'], []], 47 | 'c' => [['foo'], ['foo', 'bar'], []], 48 | ]; 49 | $rules = [ 50 | ['a', 'each', ['rule' => [ArrayStructureValidator::class, 'min' => 2, 'max' => 2]]], 51 | ['b', 'each', ['rule' => [ArrayStructureValidator::class, 'min' => 2, 'max' => 2]]], 52 | [ 53 | 'c', 54 | 'each', 55 | [ 56 | 'rule' => [ArrayStructureValidator::class, 'min' => 2, 'max' => 2], 57 | 'stopOnFirstError' => false, 58 | ], 59 | ], 60 | ]; 61 | $model = new DynamicModel($data); 62 | foreach ($rules as $rule) { 63 | $model->addRule(...$rule); 64 | } 65 | $model->validate(); 66 | //VarDumper::dump($model->getErrors()); 67 | self::assertTrue($model->hasErrors()); 68 | self::assertFalse($model->hasErrors('a')); 69 | self::assertTrue($model->hasErrors('b')); 70 | self::assertCount(1, $model->getErrors('b')); 71 | self::assertCount(2, $model->getErrors('c')); 72 | } 73 | 74 | public function testWithAssociativeStructure():void 75 | { 76 | $data = [ 77 | 'valid' => [ 78 | 'a' => 1, 79 | 'b' => 2, 80 | 'c' => ' foo ', 81 | 'd' => '123@mail.ru', 82 | 'e' => 'http://mail.ru', 83 | 'f' => '192.168.1.1', 84 | ], 85 | 'invalid1' => [ 86 | 'a' => 1, 87 | 'b' => 2, 88 | 'c' => ' foo ', 89 | 'd' => '-123@mail.ru-', 90 | 'e' => 'qwerty', 91 | 'f' => '192.168.foo', 92 | ], 93 | 'invalid2' => [ 94 | 'a' => 1, 95 | 'b' => 2, 96 | 'c' => ' foo ', 97 | 'd' => '-123@mail.ru-', 98 | 'e' => 'qwerty', 99 | 'f' => '192.168.foo', 100 | ], 101 | 'invalid3' => ['a' => 1, 'b' => 2, 'foo' => 4, 'bar' => 5], 102 | ]; 103 | $struct = [ 104 | 'a' => [['integer', 'min' => 5]], 105 | 'b' => [['integer'], ['in', 'range' => ['3', '4']]], 106 | 'c' => [['trim'], ['string', 'min' => 5]], 107 | 'd' => [['email']], 108 | 'e' => [['url']], 109 | 'f' => [['ip']], 110 | ]; 111 | $rules = [ 112 | [ 113 | 'valid', 114 | ArrayStructureValidator::class, 115 | [ 116 | 'rules' => [ 117 | 'a' => [['integer', 'min' => 1]], 118 | 'b' => [['integer', 'max' => 3], ['in', 'range' => [1, 2, 3]]], 119 | 'c' => [['trim'], ['string', 'max' => 3]], 120 | 'd' => [['email']], 121 | 'e' => [['url']], 122 | 'f' => [['ip']], 123 | ], 124 | ], 125 | ], 126 | ['invalid1', ArrayStructureValidator::class, ['rules' => $struct, 'compactErrors' => true]], 127 | ['invalid2', ArrayStructureValidator::class, ['rules' => $struct]], 128 | ['invalid3', ArrayStructureValidator::class, ['rules' => $struct]], 129 | ]; 130 | $model = new DynamicModel($data); 131 | foreach ($rules as $rule) { 132 | $model->addRule(...$rule); 133 | } 134 | $model->validate(); 135 | //VarDumper::dump($model->getErrors()); 136 | self::assertTrue($model->hasErrors()); 137 | self::assertFalse($model->hasErrors('valid')); 138 | self::assertTrue($model->hasErrors('invalid1')); 139 | self::assertTrue($model->hasErrors('invalid2')); 140 | self::assertTrue($model->hasErrors('invalid3')); 141 | self::assertCount(1, $model->getErrors('invalid1')); 142 | self::assertContains('Invalid3 contains unexpected items foo,bar', $model->getErrors('invalid3')); 143 | } 144 | 145 | public function testWithIndexedStructureEachOption():void 146 | { 147 | $data = [ 148 | 'valid' => [['x' => 1, 'y' => 2], ['x' => 5, 'y' => 10]], 149 | 'invalid' => [['x' => 0, 'y' => 2], ['x' => 5, 'y' => 100], ['x' => 'foo', 'foo' => 10], ['y' => 20]], 150 | 'invalid2' => [['x' => 0, 'y' => 2], ['x' => 5, 'y' => 100], ['x' => 'foo', 'foo' => 10], ['y' => 20]], 151 | ]; 152 | $rules = [ 153 | [ 154 | 'valid', 155 | ArrayStructureValidator::class, 156 | [ 157 | 'rules' => ['x' => [['required'], ['integer']], 'y' => [['required'], ['integer']]], 158 | 'each' => true, 159 | ], 160 | ], 161 | [ 162 | 'invalid', 163 | ArrayStructureValidator::class, 164 | [ 165 | 'rules' => ['x' => [['required'], ['integer', 'min' => 1]], 'y' => [['required'], ['integer']]], 166 | 'each' => true, 167 | 'compactErrors' => false, 168 | 'stopOnFirstError' => false, 169 | ], 170 | ], 171 | ]; 172 | $model = new DynamicModel($data); 173 | foreach ($rules as $rule) { 174 | $model->addRule(...$rule); 175 | } 176 | $model->validate(); 177 | //VarDumper::dump($model->getErrors()); 178 | self::assertFalse($model->hasErrors('valid')); 179 | self::assertTrue($model->hasErrors('invalid')); 180 | } 181 | 182 | public function testWithIndexedStructureAndEachValidator():void 183 | { 184 | $data = [ 185 | 'valid' => [['x' => 1, 'y' => 2], ['x' => 5, 'y' => 10]], 186 | 'invalid' => [['x' => 0, 'y' => 2], ['x' => 5, 'y' => 100], ['x' => 'foo', 'foo' => 10], ['y' => 20]], 187 | ]; 188 | 189 | $rules = [ 190 | [ 191 | 'valid', 192 | 'each', 193 | [ 194 | 'rule' => [ 195 | ArrayStructureValidator::class, 196 | 'rules' => [ 197 | 'x' => [['required'], ['integer']], 198 | 'y' => [['required'], ['integer']], 199 | ], 200 | ], 201 | ], 202 | ], 203 | [ 204 | 'invalid', 205 | 'each', 206 | [ 207 | 'rule' => [ 208 | ArrayStructureValidator::class, 209 | 'rules' => [ 210 | 'x' => [['required'], ['integer', 'min' => 1]], 211 | 'y' => [['required'], ['integer']], 212 | ], 213 | 'stopOnFirstError' => false, 214 | ], 215 | 'stopOnFirstError' => false, 216 | ], 217 | ], 218 | ]; 219 | $model = new DynamicModel($data); 220 | foreach ($rules as $rule) { 221 | $model->addRule(...$rule); 222 | } 223 | $model->validate(); 224 | //VarDumper::dump($model->getErrors()); 225 | self::assertFalse($model->hasErrors('valid')); 226 | self::assertTrue($model->hasErrors('invalid')); 227 | } 228 | 229 | public function testMutableImmutable():void 230 | { 231 | $data = [ 232 | 'immutable' => ['x' => 1, 'y' => ' foo '], 233 | 'mutable' => ['x' => 1, 'y' => ' foo '], 234 | 'mutableIndexed' => [['x' => 1, 'y' => ' foo '], ['x' => 2, 'y' => ' bar ']], 235 | ]; 236 | $structure = [ 237 | 'x' => [['filter', 'filter' => function($v) { return $v + 10; }], ['integer', 'min' => 10]], 238 | 'y' => [['trim']], 239 | 'z' => [['default', 'value' => 100500]], 240 | ]; 241 | $rules = [ 242 | ['immutable', ArrayStructureValidator::class, ['rules' => $structure, 'mutable' => false]], 243 | ['mutable', ArrayStructureValidator::class, ['rules' => $structure, 'mutable' => true]], 244 | [ 245 | 'mutableIndexed', 246 | ArrayStructureValidator::class, 247 | [ 248 | 'rules' => $structure, 249 | 'mutable' => true, 250 | 'each' => true, 251 | ], 252 | ], 253 | ]; 254 | $model = new DynamicModel($data); 255 | foreach ($rules as $rule) { 256 | $model->addRule(...$rule); 257 | } 258 | 259 | self::assertTrue($model->validate()); 260 | self::assertEquals($model->immutable, $data['immutable']); 261 | self::assertEquals($model->mutable, ['x' => 11, 'y' => 'foo', 'z' => 100500]); 262 | self::assertEquals($model->mutableIndexed[0], ['x' => 11, 'y' => 'foo', 'z' => 100500]); 263 | self::assertEquals($model->mutableIndexed[1], ['x' => 12, 'y' => 'bar', 'z' => 100500]); 264 | } 265 | 266 | public function testWithClosureRules():void 267 | { 268 | $data = [ 269 | 'hash' => ['a' => 1, 'b' => 'foo'], 270 | 'indexed' => [ 271 | ['a' => 1, 'b' => 'foo'], 272 | ['a' => 12, 'b' => 'bar'], 273 | ], 274 | ]; 275 | $closure = function($attribute, $model, $index, $baseModel, $baseAttribute) { 276 | if ($baseAttribute === 'hash') { 277 | self::assertNull($index); 278 | } else { 279 | self::assertTrue(in_array($index, [0, 1], true)); 280 | } 281 | self::assertInstanceOf(DynamicModel::class, $model); 282 | self::assertInstanceOf(DynamicModel::class, $baseModel); 283 | self::assertTrue($model->hasAttribute('a')); 284 | self::assertTrue($model->hasAttribute('b')); 285 | self::assertFalse($model->hasAttribute('hash')); 286 | self::assertFalse($baseModel->hasAttribute('a')); 287 | self::assertTrue($baseModel->hasAttribute('hash')); 288 | self::assertTrue($baseModel->hasAttribute('indexed')); 289 | if ($model->b !== 'foo') { 290 | $model->addError($attribute, $attribute . ' Fail condition from closure'); 291 | } 292 | }; 293 | $structure = [ 294 | 'a' => [ 295 | ['integer'], 296 | [$closure], 297 | ], 298 | 'b' => [[$closure]], 299 | ]; 300 | $rules = [ 301 | ['hash', ArrayStructureValidator::class, ['rules' => $structure]], 302 | [ 303 | 'indexed', 304 | ArrayStructureValidator::class, 305 | ['rules' => $structure, 'compactErrors' => false, 'each' => true], 306 | ], 307 | ]; 308 | $model = new DynamicModel($data); 309 | foreach ($rules as $rule) { 310 | $model->addRule(...$rule); 311 | } 312 | //VarDumper::dump($model->errors); 313 | self::assertFalse($model->validate()); 314 | self::assertFalse($model->hasErrors('hash')); 315 | self::assertContains('[1]a Fail condition from closure', $model->getErrors('indexed')); 316 | self::assertContains('[1]b Fail condition from closure', $model->getErrors('indexed')); 317 | } 318 | 319 | public function testWithCallableRules():void 320 | { 321 | $data = [ 322 | 'hash' => ['a' => 1, 'b' => 'foo'], 323 | 'indexed' => [ 324 | ['a' => 1, 'b' => 'foo'], 325 | ['a' => 12, 'b' => 'bar'], 326 | ], 327 | ]; 328 | 329 | $structure = [ 330 | 'a' => [ 331 | ['integer'], 332 | [Closure::fromCallable([$this, 'callableValidator'])], 333 | ], 334 | 'b' => [ 335 | [Closure::fromCallable([$this, 'callableValidator'])] 336 | ], 337 | ]; 338 | $rules = [ 339 | ['hash', ArrayStructureValidator::class, ['rules' => $structure]], 340 | [ 341 | 'indexed', 342 | ArrayStructureValidator::class, 343 | ['rules' => $structure, 'compactErrors' => false, 'each' => true], 344 | ], 345 | ]; 346 | $model = new DynamicModel($data); 347 | foreach ($rules as $rule) { 348 | $model->addRule(...$rule); 349 | } 350 | //VarDumper::dump($model->errors); 351 | self::assertFalse($model->validate()); 352 | self::assertFalse($model->hasErrors('hash')); 353 | self::assertContains('[1]a Fail condition from callable', $model->getErrors('indexed')); 354 | self::assertContains('[1]b Fail condition from callable', $model->getErrors('indexed')); 355 | } 356 | 357 | public function testNestedArray():void 358 | { 359 | $data = [ 360 | 'a' => [ 361 | 'foo' => [['x' => 1], ['x' => 5], ['x' => 3, 'y' => 4]], 362 | 'bar' => [['a' => 1], ['b' => 5], ['x' => 3, 'a' => 4, 'c' => 'foo']], 363 | ], 364 | 'b' => ['foo' => 1, 'bar' => ['id' => 1, 'v' => '123']], 365 | ]; 366 | $rules = [ 367 | [ 368 | 'a', 369 | ArrayStructureValidator::class, 370 | [ 371 | 'rules' => [ 372 | 'foo' => [ 373 | [ 374 | ArrayStructureValidator::class, 375 | 'each' => true, 376 | 'rules' => [ 377 | 'x' => [['integer', 'min' => 0, 'max' => 10]], 378 | 'y' => [['safe']], 379 | 'z' => [['default', 'value' => 100500]], 380 | ], 381 | ], 382 | ], 383 | 'bar' => [ 384 | [ 385 | ArrayStructureValidator::class, 386 | 'each' => true, 387 | 'rules' => [ 388 | 'x' => [['default', 'value' => 100500], ['integer', 'min' => 0]], 389 | 'a' => [['safe']], 390 | 'b' => [['default', 'value' => 33], ['match', 'pattern' => '/\d+/']], 391 | 'c' => [['default', 'value' => 'bar'], ['string']], 392 | ], 393 | ], 394 | ], 395 | ], 396 | ], 397 | ], 398 | [ 399 | 'b', 400 | ArrayStructureValidator::class, 401 | [ 402 | 'rules' => [ 403 | 'foo' => [['required'], ['integer']], 404 | 'bar' => [ 405 | [ 406 | ArrayStructureValidator::class, 407 | 'rules' => [ 408 | 'id' => [['integer']], 409 | 'v' => [['required']], 410 | 'a' => [['safe']], 411 | 'b' => [['default', 'value' => 'foo']], 412 | ], 413 | ], 414 | ], 415 | ], 416 | ], 417 | ], 418 | ]; 419 | $model = new DynamicModel($data); 420 | foreach ($rules as $rule) { 421 | $model->addRule(...$rule); 422 | } 423 | self::assertTrue($model->validate()); 424 | //VarDumper::dump($model->getAttributes()); 425 | foreach ($model->a['foo'] as $item) { 426 | self::assertArrayHasKey('z', $item); 427 | self::assertEquals(100500, $item['z']); 428 | } 429 | foreach ($model->a['bar'] as $item) { 430 | self::assertArrayHasKey('a', $item); 431 | self::assertArrayHasKey('b', $item); 432 | self::assertArrayHasKey('c', $item); 433 | self::assertArrayHasKey('x', $item); 434 | } 435 | self::assertNull($model->b['bar']['a']); 436 | self::assertEquals($model->b['bar']['b'], 'foo'); 437 | } 438 | 439 | public function testErrorMessages():void 440 | { 441 | $data = [ 442 | 'indexedFullErrors' => [ 443 | 'foo' => [['x' => 100], ['x' => 5], ['x' => 3, 'y' => 4]], 444 | 'bar' => [['a' => 1], ['b' => 5], ['x' => 3, 'a' => 4]], 445 | ], 446 | 'indexedStopOnFirst' => [ 447 | 'foo' => [['x' => 100], ['x' => 5], ['x' => 3, 'y' => 4]], 448 | 'bar' => [['a' => 1], ['b' => 5], ['x' => 3, 'a' => 4]], 449 | ], 450 | 'indexedCompactErrors' => [ 451 | 'foo' => [['x' => 100], ['x' => 5], ['x' => 3, 'y' => 4]], 452 | 'bar' => [['a' => 1], ['b' => 5], ['x' => 3, 'a' => 4]], 453 | ], 454 | 'hashFullErrors' => ['foo' => 1, 'bar' => ['id' => 1, 'v' => '123']], 455 | 'hashCompactErrors' => ['foo' => 1, 'bar' => ['id' => 1, 'v' => '123']], 456 | ]; 457 | $rules = [ 458 | [ 459 | 'indexedFullErrors', 460 | ArrayStructureValidator::class, 461 | [ 462 | 'rules' => [ 463 | 'foo' => [ 464 | [ 465 | ArrayStructureValidator::class, 466 | 'each' => true, 467 | 'rules' => [ 468 | 'x' => [['integer', 'min' => 0, 'max' => 10]], 469 | 'y' => [['required']], 470 | ], 471 | 'stopOnFirstError' => false, 472 | 'compactErrors' => false, 473 | ], 474 | ], 475 | 'bar' => [ 476 | [ 477 | ArrayStructureValidator::class, 478 | 'each' => true, 479 | 'rules' => [ 480 | 'x' => [['default', 'value' => 100500], ['integer', 'min' => 0]], 481 | 'a' => [['integer', 'min' => 2]], 482 | 'b' => [ 483 | ['default', 'value' => 'foo'], 484 | ['match', 'pattern' => '/\d+/', 'not' => true], 485 | ], 486 | ], 487 | 'stopOnFirstError' => false, 488 | 'compactErrors' => false, 489 | ], 490 | ], 491 | ], 492 | ], 493 | ], 494 | [ 495 | 'indexedStopOnFirst', 496 | ArrayStructureValidator::class, 497 | [ 498 | 'rules' => [ 499 | 'foo' => [ 500 | [ 501 | ArrayStructureValidator::class, 502 | 'each' => true, 503 | 'rules' => [ 504 | 'x' => [['integer', 'min' => 0, 'max' => 10]], 505 | 'y' => [['required']], 506 | ], 507 | 'stopOnFirstError' => true, 508 | 'compactErrors' => false, 509 | ], 510 | ], 511 | 'bar' => [ 512 | [ 513 | ArrayStructureValidator::class, 514 | 'each' => true, 515 | 'rules' => [ 516 | 'x' => [['default', 'value' => 100500], ['integer', 'min' => 0]], 517 | 'a' => [['integer', 'min' => 2]], 518 | 'b' => [ 519 | ['default', 'value' => 'foo'], 520 | ['match', 'pattern' => '/\d+/', 'not' => true], 521 | ], 522 | ], 523 | 'stopOnFirstError' => true, 524 | 'compactErrors' => false, 525 | ], 526 | ], 527 | ], 528 | ], 529 | ], 530 | [ 531 | 'indexedCompactErrors', 532 | ArrayStructureValidator::class, 533 | [ 534 | 'rules' => [ 535 | 'foo' => [ 536 | [ 537 | ArrayStructureValidator::class, 538 | 'each' => true, 539 | 'rules' => [ 540 | 'x' => [['integer', 'min' => 0, 'max' => 10]], 541 | 'y' => [['required']], 542 | ], 543 | 'stopOnFirstError' => false, 544 | 'compactErrors' => true, 545 | ], 546 | ], 547 | 'bar' => [ 548 | [ 549 | ArrayStructureValidator::class, 550 | 'each' => true, 551 | 'rules' => [ 552 | 'x' => [['default', 'value' => 100500], ['integer', 'min' => 0]], 553 | 'a' => [['integer', 'min' => 2]], 554 | 'b' => [ 555 | ['default', 'value' => 'foo'], 556 | ['match', 'pattern' => '/\d+/', 'not' => true], 557 | ], 558 | ], 559 | 'stopOnFirstError' => false, 560 | 'compactErrors' => true, 561 | ], 562 | ], 563 | ], 564 | ], 565 | ], 566 | [ 567 | 'hashFullErrors', 568 | ArrayStructureValidator::class, 569 | [ 570 | 'rules' => [ 571 | 'foo' => [['required'], ['integer']], 572 | 'bar' => [ 573 | [ 574 | ArrayStructureValidator::class, 575 | 'rules' => [ 576 | 'id' => [['email']], 577 | 'v' => [['required']], 578 | 'a' => [['required', 'message' => '{attribute} custom message for required']], 579 | ], 580 | 'stopOnFirstError' => false, 581 | 'compactErrors' => false, 582 | ], 583 | ], 584 | ], 585 | 'stopOnFirstError' => false, 586 | 'compactErrors' => false, 587 | ], 588 | ], 589 | [ 590 | 'hashCompactErrors', 591 | ArrayStructureValidator::class, 592 | [ 593 | 'rules' => [ 594 | 'foo' => [['required'], ['integer']], 595 | 'bar' => [ 596 | [ 597 | ArrayStructureValidator::class, 598 | 'rules' => [ 599 | 'id' => [['email']], 600 | 'v' => [['required']], 601 | 'a' => [['required', 'message' => '{attribute} custom message for required']], 602 | ], 603 | 'stopOnFirstError' => false, 604 | 'compactErrors' => true, 605 | ], 606 | ], 607 | ], 608 | 'stopOnFirstError' => false, 609 | 'compactErrors' => false, 610 | ], 611 | ], 612 | ]; 613 | $model = new DynamicModel($data); 614 | foreach ($rules as $rule) { 615 | $model->addRule(...$rule); 616 | } 617 | self::assertFalse($model->validate()); 618 | self::assertContains('[foo][0]Y cannot be blank.', $model->getErrors('indexedFullErrors')); 619 | self::assertContains('[foo][1]Y cannot be blank.', $model->getErrors('indexedFullErrors')); 620 | self::assertContains('[foo][0]X must be no greater than 10.', $model->getErrors('indexedFullErrors')); 621 | self::assertContains('[bar][0]A must be no less than 2.', $model->getErrors('indexedFullErrors')); 622 | self::assertContains('[bar][1]B is invalid.', $model->getErrors('indexedFullErrors')); 623 | 624 | self::assertContains('[foo][0]Y cannot be blank.', $model->getErrors('indexedStopOnFirst')); 625 | self::assertNotContains('[foo][1]Y cannot be blank.', $model->getErrors('indexedStopOnFirst')); 626 | self::assertContains('[foo][0]X must be no greater than 10.', $model->getErrors('indexedStopOnFirst')); 627 | self::assertContains('[bar][0]A must be no less than 2.', $model->getErrors('indexedStopOnFirst')); 628 | self::assertNotContains('[bar][1]B is invalid.', $model->getErrors('indexedStopOnFirst')); 629 | 630 | self::assertContains('[bar]A custom message for required', $model->getErrors('hashFullErrors')); 631 | self::assertContains('[bar]Id is not a valid email address.', $model->getErrors('hashFullErrors')); 632 | 633 | $compactErrors1 = implode("\n", 634 | ['[bar]A custom message for required', '[bar]Id is not a valid email address.'] 635 | ); 636 | $compactErrors2 = implode("\n", 637 | ['[bar]Id is not a valid email address.', '[bar]A custom message for required'] 638 | ); 639 | $errors = implode("\n",$model->getErrors('hashCompactErrors')); 640 | self::assertTrue($errors === $compactErrors1 || $errors === $compactErrors2); 641 | } 642 | 643 | public function testUniqueValidatorNotSupported() 644 | { 645 | $this->expectExceptionMessage('Unique rule not supported with current validator'); 646 | $model = new DynamicModel(['x' => ['a' => 1]]); 647 | $model->addRule('x', ArrayStructureValidator::class, ['rules' => ['a' => [['unique']]]]); 648 | $model->validate(); 649 | } 650 | 651 | public function testExistValidatorNotSupported() 652 | { 653 | $this->expectExceptionMessage('Avoid exist validator usage with multidimensional array'); 654 | $model = new DynamicModel(['x' => [['a' => 1]]]); 655 | $model->addRule('x', ArrayStructureValidator::class, ['each' => true, 'rules' => ['a' => [['exist']]]]); 656 | $model->validate(); 657 | } 658 | 659 | public function testValidationWithoutModel() 660 | { 661 | $validator = new ArrayStructureValidator([ 662 | 'each' => true, 663 | 'rules' => [ 664 | 'x' => [['integer', 'min' => 0, 'max' => 10]], 665 | 'y' => [['required']], 666 | ], 667 | ]); 668 | $error = ''; 669 | $isValid = $validator->validate([['x' => 100], ['x' => 5], ['x' => 3, 'y' => 4]], $error); 670 | self::assertFalse($isValid); 671 | self::assertContains('[0]Y cannot be blank.', $error); 672 | self::assertContains('[0]X must be no greater than 10.', $error); 673 | } 674 | 675 | public function testScenarioConditionsShouldBeApplied():void 676 | { 677 | $model = new class extends Model { 678 | public $value; 679 | 680 | public function scenarios() 681 | { 682 | return [ 683 | 'default' => ['value'], 684 | 'test1' => ['value'], 685 | 'test2' => ['value'], 686 | ]; 687 | } 688 | 689 | public function rules() 690 | { 691 | return [ 692 | [ 693 | 'value', 694 | ArrayStructureValidator::class, 695 | 'rules' => [ 696 | 'x' => [ 697 | ['integer', 'min' => 5, 'on' => ['test1']], 698 | ['integer', 'max' => 5, 'on' => ['test2']], 699 | ], 700 | 'y' => [ 701 | ['default', 'value' => 1, 'on' => ['test1']], 702 | ['default', 'value' => 2, 'on' => ['test2']], 703 | ], 704 | 'z' => [['default', 'value' => 'foo']], 705 | 'foo' => [['string', 'max' => 3]], 706 | ], 707 | 'on' => ['test1', 'test2'], 708 | ], 709 | [ 710 | 'value', 711 | ArrayStructureValidator::class, 712 | 'rules' => [ 713 | 'x' => [['safe']], 714 | 'y' => [['safe']], 715 | 'foo' => [['safe']], 716 | 'z' => [['default', 'value' => 'bar']], 717 | ], 718 | 'on' => ['default'], 719 | ], 720 | ]; 721 | } 722 | }; 723 | 724 | $model->scenario = 'default'; 725 | $model->value = ['x' => '1', 'y' => null, 'foo' => '123']; 726 | $model->validate(); 727 | self::assertNull($model->value['y']); 728 | self::assertFalse($model->hasErrors()); 729 | self::assertEquals('bar', $model->value['z']); 730 | 731 | $model->value = ['x' => '1', 'y' => null, 'foo' => '1234']; 732 | $model->scenario = 'test1'; 733 | $model->validate(); 734 | //VarDumper::dump([$model->scenario, $model->getErrors()]); 735 | self::assertEquals(1, $model->value['y']); 736 | self::assertEquals('foo', $model->value['z']); 737 | self::assertContains('X must be no less than 5.', $model->getErrors('value')); 738 | self::assertContains('Foo should contain at most 3 characters.', $model->getErrors('value')); 739 | 740 | $model->scenario = 'test2'; 741 | $model->value = ['x' => '1', 'y' => null, 'foo' => '123']; 742 | $model->validate(); 743 | self::assertEquals(2, $model->value['y']); 744 | self::assertEquals('foo', $model->value['z']); 745 | self::assertFalse($model->hasErrors()); 746 | } 747 | 748 | public function testWhenConditionsShouldBeApplied() 749 | { 750 | $model = new class extends Model { 751 | public $value; 752 | 753 | public $dummy; 754 | 755 | public function scenarios() 756 | { 757 | return [ 758 | 'default' => ['value'], 759 | 'test1' => ['value'], 760 | 'test2' => ['value'], 761 | ]; 762 | } 763 | 764 | public function rules() 765 | { 766 | $checker = function($model, $attribute, $index, $baseModel, $baseAttribute) { 767 | Assert::assertInstanceOf(DynamicModel::class, $model); 768 | Assert::assertInstanceOf(Model::class, $baseModel); 769 | Assert::assertEquals('z', $attribute); 770 | Assert::assertEquals('value', $baseAttribute); 771 | Assert::assertNull($index); 772 | return $model->x > 10; 773 | }; 774 | return [ 775 | [ 776 | 'value', 777 | ArrayStructureValidator::class, 778 | 'rules' => [ 779 | 'x' => [['safe']], 780 | 'z' => [ 781 | [ 782 | 'default', 783 | 'value' => 'foo', 784 | 'when' => $checker, 785 | ], 786 | [ 787 | 'default', 788 | 'value' => 'bar', 789 | 'when' => function($model, $attribute, $index, $baseModel) { 790 | return $model->x < 10 && $baseModel->dummy === 'bar'; 791 | }, 792 | ], 793 | ], 794 | ], 795 | ], 796 | ]; 797 | } 798 | }; 799 | $model->dummy = 'bar'; 800 | $model->value = ['x' => 1]; 801 | $model->validate(); 802 | self::assertEquals('bar', $model->value['z']); 803 | 804 | $model->dummy = 'bar'; 805 | $model->value = ['x' => 15]; 806 | $model->validate(); 807 | self::assertEquals('foo', $model->value['z']); 808 | 809 | $model->dummy = ''; 810 | $model->value = ['x' => 5]; 811 | $model->validate(); 812 | self::assertNull($model->value['z']); 813 | } 814 | 815 | public function callableValidator($attribute, $model, $index, $baseModel, $baseAttribute) { 816 | if ($baseAttribute === 'hash') { 817 | self::assertNull($index); 818 | } else { 819 | self::assertTrue(in_array($index, [0, 1], true)); 820 | } 821 | self::assertInstanceOf(DynamicModel::class, $model); 822 | self::assertInstanceOf(DynamicModel::class, $baseModel); 823 | self::assertTrue($model->hasAttribute('a')); 824 | self::assertTrue($model->hasAttribute('b')); 825 | self::assertFalse($model->hasAttribute('hash')); 826 | self::assertFalse($baseModel->hasAttribute('a')); 827 | self::assertTrue($baseModel->hasAttribute('hash')); 828 | self::assertTrue($baseModel->hasAttribute('indexed')); 829 | if ($model->b !== 'foo') { 830 | $model->addError($attribute, $attribute . ' Fail condition from callable'); 831 | } 832 | } 833 | } 834 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | mockApplication(); 16 | } 17 | 18 | protected function mockApplication(array $extendConfig = []):Application 19 | { 20 | $config = ArrayHelper::merge([ 21 | 'id' => 'yii2-openapi-test', 22 | 'basePath' => __DIR__, 23 | 'language'=> 'en', 24 | 'components'=>[] 25 | ], $extendConfig); 26 | return new Application($config); 27 | } 28 | } -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |