Skip to content

Commit

Permalink
Merge branch 'feature-validation-nested-rules' into 9.x
Browse files Browse the repository at this point in the history
  • Loading branch information
taylorotwell committed Jan 22, 2022
2 parents 6bb9b4d + c897084 commit 911593b
Show file tree
Hide file tree
Showing 5 changed files with 485 additions and 15 deletions.
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, $value, $attribute, $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 @@ -81,6 +81,17 @@ public static function notIn($values)
return new NotIn(is_array($values) ? $values : func_get_args());
}

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

/**
* Get a required_if constraint builder instance.
*
Expand Down
46 changes: 35 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,32 @@ 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);
return array_map(
[$this, 'prepareRule'],
$rule,
array_fill(array_key_first($rule), count($rule), $attribute)
);
}

/**
* 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 +117,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 +142,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]]
);

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

$results = $this->mergeRules($results, $key, $rule);
}
}
}
}
Expand Down Expand Up @@ -177,7 +201,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 +215,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 ($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

0 comments on commit 911593b

Please sign in to comment.