Passed
Pull Request — master (#505)
by Alexander
03:21 queued 18s
created

NestedTest.php$15 ➔ testIterableMaximumLevel()   A

Complexity

Conditions 1

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Tests\Rule;
6
7
use ArrayObject;
8
use InvalidArgumentException;
9
use ReflectionProperty;
10
use stdClass;
11
use Yiisoft\Arrays\ArrayHelper;
12
use Yiisoft\Validator\DataSet\ObjectDataSet;
13
use Yiisoft\Validator\DataSetInterface;
14
use Yiisoft\Validator\Error;
15
use Yiisoft\Validator\Result;
16
use Yiisoft\Validator\Rule\BooleanValue;
17
use Yiisoft\Validator\Rule\Callback;
18
use Yiisoft\Validator\Rule\Count;
19
use Yiisoft\Validator\Rule\Each;
20
use Yiisoft\Validator\Rule\HasLength;
21
use Yiisoft\Validator\Rule\In;
22
use Yiisoft\Validator\Rule\Nested;
23
use Yiisoft\Validator\Rule\NestedHandler;
24
use Yiisoft\Validator\Rule\Number;
25
use Yiisoft\Validator\Rule\Regex;
26
use Yiisoft\Validator\Rule\Required;
27
use Yiisoft\Validator\RulesProviderInterface;
28
use Yiisoft\Validator\Tests\Rule\Base\DifferentRuleInHandlerTestTrait;
29
use Yiisoft\Validator\Tests\Rule\Base\RuleTestCase;
30
use Yiisoft\Validator\Tests\Rule\Base\RuleWithOptionsTestTrait;
31
use Yiisoft\Validator\Tests\Rule\Base\SkipOnErrorTestTrait;
32
use Yiisoft\Validator\Tests\Rule\Base\WhenTestTrait;
33
use Yiisoft\Validator\Tests\Support\Data\EachNestedObjects\Foo;
34
use Yiisoft\Validator\Tests\Support\Data\IteratorWithBooleanKey;
35
use Yiisoft\Validator\Tests\Support\Data\InheritAttributesObject\InheritAttributesObject;
36
use Yiisoft\Validator\Tests\Support\Data\ObjectWithDifferentPropertyVisibility;
37
use Yiisoft\Validator\Tests\Support\Data\ObjectWithNestedObject;
38
use Yiisoft\Validator\Tests\Support\Rule\StubRule\StubRuleWithOptions;
39
use Yiisoft\Validator\Tests\Support\RulesProvider\SimpleRulesProvider;
40
use Yiisoft\Validator\ValidationContext;
41
use Yiisoft\Validator\Validator;
42
43
use function array_slice;
44
45
final class NestedTest extends RuleTestCase
46
{
47
    use DifferentRuleInHandlerTestTrait;
48
    use RuleWithOptionsTestTrait;
49
    use SkipOnErrorTestTrait;
50
    use WhenTestTrait;
51
52
    public function testGetName(): void
53
    {
54
        $rule = new Nested();
55
56
        $this->assertSame('nested', $rule->getName());
57
    }
58
59
    public function testDefaultValues(): void
60
    {
61
        $rule = new Nested();
62
63
        $this->assertNull($rule->getRules());
64
        $this->assertSame(
65
            ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PUBLIC,
66
            $rule->getValidatedObjectPropertyVisibility(),
67
        );
68
        $this->assertFalse($rule->isPropertyPathRequired());
69
        $this->assertSame('Property "{path}" is not found.', $rule->getNoPropertyPathMessage());
70
        $this->assertNull($rule->getSkipOnEmpty());
71
        $this->assertFalse($rule->shouldSkipOnError());
72
        $this->assertNull($rule->getWhen());
73
    }
74
75
    public function testPropertyVisibilityInConstructor(): void
76
    {
77
        $rule = new Nested(validatedObjectPropertyVisibility: ReflectionProperty::IS_PRIVATE);
78
79
        $this->assertSame(ReflectionProperty::IS_PRIVATE, $rule->getValidatedObjectPropertyVisibility());
80
    }
81
82
    public function testHandlerClassName(): void
83
    {
84
        $rule = new Nested();
85
86
        $this->assertSame(NestedHandler::class, $rule->getHandler());
87
    }
88
89
    public function dataOptions(): array
90
    {
91
        return [
92
            [
93
                new Nested([new Number(integerPattern: '/1/', numberPattern: '/1/')]),
94
                [
95
                    'noRulesWithNoObjectMessage' => [
96
                        'template' => 'Nested rule without rules can be used for objects only.',
97
                        'parameters' => [],
98
                    ],
99
                    'incorrectDataSetTypeMessage' => [
100
                        'template' => 'An object data set data can only have an array or an object type.',
101
                        'parameters' => [],
102
                    ],
103
                    'incorrectInputMessage' => [
104
                        'template' => 'The value must have an array or an object type.',
105
                        'parameters' => [],
106
                    ],
107
                    'noPropertyPathMessage' => [
108
                        'template' => 'Property "{path}" is not found.',
109
                        'parameters' => [],
110
                    ],
111
                    'requirePropertyPath' => false,
112
                    'skipOnEmpty' => false,
113
                    'skipOnError' => false,
114
                    'rules' => [
115
                        [
116
                            'number',
117
                            'asInteger' => false,
118
                            'min' => null,
119
                            'max' => null,
120
                            'incorrectInputMessage' => [
121
                                'template' => 'The allowed types are integer, float and string.',
122
                                'parameters' => [],
123
                            ],
124
                            'notNumberMessage' => [
125
                                'template' => 'Value must be a number.',
126
                                'parameters' => [],
127
                            ],
128
                            'tooSmallMessage' => [
129
                                'template' => 'Value must be no less than {min}.',
130
                                'parameters' => ['min' => null],
131
                            ],
132
                            'tooBigMessage' => [
133
                                'template' => 'Value must be no greater than {max}.',
134
                                'parameters' => ['max' => null],
135
                            ],
136
                            'skipOnEmpty' => false,
137
                            'skipOnError' => false,
138
                            'integerPattern' => '/1/',
139
                            'numberPattern' => '/1/',
140
                        ],
141
                    ],
142
                ],
143
            ],
144
            [
145
                new Nested(['user.age' => new Number(integerPattern: '/1/', numberPattern: '/1/')]),
146
                [
147
                    'noRulesWithNoObjectMessage' => [
148
                        'template' => 'Nested rule without rules can be used for objects only.',
149
                        'parameters' => [],
150
                    ],
151
                    'incorrectDataSetTypeMessage' => [
152
                        'template' => 'An object data set data can only have an array or an object type.',
153
                        'parameters' => [],
154
                    ],
155
                    'incorrectInputMessage' => [
156
                        'template' => 'The value must have an array or an object type.',
157
                        'parameters' => [],
158
                    ],
159
                    'noPropertyPathMessage' => [
160
                        'template' => 'Property "{path}" is not found.',
161
                        'parameters' => [],
162
                    ],
163
                    'requirePropertyPath' => false,
164
                    'skipOnEmpty' => false,
165
                    'skipOnError' => false,
166
                    'rules' => [
167
                        'user.age' => [
168
                            'number',
169
                            'asInteger' => false,
170
                            'min' => null,
171
                            'max' => null,
172
                            'incorrectInputMessage' => [
173
                                'template' => 'The allowed types are integer, float and string.',
174
                                'parameters' => [],
175
                            ],
176
                            'notNumberMessage' => [
177
                                'template' => 'Value must be a number.',
178
                                'parameters' => [],
179
                            ],
180
                            'tooSmallMessage' => [
181
                                'template' => 'Value must be no less than {min}.',
182
                                'parameters' => ['min' => null],
183
                            ],
184
                            'tooBigMessage' => [
185
                                'template' => 'Value must be no greater than {max}.',
186
                                'parameters' => ['max' => null],
187
                            ],
188
                            'skipOnEmpty' => false,
189
                            'skipOnError' => false,
190
                            'integerPattern' => '/1/',
191
                            'numberPattern' => '/1/',
192
                        ],
193
                    ],
194
                ],
195
            ],
196
            [
197
                new Nested([
198
                    'author.name' => new StubRuleWithOptions('author-name', ['key' => 'name']),
199
                    'author.age' => new StubRuleWithOptions('author-age', ['key' => 'age']),
200
                ]),
201
                [
202
                    'noRulesWithNoObjectMessage' => [
203
                        'template' => 'Nested rule without rules can be used for objects only.',
204
                        'parameters' => [],
205
                    ],
206
                    'incorrectDataSetTypeMessage' => [
207
                        'template' => 'An object data set data can only have an array or an object type.',
208
                        'parameters' => [],
209
                    ],
210
                    'incorrectInputMessage' => [
211
                        'template' => 'The value must have an array or an object type.',
212
                        'parameters' => [],
213
                    ],
214
                    'noPropertyPathMessage' => [
215
                        'template' => 'Property "{path}" is not found.',
216
                        'parameters' => [],
217
                    ],
218
                    'requirePropertyPath' => false,
219
                    'skipOnEmpty' => false,
220
                    'skipOnError' => false,
221
                    'rules' => [
222
                        'author.name' => ['author-name', 'key' => 'name'],
223
                        'author.age' => ['author-age', 'key' => 'age'],
224
                    ],
225
                ],
226
            ],
227
            [
228
                new Nested([
229
                    'author' => [
230
                        'name' => new StubRuleWithOptions('author-name', ['key' => 'name']),
231
                        'age' => new StubRuleWithOptions('author-age', ['key' => 'age']),
232
                    ],
233
                ]),
234
                [
235
                    'noRulesWithNoObjectMessage' => [
236
                        'template' => 'Nested rule without rules can be used for objects only.',
237
                        'parameters' => [],
238
                    ],
239
                    'incorrectDataSetTypeMessage' => [
240
                        'template' => 'An object data set data can only have an array or an object type.',
241
                        'parameters' => [],
242
                    ],
243
                    'incorrectInputMessage' => [
244
                        'template' => 'The value must have an array or an object type.',
245
                        'parameters' => [],
246
                    ],
247
                    'noPropertyPathMessage' => [
248
                        'template' => 'Property "{path}" is not found.',
249
                        'parameters' => [],
250
                    ],
251
                    'requirePropertyPath' => false,
252
                    'skipOnEmpty' => false,
253
                    'skipOnError' => false,
254
                    'rules' => [
255
                        'author' => [
256
                            'name' => ['author-name', 'key' => 'name'],
257
                            'age' => ['author-age', 'key' => 'age'],
258
                        ],
259
                    ],
260
                ],
261
            ],
262
        ];
263
    }
264
265
    public function testValidationRuleIsNotInstanceOfRule(): void
266
    {
267
        $this->expectException(InvalidArgumentException::class);
268
        new Nested(['path.to.value' => (new stdClass())]);
269
    }
270
271
    public function testWithNestedAndEachShortcutBare(): void
272
    {
273
        $this->expectException(InvalidArgumentException::class);
274
        $this->expectExceptionMessage('Bare shortcut is prohibited. Use "Each" rule instead.');
275
        new Nested(['*' => [new Number(min: -10, max: 10)]]);
276
    }
277
278
    public function dataHandler(): array
279
    {
280
        return [
281
            'class-string-rules' => [
282
                new class () {
283
                    #[Nested(ObjectWithDifferentPropertyVisibility::class)]
284
                    private array $array = [
0 ignored issues
show
introduced by
The private property $array is not used, and could be removed.
Loading history...
285
                        'name' => 'hello',
286
                        'age' => 17,
287
                        'number' => 500,
288
                    ];
289
                },
290
                [
291
                    'array.age' => ['Value must be no less than 21.'],
292
                    'array.number' => ['Value must be no greater than 100.'],
293
                ],
294
            ],
295
            'class-string-rules-private-only' => [
296
                new class () {
297
                    #[Nested(
298
                        rules: ObjectWithDifferentPropertyVisibility::class,
299
                        rulesSourceClassPropertyVisibility: ReflectionProperty::IS_PRIVATE,
300
                    )]
301
                    private array $array = [
302
                        'name' => 'hello',
303
                        'age' => 17,
304
                        'number' => 500,
305
                    ];
306
                },
307
                [
308
                    'array.number' => ['Value must be no greater than 100.'],
309
                ],
310
            ],
311
            'rules-provider' => [
312
                new class () implements RulesProviderInterface {
313
                    private array $array = [
314
                        'name' => 'hello',
315
                        'age' => 17,
316
                        'number' => 500,
317
                    ];
318
319
                    public function getRules(): iterable
320
                    {
321
                        return [
322
                            'array' => new Nested(
323
                                new SimpleRulesProvider([
324
                                    'age' => new Number(min: 99),
325
                                ])
326
                            ),
327
                        ];
328
                    }
329
                },
330
                [
331
                    'array.age' => ['Value must be no less than 99.'],
332
                ],
333
            ],
334
            'empty-rules' => [
335
                new class () {
336
                    #[Nested([])]
337
                    private ObjectWithDifferentPropertyVisibility $object;
338
339
                    public function __construct()
340
                    {
341
                        $this->object = new ObjectWithDifferentPropertyVisibility();
342
                    }
343
                },
344
                [],
345
            ],
346
            'wo-rules' => [
347
                new class () {
348
                    #[Nested]
349
                    private ObjectWithDifferentPropertyVisibility $object;
350
351
                    public function __construct()
352
                    {
353
                        $this->object = new ObjectWithDifferentPropertyVisibility();
354
                    }
355
                },
356
                [
357
                    'object.name' => ['Value cannot be blank.'],
358
                    'object.age' => ['Value must be no less than 21.'],
359
                ],
360
            ],
361
            'wo-rules-only-public' => [
362
                new class () {
363
                    #[Nested(validatedObjectPropertyVisibility: ReflectionProperty::IS_PUBLIC)]
364
                    private ObjectWithDifferentPropertyVisibility $object;
365
366
                    public function __construct()
367
                    {
368
                        $this->object = new ObjectWithDifferentPropertyVisibility();
369
                    }
370
                },
371
                [
372
                    'object.name' => ['Value cannot be blank.'],
373
                ],
374
            ],
375
            'wo-rules-only-protected' => [
376
                new class () {
377
                    #[Nested(validatedObjectPropertyVisibility: ReflectionProperty::IS_PROTECTED)]
378
                    private ObjectWithDifferentPropertyVisibility $object;
379
380
                    public function __construct()
381
                    {
382
                        $this->object = new ObjectWithDifferentPropertyVisibility();
383
                    }
384
                },
385
                [
386
                    'object.age' => ['Value must be no less than 21.'],
387
                ],
388
            ],
389
            'wo-rules-inherit-attributes' => [
390
                new class () {
391
                    #[Nested]
392
                    private InheritAttributesObject $object;
393
394
                    public function __construct()
395
                    {
396
                        $this->object = new InheritAttributesObject();
397
                    }
398
                },
399
                [
400
                    'object.age' => [
401
                        'Value must be no less than 21.',
402
                        'Value must be equal to "23".',
403
                    ],
404
                    'object.number' => ['Value must be equal to "99".'],
405
                ],
406
            ],
407
            'nested-into-each' => [
408
                new Foo(),
409
                [
410
                    'name' => ['Value cannot be blank.'],
411
                    'bars.0.name' => ['Value cannot be blank.'],
412
                ],
413
            ],
414
        ];
415
    }
