Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Add ability to generate unique validation rules per nested array element (repush) #40498

Merged
merged 6 commits into from
Jan 22, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/Illuminate/Validation/NestedRules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Illuminate\Validation;

use Illuminate\Support\Arr;

class NestedRules
{
/**
* The callback to execute.
*
* @var callable
*/
protected $callback;

/**
* Create a new nested rule instance.
*
* @param callable $callback
* @return void
*/
public function __construct(callable $callback)
{
$this->callback = $callback;
}

/**
* Compile the callback into an array of rules.
*
* @param string $attribute
* @param mixed $value
* @param mixed $data
* @return \stdClass
*/
public function compile($attribute, $value, $data = null)
{
$rules = call_user_func($this->callback, $attribute, $value, $data);

$parser = new ValidationRuleParser(
Arr::undot(Arr::wrap($data))
);

if (is_array($rules) && Arr::isAssoc($rules)) {
$nested = [];

foreach ($rules as $key => $rule) {
$nested[$attribute.'.'.$key] = $rule;
}

return $parser->explode($nested);
}

return $parser->explode([$attribute => $rules]);
}
}
11 changes: 11 additions & 0 deletions src/Illuminate/Validation/Rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ public static function when($condition, $rules, $defaultRules = [])
return new ConditionalRules($condition, $rules, $defaultRules);
}

/**
* Create a new nested rule set.
*
* @param callable $callback
* @return \Illuminate\Validation\NestedRules
*/
public static function nested($callback)
{
return new NestedRules($callback);
}

/**
* Get a dimensions constraint builder instance.
*
Expand Down
48 changes: 37 additions & 11 deletions src/Illuminate/Validation/ValidationRuleParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ protected function explodeRules($rules)

unset($rules[$key]);
} else {
$rules[$key] = $this->explodeExplicitRule($rule);
$rules[$key] = $this->explodeExplicitRule($rule, $key);
}
}

Expand All @@ -79,26 +79,34 @@ protected function explodeRules($rules)
* Explode the explicit rule into an array if necessary.
*
* @param mixed $rule
* @param string $attribute
* @return array
*/
protected function explodeExplicitRule($rule)
protected function explodeExplicitRule($rule, $attribute)
{
if (is_string($rule)) {
return explode('|', $rule);
} elseif (is_object($rule)) {
return [$this->prepareRule($rule)];
return Arr::wrap($this->prepareRule($rule, $attribute));
}

return array_map([$this, 'prepareRule'], $rule);
$attributes = array_fill(
array_key_first($rule), count($rule), $attribute
);

return array_map(
[$this, 'prepareRule'], $rule, $attributes
);
}
taylorotwell marked this conversation as resolved.
Show resolved Hide resolved

/**
* Prepare the given rule for the Validator.
*
* @param mixed $rule
* @param string $attribute
* @return mixed
*/
protected function prepareRule($rule)
protected function prepareRule($rule, $attribute)
{
if ($rule instanceof Closure) {
$rule = new ClosureValidationRule($rule);
Expand All @@ -111,6 +119,12 @@ protected function prepareRule($rule)
return $rule;
}

if ($rule instanceof NestedRules) {
return $rule->compile(
$attribute, $this->data[$attribute] ?? null, Arr::dot($this->data)
)->rules[$attribute];
}

return (string) $rule;
}

Expand All @@ -130,10 +144,22 @@ protected function explodeWildcardRules($results, $attribute, $rules)

