diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 0c44bf201c26..255b797927e7 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -1392,6 +1392,57 @@ public function validateRequiredIf($attribute, $value, $parameters) { $this->requireParameterCount(2, $parameters, 'required_if'); + [$values, $other] = $this->prepareValuesAndOther($parameters); + + if (in_array($other, $values)) { + return $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Indicate that an attribute should be excluded when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'exclude_if'); + + [$values, $other] = $this->prepareValuesAndOther($parameters); + + return ! in_array($other, $values); + } + + /** + * Indicate that an attribute should be excluded when another attribute does not have a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateExcludeUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'exclude_unless'); + + [$values, $other] = $this->prepareValuesAndOther($parameters); + + return in_array($other, $values); + } + + /** + * Prepare the values and the other value for validation. + * + * @param array $parameters + * @return array + */ + protected function prepareValuesAndOther($parameters) + { $other = Arr::get($this->data, $parameters[0]); $values = array_slice($parameters, 1); @@ -1400,11 +1451,7 @@ public function validateRequiredIf($attribute, $value, $parameters) $values = $this->convertValuesToBoolean($values); } - if (in_array($other, $values)) { - return $this->validateRequired($attribute, $value); - } - - return true; + return [$values, $other]; } /** diff --git a/src/Illuminate/Validation/Validator.php b/src/Illuminate/Validation/Validator.php index 7adabd6d5a13..df5e61a10159 100755 --- a/src/Illuminate/Validation/Validator.php +++ b/src/Illuminate/Validation/Validator.php @@ -48,6 +48,13 @@ class Validator implements ValidatorContract */ protected $failedRules = []; + /** + * Attributes that should be excluded from the validated data. + * + * @var array + */ + protected $excludeAttributes = []; + /** * The message bag instance. * @@ -177,6 +184,13 @@ class Validator implements ValidatorContract 'Before', 'After', 'BeforeOrEqual', 'AfterOrEqual', 'Gt', 'Lt', 'Gte', 'Lte', ]; + /** + * The validation rules that can exclude an attribute. + * + * @var array + */ + protected $excludeRules = ['ExcludeIf', 'ExcludeUnless']; + /** * The size related validation rules. * @@ -273,9 +287,23 @@ public function passes() foreach ($this->rules as $attribute => $rules) { $attribute = str_replace('\.', '->', $attribute); + // If this attribute is a nested rule, its parent might have already + // been excluded. If so, we have to remove the attribute. + if ($this->shouldBeExcluded($attribute)) { + $this->removeAttribute($attribute); + + continue; + } + foreach ($rules as $rule) { $this->validateAttribute($attribute, $rule); + if ($this->shouldBeExcluded($attribute)) { + $this->removeAttribute($attribute); + + break; + } + if ($this->shouldStopValidating($attribute)) { break; } @@ -292,6 +320,40 @@ public function passes() return $this->messages->isEmpty(); } + /** + * Determine if the attribute should be excluded. + * + * @param string $attribute + * + * @return bool + */ + protected function shouldBeExcluded($attribute) + { + foreach ($this->excludeAttributes as $excludeAttribute) { + if ($attribute === $excludeAttribute) { + return true; + } + + if (Str::startsWith($attribute, $excludeAttribute.'.')) { + return true; + } + } + + return false; + } + + /** + * Remove the given attribute. + * + * @param string $attribute + * + * @return void + */ + protected function removeAttribute($attribute) + { + unset($this->data[$attribute], $this->rules[$attribute]); + } + /** * Determine if the data fails the validation rules. * @@ -475,6 +537,10 @@ protected function replaceAsterisksInParameters(array $parameters, array $keys) */ protected function isValidatable($rule, $attribute, $value) { + if (in_array($rule, $this->excludeRules)) { + return true; + } + return $this->presentOrRuleIsImplicit($rule, $attribute, $value) && $this->passesOptionalCheck($attribute) && $this->isNotNullIfMarkedAsNullable($rule, $attribute) && @@ -621,6 +687,12 @@ public function addFailure($attribute, $rule, $parameters = []) $this->passes(); } + if (in_array($rule, $this->excludeRules)) { + $this->excludeAttributes[] = $attribute; + + return; + } + $this->messages->add($attribute, $this->makeReplacements( $this->getMessage($attribute, $rule), $attribute, $rule, $parameters )); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 3ca126cb9804..c4de1bf2fc64 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4782,6 +4782,289 @@ public function invalidUuidList() ]; } + public function providesPassingExcludeIfData() + { + return [ + [ + [ + 'has_appointment' => ['required', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,false', 'required', 'date'], + ], [ + 'has_appointment' => false, + 'appointment_date' => 'should be excluded', + ], [ + 'has_appointment' => false, + ], + ], + [ + [ + 'cat' => ['required', 'string'], + 'mouse' => ['exclude_if:cat,Tom', 'required', 'file'], + ], [ + 'cat' => 'Tom', + 'mouse' => 'should be excluded', + ], [ + 'cat' => 'Tom', + ], + ], + [ + [ + 'has_appointment' => ['required', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,false', 'required', 'date'], + ], [ + 'has_appointment' => false, + ], [ + 'has_appointment' => false, + ], + ], + [ + [ + 'has_appointment' => ['required', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,false', 'required', 'date'], + ], [ + 'has_appointment' => true, + 'appointment_date' => '2019-12-13', + ], [ + 'has_appointment' => true, + 'appointment_date' => '2019-12-13', + ], + ], + [ + [ + 'has_no_appointments' => ['required', 'bool'], + 'has_doctor_appointment' => ['exclude_if:has_no_appointments,true', 'required', 'bool'], + 'doctor_appointment_date' => ['exclude_if:has_no_appointments,true', 'exclude_if:has_doctor_appointment,false', 'required', 'date'], + ], [ + 'has_no_appointments' => true, + 'has_doctor_appointment' => true, + 'doctor_appointment_date' => '2019-12-13', + ], [ + 'has_no_appointments' => true, + ], + ], + [ + [ + 'has_no_appointments' => ['required', 'bool'], + 'has_doctor_appointment' => ['exclude_if:has_no_appointments,true', 'required', 'bool'], + 'doctor_appointment_date' => ['exclude_if:has_no_appointments,true', 'exclude_if:has_doctor_appointment,false', 'required', 'date'], + ], [ + 'has_no_appointments' => false, + 'has_doctor_appointment' => false, + 'doctor_appointment_date' => 'should be excluded', + ], [ + 'has_no_appointments' => false, + 'has_doctor_appointment' => false, + ], + ], + 'nested-01' => [ + [ + 'has_appointments' => ['required', 'bool'], + 'appointments.*' => ['exclude_if:has_appointments,false', 'required', 'date'], + ], [ + 'has_appointments' => false, + 'appointments' => ['2019-05-15', '2020-05-15'], + ], [ + 'has_appointments' => false, + ], + ], + 'nested-02' => [ + [ + 'has_appointments' => ['required', 'bool'], + 'appointments.*.date' => ['exclude_if:has_appointments,false', 'required', 'date'], + 'appointments.*.name' => ['exclude_if:has_appointments,false', 'required', 'string'], + ], [ + 'has_appointments' => false, + 'appointments' => [ + ['date' => 'should be excluded', 'name' => 'should be excluded'], + ], + ], [ + 'has_appointments' => false, + ], + ], + 'nested-03' => [ + [ + 'has_appointments' => ['required', 'bool'], + 'appointments' => ['exclude_if:has_appointments,false', 'required', 'array'], + 'appointments.*.date' => ['required', 'date'], + 'appointments.*.name' => ['required', 'string'], + ], [ + 'has_appointments' => false, + 'appointments' => [ + ['date' => 'should be excluded', 'name' => 'should be excluded'], + ], + ], [ + 'has_appointments' => false, + ], + ], + 'nested-04' => [ + [ + 'has_appointments' => ['required', 'bool'], + 'appointments.*.date' => ['required', 'date'], + 'appointments' => ['exclude_if:has_appointments,false', 'required', 'array'], + ], [ + 'has_appointments' => false, + 'appointments' => [ + ['date' => 'should be excluded', 'name' => 'should be excluded'], + ], + ], [ + 'has_appointments' => false, + ], + ], + ]; + } + + /** + * @dataProvider providesPassingExcludeIfData + */ + public function testExcludeIf($rules, $data, $expectedValidatedData) + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + $data, + $rules + ); + + $passes = $validator->passes(); + + if (! $passes) { + $message = sprintf("Validation unexpectedly failed:\nRules: %s\nData: %s\nValidation error: %s", + json_encode($rules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($validator->messages()->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } + + $this->assertTrue($passes, $message ?? ''); + + $this->assertSame($expectedValidatedData, $validator->validated()); + } + + public function providesFailingExcludeIfData() + { + return [ + [ + [ + 'has_appointment' => ['required', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,false', 'required', 'date'], + ], [ + 'has_appointment' => true, + ], [ + 'appointment_date' => ['validation.required'], + ], + ], + [ + [ + 'cat' => ['required', 'string'], + 'mouse' => ['exclude_if:cat,Tom', 'required', 'file'], + ], [ + 'cat' => 'Bob', + 'mouse' => 'not a file', + ], [ + 'mouse' => ['validation.file'], + ], + ], + [ + [ + 'has_appointments' => ['required', 'bool'], + 'appointments' => ['exclude_if:has_appointments,false', 'required', 'array'], + 'appointments.*.date' => ['required', 'date'], + 'appointments.*.name' => ['required', 'string'], + ], [ + 'has_appointments' => true, + 'appointments' => [ + ['date' => 'invalid', 'name' => 'Bob'], + ['date' => '2019-05-15'], + ], + ], [ + 'appointments.0.date' => ['validation.date'], + 'appointments.1.name' => ['validation.required'], + ], + ], + ]; + } + + /** + * @dataProvider providesFailingExcludeIfData + */ + public function testExcludeIfWhenValidationFails($rules, $data, $expectedMessages) + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + $data, + $rules + ); + + $fails = $validator->fails(); + + if (! $fails) { + $message = sprintf("Validation unexpectedly passed:\nRules: %s\nData: %s", + json_encode($rules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } + + $this->assertTrue($fails, $message ?? ''); + + $this->assertSame($expectedMessages, $validator->messages()->toArray()); + } + + public function testExcludeUnless() + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Felix', 'mouse' => 'Jerry'], + ['cat' => 'required|string', 'mouse' => 'exclude_unless:cat,Tom|required|string'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['cat' => 'Felix'], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Felix'], + ['cat' => 'required|string', 'mouse' => 'exclude_unless:cat,Tom|required|string'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['cat' => 'Felix'], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Tom', 'mouse' => 'Jerry'], + ['cat' => 'required|string', 'mouse' => 'exclude_unless:cat,Tom|required|string'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['cat' => 'Tom', 'mouse' => 'Jerry'], $validator->validated()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Tom'], + ['cat' => 'required|string', 'mouse' => 'exclude_unless:cat,Tom|required|string'] + ); + $this->assertTrue($validator->fails()); + $this->assertSame(['mouse' => ['validation.required']], $validator->messages()->toArray()); + } + + public function testExcludeValuesAreReallyRemoved() + { + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Tom', 'mouse' => 'Jerry'], + ['cat' => 'required|string', 'mouse' => 'exclude_if:cat,Tom|required|string'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['cat' => 'Tom'], $validator->validated()); + $this->assertSame(['cat' => 'Tom'], $validator->valid()); + $this->assertSame([], $validator->invalid()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['cat' => 'Tom', 'mouse' => null], + ['cat' => 'required|string', 'mouse' => 'exclude_if:cat,Felix|required|string'] + ); + $this->assertTrue($validator->fails()); + $this->assertSame(['cat' => 'Tom'], $validator->valid()); + $this->assertSame(['mouse' => null], $validator->invalid()); + } + protected function getTranslator() { return m::mock(TranslatorContract::class);