416
417
    /**
418
     * @dataProvider dataHandler
419
     */
420
    public function testHandler(object $data, array $expectedErrorMessagesIndexedByPath): void
421
    {
422
        $result = (new Validator())->validate($data);
423
        $this->assertSame($expectedErrorMessagesIndexedByPath, $result->getErrorMessagesIndexedByPath());
424
    }
425
426
    public function testPropagateOptions(): void
427
    {
428
        $rule = new Nested([
429
            'posts' => [
430
                new Each([
431
                    new Nested([
432
                        'title' => [new HasLength(min: 3)],
433
                        'authors' => [
434
                            new Each([
435
                                new Nested([
436
                                    'name' => [new HasLength(min: 5)],
437
                                    'age' => [
438
                                        new Number(min: 18),
439
                                        new Number(min: 20),
440
                                    ],
441
                                ]),
442
                            ]),
443
                        ],
444
                    ]),
445
                ]),
446
            ],
447
            'meta' => [new HasLength(min: 7)],
448
        ], propagateOptions: true, skipOnEmpty: true, skipOnError: true);
449
        $options = $rule->getOptions();
450
        $paths = [
451
            [],
452
            ['rules', 'posts', 0],
453
            ['rules', 'posts', 0, 'rules', 0],
454
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'title', 0],
455
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0],
456
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0],
457
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'name', 0],
458
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'age', 0],
459
            ['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'age', 1],
460
            ['rules', 'meta', 0],
461
        ];