foreach ($data as $key => $value) {
if (Str::startsWith($key, $attribute) || (bool) preg_match('/^'.$pattern.'\z/', $key)) {
foreach ((array) $rules as $rule) {
$this->implicitAttributes[$attribute][] = $key;

$results = $this->mergeRules($results, $key, $rule);
foreach (Arr::flatten((array) $rules) as $rule) {
if ($rule instanceof NestedRules) {
$compiled = $rule->compile($key, $value, $data);

$this->implicitAttributes = array_merge_recursive(
$compiled->implicitAttributes,
$this->implicitAttributes,
[$attribute => [$key]]
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this statement and why it is needed?

Copy link
Contributor Author

@stevebauman stevebauman Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes absolutely:

I'm using array_merge_recursive here so the implicitAttributes arrays (along with their sub arrays) are merged together properly per iteration. Any additional implicit attribute absolute dot paths will be appended properly to each asterisk dot path.

I also make sure to merge the current implicit attribute dot path using: [$attribute => [$key]] which is done in the else statement below. This "segments" the implicit attributes into their own sections -- and from what I understand, the validator will perform validation on these independently, allowing us to use distinct for example, to validate across the current array and not the entire array:

Example:

$rules = [
    'items.*' => Rule::nested(function () {
        return [
            'discounts.*.id' => Rule::nested(function () {
                return 'distinct';
            }),
        ];
    }),
];

$result = (new ValidationRuleParser(['...']))->explode($rules);

// {#65 ▼
//   +"rules": array:5 [▼
//     "items.0.discounts.0.id" => array:1 [▼
//       0 => "distinct"
//     ]
//     "items.0.discounts.1.id" => array:1 [▼
//       0 => "distinct"
//     ]
//     "items.0.discounts.2.id" => array:1 [▼
//       0 => "distinct"
//     ]
//     "items.1.discounts.0.id" => array:1 [▼
//       0 => "distinct"
//     ]
//     "items.1.discounts.1.id" => array:1 [▼
//       0 => "distinct"
//     ]
//   ]
//   +"implicitAttributes": array:3 [▼
//     "items.1.discounts.*.id" => array:2 [▼
//       0 => "items.1.discounts.0.id"
//       1 => "items.1.discounts.1.id"
//     ]
//     "items.0.discounts.*.id" => array:3 [▼
//       0 => "items.0.discounts.0.id"
//       1 => "items.0.discounts.1.id"
//       2 => "items.0.discounts.2.id"
//     ]
//     "items.*" => array:2 [▼
//       0 => "items.0"
//       1 => "items.1"
//     ]
//   ]
// }


$results = $this->mergeRules($results, $compiled->rules);
} else {
$this->implicitAttributes[$attribute][] = $key;

$results = $this->mergeRules($results, $key, $rule);
}
}
}
}
Expand Down Expand Up @@ -177,7 +203,7 @@ protected function mergeRulesForAttribute($results, $attribute, $rules)
$merge = head($this->explodeRules([$rules]));

$results[$attribute] = array_merge(
isset($results[$attribute]) ? $this->explodeExplicitRule($results[$attribute]) : [], $merge
isset($results[$attribute]) ? $this->explodeExplicitRule($results[$attribute], $attribute) : [], $merge
);

return $results;
Expand All @@ -191,7 +217,7 @@ protected function mergeRulesForAttribute($results, $attribute, $rules)
*/
public static function parse($rule)
{
if ($rule instanceof RuleContract) {
if ($rule instanceof RuleContract || $rule instanceof NestedRules) {
return [$rule, []];
}

Expand Down
215 changes: 215 additions & 0 deletions tests/Validation/ValidationNestedRulesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php

namespace Illuminate\Tests\Validation;

use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Mockery as m;
use PHPUnit\Framework\TestCase;

class ValidationNestedRulesTest extends TestCase
{
public function testNestedCallbacksCanProperlySegmentRules()
{
$data = [
'items' => [
// Contains duplicate ID.
['discounts' => [['id' => 1], ['id' => 1], ['id' => 2]]],
['discounts' => [['id' => 1], ['id' => 2]]],
],
];

$rules = [
'items.*' => Rule::nested(function () {
return ['discounts.*.id' => 'distinct'];
}),
];

$trans = $this->getIlluminateArrayTranslator();

$v = new Validator($trans, $data, $rules);

$this->assertFalse($v->passes());

$this->assertEquals([
'items.0.discounts.0.id' => ['validation.distinct'],
'items.0.discounts.1.id' => ['validation.distinct'],
], $v->getMessageBag()->toArray());
}

public function testNestedCallbacksCanBeRecursivelyNested()
{
$data = [
'items' => [
// Contains duplicate ID.
['discounts' => [['id' => 1], ['id' => 1], ['id' => 2]]],
['discounts' => [['id' => 1], ['id' => 2]]],
],
];

$rules = [
'items.*' => Rule::nested(function () {
return [
'discounts.*.id' => Rule::nested(function () {
return 'distinct';
}),
];
}),
];

$trans = $this->getIlluminateArrayTranslator();

$v = new Validator($trans, $data, $rules);

$this->assertFalse($v->passes());

$this->assertEquals([
'items.0.discounts.0.id' => ['validation.distinct'],
'items.0.discounts.1.id' => ['validation.distinct'],
], $v->getMessageBag()->toArray());
}

public function testNestedCallbacksCanReturnMultipleValidationRules()
{
$data = [
'items' => [
[
'discounts' => [
['id' => 1, 'percent' => 30, 'discount' => 1400],
['id' => 1, 'percent' => -1, 'discount' => 12300],
['id' => 2, 'percent' => 120, 'discount' => 1200],
],
],
[
'discounts' => [
['id' => 1, 'percent' => 30, 'discount' => 'invalid'],
['id' => 2, 'percent' => 'invalid', 'discount' => 1250],
['id' => 3, 'percent' => 'invalid', 'discount' => 'invalid'],
],
],
],
];
$rules = [
'items.*' => Rule::nested(function () {
return [
'discounts.*.id' => 'distinct',
'discounts.*' => Rule::nested(function () {
return [
'id' => 'distinct',
'percent' => 'numeric|min:0|max:100',
'discount' => 'numeric',
];
}),
];
}),
];

$trans = $this->getIlluminateArrayTranslator();

$v = new Validator($trans, $data, $rules);

$this->assertFalse($v->passes());

$this->assertEquals([
'items.0.discounts.0.id' => ['validation.distinct'],
'items.0.discounts.1.id' => ['validation.distinct'],
'items.0.discounts.1.percent' => ['validation.min.numeric'],
'items.0.discounts.2.percent' => ['validation.max.numeric'],
'items.1.discounts.0.discount' => ['validation.numeric'],
'items.1.discounts.1.percent' => ['validation.numeric'],
'items.1.discounts.2.percent' => ['validation.numeric'],
'items.1.discounts.2.discount' => ['validation.numeric'],
], $v->getMessageBag()->toArray());
}

public function testNestedCallbacksCanReturnArraysOfValidationRules()
{
$data = [
'items' => [
// Contains duplicate ID.
['discounts' => [['id' => 1], ['id' => 1], ['id' => 2]]],
['discounts' => [['id' => 1], ['id' => 'invalid']]],
],
];

$rules = [
'items.*' => Rule::nested(function () {
return ['discounts.*.id' => ['distinct', 'numeric']];
}),
];

$trans = $this->getIlluminateArrayTranslator();

$v = new Validator($trans, $data, $rules);

$this->assertFalse($v->passes());

$this->assertEquals([
'items.0.discounts.0.id' => ['validation.distinct'],
'items.0.discounts.1.id' => ['validation.distinct'],
'items.1.discounts.1.id' => ['validation.numeric'],
], $v->getMessageBag()->toArray());
}

public function testNestedCallbacksCanReturnDifferentRules()
{
$data = [
'items' => [
[
'discounts' => [
['id' => 1, 'type' => 'percent', 'discount' => 120],
['id' => 1, 'type' => 'absolute', 'discount' => 100],
['id' => 2, 'type' => 'percent', 'discount' => 50],
],
],
[
'discounts' => [
['id' => 2, 'type' => 'percent', 'discount' => 'invalid'],
['id' => 3, 'type' => 'absolute', 'discount' => 2000],
],
],
],
];

$rules = [
'items.*' => Rule::nested(function () {
return [
'discounts.*.id' => 'distinct',
'discounts.*.type' => 'in:percent,absolute',
'discounts.*' => Rule::nested(function ($attribute, $value) {
return $value['type'] === 'percent'
? ['discount' => 'numeric|min:0|max:100']
: ['discount' => 'numeric'];
}),
];
}),
];

$trans = $this->getIlluminateArrayTranslator();

$v = new Validator($trans, $data, $rules);

$this->assertFalse($v->passes());

$this->assertEquals([
'items.0.discounts.0.id' => ['validation.distinct'],
'items.0.discounts.1.id' => ['validation.distinct'],
'items.0.discounts.0.discount' => ['validation.max.numeric'],
'items.1.discounts.0.discount' => ['validation.numeric'],
], $v->getMessageBag()->toArray());
}

protected function getTranslator()
{
return m::mock(TranslatorContract::class);
}

public function getIlluminateArrayTranslator()
{
return new Translator(
new ArrayLoader, 'en'
);
}
}
Loading