diff --git a/src/Illuminate/Validation/NestedRules.php b/src/Illuminate/Validation/NestedRules.php new file mode 100644 index 000000000000..5099386f123e --- /dev/null +++ b/src/Illuminate/Validation/NestedRules.php @@ -0,0 +1,55 @@ +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]); + } +} diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index fba3e7c7dd44..adfbeeb03e2d 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -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. * @@ -103,4 +114,4 @@ public static function unique($table, $column = 'NULL') { return new Unique($table, $column); } -} +} \ No newline at end of file diff --git a/src/Illuminate/Validation/ValidationRuleParser.php b/src/Illuminate/Validation/ValidationRuleParser.php index 66f94c229537..3f9be5b5cbc8 100644 --- a/src/Illuminate/Validation/ValidationRuleParser.php +++ b/src/Illuminate/Validation/ValidationRuleParser.php @@ -68,7 +68,7 @@ protected function explodeRules($rules) unset($rules[$key]); } else { - $rules[$key] = $this->explodeExplicitRule($rule); + $rules[$key] = $this->explodeExplicitRule($rule, $key); } } @@ -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 + ); } /** * 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); @@ -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; } @@ -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]] + ); + + $results = $this->mergeRules($results, $compiled->rules); + } else { + $this->implicitAttributes[$attribute][] = $key; + + $results = $this->mergeRules($results, $key, $rule); + } } } } @@ -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; @@ -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, []]; } @@ -302,4 +328,4 @@ public static function filterConditionalRules($rules, array $data = []) })->filter()->flatten(1)->values()->all()]; })->all(); } -} +} \ No newline at end of file diff --git a/tests/Validation/ValidationNestedRulesTest.php b/tests/Validation/ValidationNestedRulesTest.php new file mode 100644 index 000000000000..a478fa8c2e3e --- /dev/null +++ b/tests/Validation/ValidationNestedRulesTest.php @@ -0,0 +1,215 @@ + [ + // 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' + ); + } +} diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 01dc86f12692..b383cfd718d3 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -9,7 +9,7 @@ class ValidationRuleParserTest extends TestCase { - public function test_conditional_rules_are_properly_expanded_and_filtered() + public function testConditionalRulesAreProperlyExpandedAndFiltered() { $rules = ValidationRuleParser::filterConditionalRules([ 'name' => Rule::when(true, ['required', 'min:2']), @@ -32,7 +32,7 @@ public function test_conditional_rules_are_properly_expanded_and_filtered() ], $rules); } - public function test_empty_rules_are_preserved() + public function testEmptyRulesArePreserved() { $rules = ValidationRuleParser::filterConditionalRules([ 'name' => [], @@ -47,7 +47,7 @@ public function test_empty_rules_are_preserved() ], $rules); } - public function test_conditional_rules_with_default() + public function testConditionalRulesWithDefault() { $rules = ValidationRuleParser::filterConditionalRules([ 'name' => Rule::when(true, ['required', 'min:2'], ['string', 'max:10']), @@ -66,7 +66,7 @@ public function test_conditional_rules_with_default() ], $rules); } - public function test_empty_conditional_rules_are_preserved() + public function testEmptyConditionalRulesArePreserved() { $rules = ValidationRuleParser::filterConditionalRules([ 'name' => Rule::when(true, '', ['string', 'max:10']), @@ -80,4 +80,169 @@ public function test_empty_conditional_rules_are_preserved() 'password' => ['string', 'max:10'], ], $rules); } -} + + public function testExplodeGeneratesNestedRules() + { + $parser = (new ValidationRuleParser([ + 'users' => [ + ['name' => 'Taylor Otwell'], + ], + ])); + + $results = $parser->explode([ + 'users.*.name' => Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals('users.0.name', $attribute); + $this->assertEquals('Taylor Otwell', $value); + $this->assertEquals($data['users.0.name'], 'Taylor Otwell'); + + return [Rule::requiredIf(true)]; + }), + ]); + + $this->assertEquals(['users.0.name' => ['required']], $results->rules); + $this->assertEquals(['users.*.name' => ['users.0.name']], $results->implicitAttributes); + } + + public function testExplodeGeneratesNestedRulesForNonNestedData() + { + $parser = (new ValidationRuleParser([ + 'name' => 'Taylor Otwell', + ])); + + $results = $parser->explode([ + 'name' => Rule::nested(function ($attribute, $value, $data = null) { + $this->assertEquals('name', $attribute); + $this->assertEquals('Taylor Otwell', $value); + $this->assertEquals(['name' => 'Taylor Otwell'], $data); + + return 'required'; + }), + ]); + + $this->assertEquals(['name' => ['required']], $results->rules); + $this->assertEquals([], $results->implicitAttributes); + } + + public function testExplodeHandlesArraysOfNestedRules() + { + $parser = (new ValidationRuleParser([ + 'users' => [ + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + ], + ])); + + $results = $parser->explode([ + 'users.*.name' => [ + Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals([ + 'users.0.name' => 'Taylor Otwell', + 'users.1.name' => 'Abigail Otwell', + ], $data); + + return [Rule::requiredIf(true)]; + }), + Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals([ + 'users.0.name' => 'Taylor Otwell', + 'users.1.name' => 'Abigail Otwell', + ], $data); + + return [ + $value === 'Taylor Otwell' + ? Rule::in('taylor') + : Rule::in('abigail'), + ]; + }), + ], + ]); + + $this->assertEquals([ + 'users.0.name' => ['required', 'in:"taylor"'], + 'users.1.name' => ['required', 'in:"abigail"'], + ], $results->rules); + + $this->assertEquals([ + 'users.*.name' => [ + 'users.0.name', + 'users.0.name', + 'users.1.name', + 'users.1.name', + ], + ], $results->implicitAttributes); + } + + public function testExplodeHandlesRecursivelyNestedRules() + { + $parser = (new ValidationRuleParser([ + 'users' => [['name' => 'Taylor Otwell']], + ])); + + $results = $parser->explode([ + 'users.*.name' => [ + Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals('users.0.name', $attribute); + $this->assertEquals('Taylor Otwell', $value); + $this->assertEquals(['users.0.name' => 'Taylor Otwell'], $data); + + return Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals('users.0.name', $attribute); + $this->assertNull($value); + $this->assertEquals(['users.0.name' => 'Taylor Otwell'], $data); + + return Rule::nested(function ($attribute, $value, $data) { + $this->assertEquals('users.0.name', $attribute); + $this->assertNull($value); + $this->assertEquals(['users.0.name' => 'Taylor Otwell'], $data); + + return [Rule::requiredIf(true)]; + }); + }); + }), + ], + ]); + + $this->assertEquals(['users.0.name' => ['required']], $results->rules); + $this->assertEquals(['users.*.name' => ['users.0.name']], $results->implicitAttributes); + } + + public function testExplodeHandlesSegmentingNestedRules() + { + $parser = (new ValidationRuleParser([ + 'items' => [ + ['discounts' => [['id' => 1], ['id' => 2]]], + ['discounts' => [['id' => 1], ['id' => 2]]], + ], + ])); + + $rules = [ + 'items.*' => Rule::nested(function () { + return ['discounts.*.id' => 'distinct']; + }), + ]; + + $results = $parser->explode($rules); + + $this->assertEquals([ + 'items.0.discounts.0.id' => ['distinct'], + 'items.0.discounts.1.id' => ['distinct'], + 'items.1.discounts.0.id' => ['distinct'], + 'items.1.discounts.1.id' => ['distinct'], + ], $results->rules); + + $this->assertEquals([ + 'items.1.discounts.*.id' => [ + 'items.1.discounts.0.id', + 'items.1.discounts.1.id', + ], + 'items.0.discounts.*.id' => [ + 'items.0.discounts.0.id', + 'items.0.discounts.1.id', + ], + 'items.*' => [ + 'items.0', + 'items.1', + ], + ], $results->implicitAttributes); + } +} \ No newline at end of file