462
        $keys = ['skipOnEmpty', 'skipOnError'];
463
464
        foreach ($paths as $path) {
465
            foreach ($keys as $key) {
466
                $fullPath = $path;
467
                $fullPath[] = $key;
468
469
                $value = ArrayHelper::getValueByPath($options, $fullPath);
470
                $this->assertTrue($value);
471
            }
472
        }
473
    }
474
475
    public function testNestedWithoutRulesWithObject(): void
476
    {
477
        $validator = new Validator();
478
        $result = $validator->validate(new ObjectWithNestedObject());
479
480
        $this->assertFalse($result->isValid());
481
        $this->assertSame(
482
            [
483
                'caption' => ['This value must contain at least 3 characters.'],
484
                'object.name' => ['This value must contain at least 5 characters.'],
485
            ],
486
            $result->getErrorMessagesIndexedByPath()
487
        );
488
    }
489
490
    public function dataWithOtherNestedAndEach(): array
491
    {
492
        $data = [
493
            'charts' => [
494
                [
495
                    'points' => [
496
                        ['coordinates' => ['x' => -11, 'y' => 11], 'rgb' => [-1, 256, 0]],
497
                        ['coordinates' => ['x' => -12, 'y' => 12], 'rgb' => [0, -2, 257]],
498
                    ],
499
                ],
500
                [
501
                    'points' => [
502
                        ['coordinates' => ['x' => -1, 'y' => 1], 'rgb' => [0, 0, 0]],
503
                        ['coordinates' => ['x' => -2, 'y' => 2], 'rgb' => [255, 255, 255]],
504
                    ],
505
                ],
506
                [
507
                    'points' => [
508
                        ['coordinates' => ['x' => -13, 'y' => 13], 'rgb' => [-3, 258, 0]],
509
                        ['coordinates' => ['x' => -14, 'y' => 14], 'rgb' => [0, -4, 259]],
510
                    ],
511
                ],
512
            ],
513
        ];
514
        $xRules = [
515
            new Number(min: -10, max: 10),
516
            new Callback(static function (mixed $value, object $rule, ValidationContext $context): Result {
0 ignored issues
show
Unused Code introduced by
The parameter $value is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

516
            new Callback(static function (/** @scrutinizer ignore-unused */ mixed $value, object $rule, ValidationContext $context): Result {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $rule is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

516
            new Callback(static function (mixed $value, /** @scrutinizer ignore-unused */ object $rule, ValidationContext $context): Result {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $context is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

516
            new Callback(static function (mixed $value, object $rule, /** @scrutinizer ignore-unused */ ValidationContext $context): Result {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
517
                $result = new Result();
518
                $result->addError('Custom error.');
519
520
                return $result;
521
            }),
522
        ];
523
        $yRules = [new Number(min: -10, max: 10)];
524
        $rgbRules = [
525
            new Count(exactly: 3),
526
            new Each([new Number(min: 0, max: 255)]),
527
        ];
528
529
        $detailedErrorsData = [
530
            [['charts', 0, 'points', 0, 'coordinates', 'x'], 'Value must be no less than -10.'],
531
            [['charts', 0, 'points', 0, 'coordinates', 'x'], 'Custom error.'],
532
            [['charts', 0, 'points', 0, 'coordinates', 'y'], 'Value must be no greater than 10.'],
533
            [['charts', 0, 'points', 0, 'rgb', 0], 'Value must be no less than 0.'],
534
            [['charts', 0, 'points', 0, 'rgb', 1], 'Value must be no greater than 255.'],
535
            [['charts', 0, 'points', 1, 'coordinates', 'x'], 'Value must be no less than -10.'],
536
            [['charts', 0, 'points', 1, 'coordinates', 'x'], 'Custom error.'],
537
            [['charts', 0, 'points', 1, 'coordinates', 'y'], 'Value must be no greater than 10.'],
538
            [['charts', 0, 'points', 1, 'rgb', 1], 'Value must be no less than 0.'],
539
            [['charts', 0, 'points', 1, 'rgb', 2], 'Value must be no greater than 255.'],
540
            [['charts', 1, 'points', 0, 'coordinates', 'x'], 'Custom error.'],
541
            [['charts', 1, 'points', 1, 'coordinates', 'x'], 'Custom error.'],
542
            [['charts', 2, 'points', 0, 'coordinates', 'x'], 'Value must be no less than -10.'],
543
            [['charts', 2, 'points', 0, 'coordinates', 'x'], 'Custom error.'],
544
            [['charts', 2, 'points', 0, 'coordinates', 'y'], 'Value must be no greater than 10.'],
545
            [['charts', 2, 'points', 0, 'rgb', 0], 'Value must be no less than 0.'],
546
            [['charts', 2, 'points', 0, 'rgb', 1], 'Value must be no greater than 255.'],
547
            [['charts', 2, 'points', 1, 'coordinates', 'x'], 'Value must be no less than -10.'],
548
            [['charts', 2, 'points', 1, 'coordinates', 'x'], 'Custom error.'],
549
            [['charts', 2, 'points', 1, 'coordinates', 'y'], 'Value must be no greater than 10.'],
550
            [['charts', 2, 'points', 1, 'rgb', 1], 'Value must be no less than 0.'],
551
            [['charts', 2, 'points', 1, 'rgb', 2], 'Value must be no greater than 255.'],
552
        ];
553
        $detailedErrors = [];
554
        foreach ($detailedErrorsData as $errorData) {
555
            $detailedErrors[] = [$errorData[1], $errorData[0]];
556
        }
557
558
        $errorMessages = [
559
            'Value must be no less than -10.',
560
            'Custom error.',
561
            'Value must be no greater than 10.',
562
            'Value must be no less than 0.',
563
            'Value must be no greater than 255.',
564
            'Value must be no less than -10.',
565
            'Custom error.',
566
            'Value must be no greater than 10.',
567
            'Value must be no less than 0.',
568
            'Value must be no greater than 255.',
569
            'Custom error.',
570
            'Custom error.',
571
            'Value must be no less than -10.',
572
            'Custom error.',
573
            'Value must be no greater than 10.',
574
            'Value must be no less than 0.',
575
            'Value must be no greater than 255.',
576
            'Value must be no less than -10.',
577
            'Custom error.',
578
            'Value must be no greater than 10.',
579
            'Value must be no less than 0.',
580
            'Value must be no greater than 255.',
581
        ];
582
        $errorMessagesIndexedByPath = [
583
            'charts.0.points.0.coordinates.x' => ['Value must be no less than -10.', 'Custom error.'],
584
            'charts.0.points.0.coordinates.y' => ['Value must be no greater than 10.'],
585
            'charts.0.points.0.rgb.0' => ['Value must be no less than 0.'],
586
            'charts.0.points.0.rgb.1' => ['Value must be no greater than 255.'],
587
            'charts.0.points.1.coordinates.x' => ['Value must be no less than -10.', 'Custom error.'],
588
            'charts.0.points.1.coordinates.y' => ['Value must be no greater than 10.'],
589
            'charts.0.points.1.rgb.1' => ['Value must be no less than 0.'],
590
            'charts.0.points.1.rgb.2' => ['Value must be no greater than 255.'],
591
            'charts.1.points.0.coordinates.x' => ['Custom error.'],
592
            'charts.1.points.1.coordinates.x' => ['Custom error.'],
593
            'charts.2.points.0.coordinates.x' => ['Value must be no less than -10.', 'Custom error.'],
594
            'charts.2.points.0.coordinates.y' => ['Value must be no greater than 10.'],
595
            'charts.2.points.0.rgb.0' => ['Value must be no less than 0.'],
596
            'charts.2.points.0.rgb.1' => ['Value must be no greater than 255.'],
597
            'charts.2.points.1.coordinates.x' => ['Value must be no less than -10.', 'Custom error.'],
598
            'charts.2.points.1.coordinates.y' => ['Value must be no greater than 10.'],
599
            'charts.2.points.1.rgb.1' => ['Value must be no less than 0.'],
600
            'charts.2.points.1.rgb.2' => ['Value must be no greater than 255.'],
601
        ];
602
603
        return [
604
            'base' => [
605
                $data,
606
                [
607
                    new Nested([
608
                        'charts' => [
609
                            new Each([
610
                                new Nested([
611
                                    'points' => [
612
                                        new Each([
613
                                            new Nested([
614
                                                'coordinates' => new Nested([
615
                                                    'x' => $xRules,
616
                                                    'y' => $yRules,
617
                                                ]),
618
                                                'rgb' => $rgbRules,
619
                                            ]),
620
                                        ]),
621
                                    ],
622
                                ]),
623
                            ]),
624
                        ],
625
                    ]),
626
                ],
627
                $detailedErrors,
628
                $errorMessages,
629
                $errorMessagesIndexedByPath,
630
            ],
631
            // https://github.com/yiisoft/validator/issues/195
632
            'withShortcut' => [
633
                $data,
634
                [
635
                    new Nested([
636
                        'charts.*.points.*.coordinates.x' => $xRules,
637
                        'charts.*.points.*.coordinates.y' => $yRules,
638
                        'charts.*.points.*.rgb' => $rgbRules,
639
                    ]),
640
                ],
641
                $detailedErrors,
642
                $errorMessages,
643
                $errorMessagesIndexedByPath,
644
            ],
645
            'withShortcutAndWithoutShortcut' => [
646
                array_merge($data, ['active' => true]),
647
                [
648
                    new Nested([
649
                        'charts.*.points.*.coordinates.x' => $xRules,
650
                        'charts.*.points.*.coordinates.y' => $yRules,
651
                        'charts.*.points.*.rgb' => $rgbRules,
652
                        'active' => new BooleanValue(),
653
                    ]),
654
                ],
655
                $detailedErrors,
656
                $errorMessages,
657
                $errorMessagesIndexedByPath,
658
            ],
659
            'withShortcutAndGrouping' => [
660
                $data,
661
                [
662
                    new Nested([
663
                        'charts.*.points.*.coordinates' => new Nested([
664
                            'x' => $xRules,
665
                            'y' => $yRules,
666
                        ]),
667
                        'charts.*.points.*.rgb' => $rgbRules,
668
                    ]),
669
                ],
670
                $detailedErrors,
671
                $errorMessages,
672
                $errorMessagesIndexedByPath,
673
            ],
674
            'withShortcutAndKeysContainingSeparatorAndShortcut' => [
675
                [
676
                    'charts.list' => [
677
                        [
678
                            'points*list' => [
679
                                [
680
                                    'coordinates.data' => ['x' => -11, 'y' => 11],
681
                                    'rgb' => [-1, 256, 0],
682
                                ],
683
                            ],
684
                        ],
685
                    ],
686
                ],
687
                [
688
                    new Nested([
689
                        'charts\.list.*.points\*list.*.coordinates\.data.x' => $xRules,
690
                        'charts\.list.*.points\*list.*.coordinates\.data.y' => $yRules,
691
                        'charts\.list.*.points\*list.*.rgb' => $rgbRules,
692
                    ]),
693
                ],
694
                [
695
                    [
696
                        $errorMessages[0],
697
                        ['charts.list', 0, 'points*list', 0, 'coordinates.data', 'x'],
698
                    ],
699
                    [
700
                        $errorMessages[1],
701
                        ['charts.list', 0, 'points*list', 0, 'coordinates.data', 'x'],
702
                    ],
703
                    [
704
                        $errorMessages[2],
705
                        ['charts.list', 0, 'points*list', 0, 'coordinates.data', 'y'],
706
                    ],
707
                    [
708
                        $errorMessages[3],
709
                        ['charts.list', 0, 'points*list', 0, 'rgb', 0],
710
                    ],
711
                    [
712
                        $errorMessages[4],
713
                        ['charts.list', 0, 'points*list', 0, 'rgb', 1],
714
                    ],
715
                ],
716
                array_slice($errorMessages, 0, 5),
717
                [
718
                    'charts\.list.0.points\*list.0.coordinates\.data.x' => [$errorMessages[0], $errorMessages[1]],
719
                    'charts\.list.0.points\*list.0.coordinates\.data.y' => [$errorMessages[2]],
720
                    'charts\.list.0.points\*list.0.rgb.0' => [$errorMessages[3]],
721
                    'charts\.list.0.points\*list.0.rgb.1' => [$errorMessages[4]],
722
                ],
723
            ],
724
        ];
725
    }
726
727
    /**
728
     * @dataProvider dataWithOtherNestedAndEach
729
     */
730
    public function testWithOtherNestedAndEach(
731
        mixed $data,
732
        array $rules,
733
        array $expectedDetailedErrors,
734
        array $expectedErrorMessages,
735
        array $expectedErrorMessagesIndexedByPath
736
    ): void {
737
        $result = (new Validator())->validate($data, $rules);
738
739
        $errorsData = array_map(
740
            static fn (Error $error) => [
741
                $error->getMessage(),
742
                $error->getValuePath(),
743
            ],
744
            $result->getErrors()
745
        );
746
747
        $this->assertSame($expectedDetailedErrors, $errorsData);
748
        $this->assertSame($expectedErrorMessages, $result->getErrorMessages());
749
        $this->assertSame($expectedErrorMessagesIndexedByPath, $result->getErrorMessagesIndexedByPath());
750
    }
751
752
    public function dataValidationPassed(): array
753
    {
754
        return [
755
            [
756
                [
757
                    'author' => [
758
                        'name' => 'Dmitry',
759
                        'age' => 18,
760
                    ],
761
                ],
762
                [
763
                    new Nested([
764
                        'author.name' => [
765
                            new HasLength(min: 3),
766
                        ],
767
                    ]),
768
                ],
769
            ],
770
            [
771
                [
772
                    'author' => [
773
                        'name' => 'Dmitry',
774
                        'age' => 18,
775
                    ],
776
                ],
777
                [
778
                    new Nested([
779
                        'author' => [
780
                            new Required(),
781
                            new Nested([
782
                                'name' => [new HasLength(min: 3)],
783
                            ]),
784
                        ],
785
                    ]),
786
                ],
787
            ],
788
            'key not exists, skip empty' => [
789
                [
790
                    'author' => [
791
                        'name' => 'Dmitry',
792
                        'age' => 18,
793
                    ],
794
                ],
795
                [new Nested(['author.sex' => [new In(['male', 'female'], skipOnEmpty: true)]])],
796
            ],
797
            'keys containing separator, one nested rule' => [
798
                [
799
                    'author.data' => [
800
                        'name.surname' => 'Dmitriy',
801
                    ],
802
                ],
803
                [
804
                    new Nested([
805
                        'author\.data.name\.surname' => [
806
                            new HasLength(min: 3),
807
                        ],
808
                    ]),
809
                ],
810
            ],
811
            'keys containing separator, multiple nested rules' => [
812
                [
813
                    'author.data' => [
814
                        'name.surname' => 'Dmitriy',
815
                    ],
816
                ],
817
                [
818
                    new Nested([
819
                        'author\.data' => new Nested([
820
                            'name\.surname' => [
821
                                new HasLength(min: 3),
822
                            ],
823
                        ]),
824
                    ]),
825
                ],
826
            ],
827
            'property path of non-integer and non-string type, array' => [
828
                [0 => 'a', 1 => 'b'],
829
                [new Nested([false => new HasLength(min: 1), true => new HasLength(min: 1)])],
830
            ],
831
            'property path of non-integer and non-string type, iterator' => [
832
                [0 => 'a', 1 => 'b'],
833
                [new Nested(new IteratorWithBooleanKey())],
834
            ],
835
            'property path of non-integer and non-string type, generator' => [
836
                [0 => 'a', 1 => 'b'],
837
                [
838
                    new Nested(
839
                        new class () implements RulesProviderInterface {
840
                            public function getRules(): iterable
841
                            {
842
                                yield false => new HasLength(min: 1);
843
                                yield true => new HasLength(min: 1);
844
                            }
845
                        },
846
                    ),
847
                ],
848
            ],
849
            'iterator in rules' => [
850
                ['user' => ['age' => 19]],
851
                [new Nested(new ArrayObject(['user.age' => new Number(min: 18)]))],
852
            ],
853
        ];
854
    }
855
856
    public function dataValidationFailed(): array
857
    {
858
        $incorrectDataSet = new class () implements DataSetInterface {
859
            public function getAttributeValue(string $attribute): mixed
860
            {
861
                return false;
862
            }
863
864
            public function getData(): ?array
865
            {
866
                return null;
867
            }
868
869
            public function hasAttribute(string $attribute): bool
870
            {
871
                return false;
872
            }
873
        };
874
875
        return [
876
            // No rules with no object
877
            'no rules with no object, array' => [
878
                new class () {
879
                    #[Nested]
880
                    public array $value = [];
881
                },
882
                null,
883
                ['value' => ['Nested rule without rules can be used for objects only.']],
884
            ],
885
            'no rules with no object, boolean' => [
886
                new class () {
887
                    #[Nested]
888
                    public bool $value = false;
889
                },
890
                null,
891
                ['value' => ['Nested rule without rules can be used for objects only.']],
892
            ],
893
            'no rules with no object, integer' => [
894
                new class () {
895
                    #[Nested]
896
                    public int $value = 42;
897
                },
898
                null,
899
                ['value' => ['Nested rule without rules can be used for objects only.']],
900
            ],
901
            'custom no rules with no object message' => [
902
                new class () {
903
                    #[Nested(noRulesWithNoObjectMessage: 'Custom no rules with no object message.')]
904
                    public array $value = [];
905
                },
906
                null,
907
                ['value' => ['Custom no rules with no object message.']],
908
            ],
909
            'custom no rules with no object message with parameters' => [
910
                new class () {
911
                    #[Nested(noRulesWithNoObjectMessage: 'Attribute - {attribute}, type - {type}.')]
912
                    public array $value = [];
913
                },
914
                null,
915
                ['value' => ['Attribute - value, type - array.']],
916
            ],
917
            // Incorrect data set type
918
            'incorrect data set type' => [
919
                $incorrectDataSet,
920
                [new Nested(['value' => new Required()])],
921
                ['' => ['An object data set data can only have an array or an object type.']],
922
            ],
923
            'custom incorrect data set type message' => [
924
                $incorrectDataSet,
925
                [
926
                    new Nested(
927
                        ['value' => new Required()],
928
                        incorrectDataSetTypeMessage: 'Custom incorrect data set type message.',
929
                    ),
930
                ],
931
                ['' => ['Custom incorrect data set type message.']],
932
            ],
933
            'custom incorrect data set type message with parameters' => [
934
                $incorrectDataSet,
935
                [new Nested(['value' => new Required()], incorrectDataSetTypeMessage: 'Type - {type}.')],
936
                ['' => ['Type - null.']],
937
            ],
938
            // Incorrect input
939
            'incorrect input' => [
940
                '',
941
                [new Nested(['value' => new Required()])],
942
                ['' => ['The value must have an array or an object type.']],
943
            ],
944
            'custom incorrect input message' => [
945
                '',
946
                [new Nested(['value' => new Required()], incorrectInputMessage: 'Custom incorrect input message.')],
947
                ['' => ['Custom incorrect input message.']],
948
            ],
949
            'custom incorrect input message with parameters' => [
950
                '',
951
                [
952
                    new Nested(
953
                        ['value' => new Required()],
954
                        incorrectInputMessage: 'Attribute - {attribute}, type - {type}.',
955
                    ),
956
                ],
957
                ['' => ['Attribute - , type - string.']],
958
            ],
959
            'custom incorrect input message with parameters, attribute set' => [
960
                ['data' => ''],
961
                [
962
                    'data' => new Nested(
963
                        ['value' => new Required()],
964
                        incorrectInputMessage: 'Attribute - {attribute}, type - {type}.',
965
                    ),
966
                ],
967
                ['data' => ['Attribute - data, type - string.']],
968
            ],
969
            'error' => [
970
                [
971
                    'author' => [
972
                        'name' => 'Alex',
973
                        'age' => 38,
974
                    ],
975
                ],
976
                [new Nested(['author.age' => [new Number(min: 40)]])],
977
                ['author.age' => ['Value must be no less than 40.']],
978
            ],
979
            'key not exists' => [
980
                [
981
                    'author' => [
982
                        'name' => 'Alex',
983
                        'age' => 38,
984
                    ],
985
                ],
986
                [new Nested(['author.sex' => [new In(['male', 'female'])]])],
987
                ['author.sex' => ['This value is not in the list of acceptable values.']],
988
            ],
989
            [
990
                ['value' => null],
991
                [new Nested(['value' => new Required()])],
992
                ['value' => ['Value cannot be blank.']],
993
            ],
994
            [
995
                [],
996
                [new Nested(['value' => new Required()], requirePropertyPath: true)],
997
                ['value' => ['Property "value" is not found.']],
998
            ],
999
            [
1000
                [],
1001
                [new Nested([0 => new Required()], requirePropertyPath: true)],
1002
                [0 => ['Property "0" is not found.']],
1003
            ],
1004
            // https://github.com/yiisoft/validator/issues/200
1005
            [
1006
                [
1007
                    'body' => [
1008
                        'shipping' => [
1009
                            'phone' => '+777777777777',
1010
                        ],
1011
                    ],
1012
                ],
1013
                [
1014
                    new Nested([
1015
                        'body.shipping' => [
1016
                            new Required(),
1017
                            new Nested([
1018
                                'phone' => [new Regex('/^\+\d{11}$/')],
1019
                            ]),
1020
                        ],
1021
                    ]),
1022
                ],
1023
                ['body.shipping.phone' => ['Value is invalid.']],
1024
            ],
1025
            [
1026
                [0 => [0 => -11]],
1027
                [
1028
                    new Nested([
1029
                        0 => new Nested([
1030
                            0 => [new Number(min: -10, max: 10)],
1031
                        ]),
1032
                    ]),
1033
                ],
1034
                ['0.0' => ['Value must be no less than -10.']],
1035
            ],
1036
            'custom error' => [
1037
                [],
1038
                [
1039
                    new Nested(
1040
                        ['value' => new Required()],
1041
                        requirePropertyPath: true,
1042
                        noPropertyPathMessage: 'Property is not found.',
1043
                    ),
1044
                ],
1045
                ['value' => ['Property is not found.']],
1046
            ],
1047
            [
1048
                new ObjectDataSet(
1049
                    new class () {
1050
                        private int $value = 7;
0 ignored issues
show
introduced by
The private property $value is not used, and could be removed.
Loading history...
1051
                    },
1052
                    ReflectionProperty::IS_PUBLIC
1053
                ),
1054
                new Nested(['value' => new Required()]),
1055
                ['value' => ['Value cannot be blank.']],
1056
            ],
1057
        ];
1058
    }
1059
1060
    public function dataValidationFailedWithDetailedErrors(): array
1061
    {
1062
        return [
1063
            'error' => [
1064
                [
1065
                    'author' => [
1066
                        'name' => 'Dmitry',
1067
                        'age' => 18,
1068
                    ],
1069
                ],
1070
                [new Nested(['author.age' => [new Number(min: 20)]])],
1071
                [['Value must be no less than 20.', ['author', 'age']]],
1072
            ],
1073
            'key not exists' => [
1074
                [
1075
                    'author' => [
1076
                        'name' => 'Dmitry',
1077
                        'age' => 18,
1078
                    ],
1079
                ],
1080
                [new Nested(['author.sex' => [new In(['male', 'female'])]])],
1081
                [['This value is not in the list of acceptable values.', ['author', 'sex']]],
1082
            ],
1083
            [
1084
                '',
1085
                [new Nested(['value' => new Required()])],
1086
                [['The value must have an array or an object type.', []]],
1087
            ],
1088
            [
1089
                ['value' => null],
1090
                [new Nested(['value' => new Required()])],
1091
                [['Value cannot be blank.', ['value']]],
1092
            ],
1093
            [
1094
                [],
1095
                [new Nested(['value1' => new Required(), 'value2' => new Required()], requirePropertyPath: true)],
1096
                [
1097
                    ['Property "value1" is not found.', ['value1']],
1098
                    ['Property "value2" is not found.', ['value2']],
1099
                ],
1100
            ],
1101
            [
1102
                // https://github.com/yiisoft/validator/issues/200
1103
                [
1104
                    'body' => [
1105
                        'shipping' => [
1106
                            'phone' => '+777777777777',
1107
                        ],
1108
                    ],
1109
                ],
1110
                [
1111
                    new Nested([
1112
                        'body.shipping' => [
1113
                            new Required(),
1114
                            new Nested([
1115
                                'phone' => [new Regex('/^\+\d{11}$/')],
1116
                            ]),
1117
                        ],
1118
                    ]),
1119
                ],
1120
                [['Value is invalid.', ['body', 'shipping', 'phone']]],
1121
            ],
1122
            [
1123
                [0 => [0 => -11]],
1124
                [
1125
                    new Nested([
1126
                        0 => new Nested([
1127
                            0 => [new Number(min: -10, max: 10)],
1128
                        ]),
1129
                    ]),
1130
                ],
1131
                [['Value must be no less than -10.', [0, 0]]],
1132
            ],
1133
            [
1134
                [
1135
                    'author.data' => [
1136
                        'name.surname' => 'Dmitriy',
1137
                    ],
1138
                ],
1139
                [new Nested(['author\.data.name\.surname' => [new HasLength(min: 8)]])],
1140
                [['This value must contain at least 8 characters.', ['author.data', 'name.surname']]],
1141
            ],
1142
        ];
1143
    }
1144
1145
    /**
1146
     * @dataProvider dataValidationFailedWithDetailedErrors
1147
     */
1148
    public function testValidationFailedWithDetailedErrors(mixed $data, array $rules, array $errors): void
1149
    {
1150
        $result = (new Validator())->validate($data, $rules);
1151
1152
        $errorsData = array_map(
1153
            static fn (Error $error) => [
1154
                $error->getMessage(),
1155
                $error->getValuePath(),
1156
            ],
1157
            $result->getErrors()
1158
        );
1159
1160
        $this->assertFalse($result->isValid());
1161
        $this->assertSame($errors, $errorsData);
1162
    }
1163
1164
    public function testInitWithNotARule(): void
1165
    {
1166
        $this->expectException(InvalidArgumentException::class);
1167
        $message = 'Every rule must be an instance of Yiisoft\Validator\RuleInterface, string given.';
1168
        $this->expectExceptionMessage($message);
1169
        new Nested([
1170
            'data' => new Nested([
1171
                'title' => [new HasLength(max: 255)],
1172
                'active' => [new BooleanValue(), 'Not a rule'],
1173
            ]),
1174
        ]);
1175
    }
1176
1177
    public function testSkipOnError(): void
1178
    {
1179
        $this->testSkipOnErrorInternal(new Nested(), new Nested(skipOnError: true));
1180
    }
1181
1182
    public function testWhen(): void
1183
    {
1184
        $when = static fn (mixed $value): bool => $value !== null;
1185
        $this->testWhenInternal(new Nested(), new Nested(when: $when));
1186
    }
1187
1188
    public function testInvalidRules(): void
1189
    {
1190
        $this->expectException(InvalidArgumentException::class);
1191
        $this->expectExceptionMessage(
1192
            'The $rules argument passed to Nested rule can be either: a null, an object implementing ' .
1193
            'RulesProviderInterface, a class string or an iterable.'
1194
        );
1195
        new Nested(new Required());
1196
    }
1197
1198
    public function testIterableMaximumLevel(): void
1199
    {
1200
        $this->expectException(InvalidArgumentException::class);
1201
        $message = 'The maximum nesting level for iterables is 2. For further nesting, use another instance instead.';
1202
        $this->expectExceptionMessage($message);
1203
        new Nested([
1204
            'attribute1' => [['attribute2' => new BooleanValue()]],
1205
        ]);
1206
    }
1207
1208
    public function testPropagateOptionsWithNullRules(): void
1209
    {
1210
        $rule = new Nested(null);
1211
        $rule->propagateOptions();
1212
        $this->assertNull($rule->getRules());
1213
    }
1214
1215
    protected function getDifferentRuleInHandlerItems(): array
1216
    {
1217
        return [Nested::class, NestedHandler::class];
1218
    }
1219
}
1220