From 63558036c2b58befdc8ae1cda4ffee2fda959920 Mon Sep 17 00:00:00 2001 From: Will Leonardi Date: Mon, 17 Oct 2016 16:52:50 -0400 Subject: [PATCH] Created ValidationSet class and validation dependencies. This class can be made dependant on other validation sets. Allowing for unlimited dependency chaining. --- README.md | 23 +++ src/Valitron/ValidationSet.php | 195 +++++++++++++++++++++++ src/Valitron/ValidationSetInterface.php | 72 +++++++++ src/Valitron/Validator.php | 199 +++++++++++++++--------- tests/Valitron/ConditionTest.php | 87 +++++++++++ 5 files changed, 500 insertions(+), 76 deletions(-) create mode 100644 src/Valitron/ValidationSet.php create mode 100644 src/Valitron/ValidationSetInterface.php create mode 100644 tests/Valitron/ConditionTest.php diff --git a/README.md b/README.md index bf18d32..6155c54 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,29 @@ $v->validate(); This introduces a new set of tags to your error language file which looks like `{field}`, if you are using a rule like `equals` you can access the second value in the language file by incrementing the field with a value like `{field1}`. +## Conditional rules + +You can add a set of rules that depend on a condition. If the previous condition fails validation, it doesn't cause the validate() method to return false, but means the dependent set of rules won't be checked. For example: + +```php + $this->condition('min', 'age', 18, function($d) { + $d->rule('required', 'credit_card') + ->rule('creditCard, 'credit_card'); + }); +``` + +The age of use doesn't have to be more than 18, but if it is, we require them to supply a credit card, and validate. You can add more dependent rules inside a dependent rule set: +```php + $this->condition(function($c) { + $c->condition('min', 'age', 18, function($c2) { + //... And so on + }); + }); +``` + +You can even set labels and messages inside the dependent set! + + ## Re-use of validation rules You can re-use your validation rules to quickly validate different data with the same rules by using the withData method: diff --git a/src/Valitron/ValidationSet.php b/src/Valitron/ValidationSet.php new file mode 100644 index 0000000..44e045a --- /dev/null +++ b/src/Valitron/ValidationSet.php @@ -0,0 +1,195 @@ +parentValidator = $validator; + } + + /** + * @param string $rule + * @param string|array $fields + * @param mixed ... $arguments + * @return $this + */ + public function rule($rule, $fields) + { + // Get any other arguments passed to function + $params = array_slice(func_get_args(), 2); + + if (is_callable($rule) + && !(is_string($rule) && $this->parentValidator->hasValidator($rule))) + { + $name = $this->parentValidator->getUniqueRuleName($fields); + $msg = isset($params[0]) ? $params[0] : null; + $this->parentValidator->addInstanceRule($name, $rule, $msg); + $rule = $name; + } + + $rules = $this->parentValidator->getRules(); + if (!isset($rules[$rule])) { + $ruleMethod = 'validate' . ucfirst($rule); + if (!method_exists($this->parentValidator, $ruleMethod)) { + throw new \InvalidArgumentException("Rule '" . $rule . "' has not been registered with {".get_class($this->parentValidator)."::addRule()."); + } + } + + // Ensure rule has an accompanying message + $msgs = $this->parentValidator->getRuleMessages(); + $parentValidatorClass = get_class($this->parentValidator); + $message = isset($msgs[$rule]) ? $msgs[$rule] : $parentValidatorClass::ERROR_DEFAULT; + + $newRule = array( + 'rule' => $rule, + 'fields' => (array)$fields, + 'params' => (array)$params, + 'message' => '{field} ' . $message + ); + + $this->validations[] = $newRule; + + return $this; + } + + /** + * @param $rule + * @param $_ + * @return $this + * @internal param callable $callback + */ + public function condition($rule, $_) + { + $params = func_get_args(); + $callback = array_pop($params); + + if (!is_callable($callback)) { + throw new \InvalidArgumentException('Argument must be a valid callback. Given argument was not callable.'); + } + + $dependent = new self($this->parentValidator); + + $callback($dependent); + + call_user_func_array(array($this, 'rule'), $params); + $this->validations[count($this->validations) - 1]['dependent'] = $dependent; + + return $this; + } + + /** + * @param string $msg + * @return $this + */ + public function message($msg) + { + $lastValidation = count($this->validations) - 1; + $this->validations[$lastValidation]['message'] = $msg; + + return $this; + } + + /** + * @param string $value + * @internal param array $labels + * @return $this + */ + public function label($value) + { + $lastValidation = count($this->validations) - 1; + + $lastRules = $this->validations[$lastValidation]['fields']; + + $this->labels(array($lastRules[0] => $value)); + + return $this; + } + + /** + * @param array $labels + * @return string + */ + public function labels($labels) + { + $this->labels = array_merge($this->labels, $labels); + + return $this; + } + + /** + * Convenience method to add multiple validation rules with an array + * + * @param array $rules + */ + public function rules($rules) + { + foreach ($rules as $ruleType => $params) { + if (is_array($params)) { + foreach ($params as $innerParams) { + array_unshift($innerParams, $ruleType); + call_user_func_array(array($this, 'rule'), $innerParams); + } + } else { + $this->rule($ruleType, $params); + } + } + } + + protected function ruleExists($rule) { + return forward_static_call(array(get_class($this->parentValidator), 'ruleExists'), $rule); + } + + protected function getRuleMessage($rule) { + return forward_static_call(array(get_class($this->parentValidator), 'getRuleMessage'), $rule); + } + + /** + * @return array + */ + public function getValidations() + { + return $this->validations; + } + + /** + * @param $name + * @param $field + * @return boolean + */ + public function hasRule($name, $field) + { + foreach ($this->validations as $validation) { + if ($validation['rule'] == $name) { + if (in_array($field, $validation['fields'])) { + return true; + } + } + } + + return false; + } + + /** + * @return array + */ + public function getLabels() + { + return $this->labels; + } +} \ No newline at end of file diff --git a/src/Valitron/ValidationSetInterface.php b/src/Valitron/ValidationSetInterface.php new file mode 100644 index 0000000..45ff699 --- /dev/null +++ b/src/Valitron/ValidationSetInterface.php @@ -0,0 +1,72 @@ + * @link http://www.vancelucas.com/ */ -class Validator +class Validator implements ValidationSetInterface { /** * @var string @@ -27,11 +27,6 @@ class Validator */ protected $_errors = array(); - /** - * @var array - */ - protected $_validations = array(); - /** * @var array */ @@ -77,16 +72,26 @@ class Validator */ protected $validUrlPrefixes = array('http://', 'https://', 'ftp://'); + /** + * @var string + */ + protected $_lastInserted = 'rule'; + + /** + * @var ValidationSetInterface + */ + protected $_validationSet; + /** * Setup validation * - * @param array $data - * @param array $fields - * @param string $lang - * @param string $langDir - * @throws \InvalidArgumentException + * @param array $data + * @param array $fields + * @param string $lang + * @param string $langDir + * @param string $validationSetClass */ - public function __construct($data, $fields = array(), $lang = null, $langDir = null) + public function __construct($data, $fields = array(), $lang = null, $langDir = null, $validationSetClass = 'Valitron\ValidationSet') { // Allows filtering of used input fields against optional second array of field names allowed // This is useful for limiting raw $_POST or $_GET data to only known fields @@ -106,6 +111,12 @@ public function __construct($data, $fields = array(), $lang = null, $langDir = n } else { throw new \InvalidArgumentException("Fail to load language file '" . $langFile . "'"); } + + // Create default validationSet + if (!is_subclass_of($validationSetClass, 'Valitron\ValidationSetInterface')) { + throw new \InvalidArgumentException("Validation set class passed to constructor must implement Valitron\\ValidationSetInterface."); + } + $this->_validationSet = new $validationSetClass($this); } /** @@ -713,7 +724,7 @@ protected function validateCreditCard($field, $value, $params) $sum += $sub_total; } if ($sum > 0 && $sum % 10 == 0) { - return true; + return true; } return false; @@ -861,7 +872,7 @@ public function error($field, $msg, array $params = array()) */ public function message($msg) { - $this->_validations[count($this->_validations) - 1]['message'] = $msg; + $this->_validationSet->message($msg); return $this; } @@ -873,8 +884,10 @@ public function reset() { $this->_fields = array(); $this->_errors = array(); - $this->_validations = array(); $this->_labels = array(); + + $validationSetClass = get_class($this->_validationSet); + $this->_validationSet = new $validationSetClass($this); } protected function getPart($data, $identifiers) @@ -924,14 +937,27 @@ protected function getPart($data, $identifiers) */ public function validate() { - foreach ($this->_validations as $v) { + return $this->validateSet($this->_validationSet); + } + + /** + * Validate a set of rules, recursing into deeper validations sets. + * + * @param ValidationSetInterface $validationSet + * @return bool + */ + protected function validateSet(ValidationSetInterface $validationSet) + { + foreach ($validationSet->getValidations() as $v) { + $dependent = !empty($v['dependent']); + foreach ($v['fields'] as $field) { - list($values, $multiple) = $this->getPart($this->_fields, explode('.', $field)); + list($values, $multiple) = $this->getPart($this->_fields, explode('.', $field)); // Don't validate if the field is not required and the value is empty - if ($this->hasRule('optional', $field) && isset($values)) { + if ($validationSet->hasRule('optional', $field) && isset($values)) { //Continue with execution below if statement - } elseif ($v['rule'] !== 'required' && !$this->hasRule('required', $field) && (! isset($values) || $values === '' || ($multiple && count($values) == 0))) { + } elseif ($v['rule'] !== 'required' && !$validationSet->hasRule('required', $field) && (!isset($values) || $values === '' || ($multiple && count($values) == 0))) { continue; } @@ -952,10 +978,19 @@ public function validate() $result = $result && call_user_func($callback, $field, $value, $v['params'], $this->_fields); } + if (!empty($v['dependent'])) { + $dependent = $dependent && $result; + continue; + } + if (!$result) { $this->error($field, $v['message'], $v['params']); } } + + if ($dependent) { + $this->validateSet($v['dependent']); + } } return count($this->errors()) === 0; @@ -966,7 +1001,7 @@ public function validate() * * @return array */ - protected function getRules() + public function getRules() { return array_merge($this->_instanceRules, static::$_rules); } @@ -976,7 +1011,7 @@ protected function getRules() * * @return array */ - protected function getRuleMessages() + public function getRuleMessages() { return array_merge($this->_instanceRuleMessage, static::$_ruleMessages); } @@ -988,18 +1023,9 @@ protected function getRuleMessages() * @param string $field The name of the field * @return boolean */ - - protected function hasRule($name, $field) + public function hasRule($name, $field) { - foreach ($this->_validations as $validation) { - if ($validation['rule'] == $name) { - if (in_array($field, $validation['fields'])) { - return true; - } - } - } - - return false; + return $this->_validationSet->hasRule($name, $field); } protected static function assertRuleCallback($callback) @@ -1048,6 +1074,15 @@ public static function addRule($name, $callback, $message = null) static::$_ruleMessages[$name] = $message; } + /** + * @param $rule + * @return boolean + */ + public static function ruleExists($rule) + { + return isset(self::$_rules[$rule]); + } + public function getUniqueRuleName($fields) { if (is_array($fields)) @@ -1083,43 +1118,29 @@ public function hasValidator($name) /** * Convenience method to add a single validation rule * - * @param string|callback $rule - * @param array|string $fields + * @param string|callback $rule + * @param array|string $fields + * @param mixed ... $arguments * @return $this * @throws \InvalidArgumentException */ public function rule($rule, $fields) { - // Get any other arguments passed to function - $params = array_slice(func_get_args(), 2); - - if (is_callable($rule) - && !(is_string($rule) && $this->hasValidator($rule))) - { - $name = $this->getUniqueRuleName($fields); - $msg = isset($params[0]) ? $params[0] : null; - $this->addInstanceRule($name, $rule, $msg); - $rule = $name; - } + call_user_func_array(array($this->_validationSet, 'rule'), func_get_args()); - $errors = $this->getRules(); - if (!isset($errors[$rule])) { - $ruleMethod = 'validate' . ucfirst($rule); - if (!method_exists($this, $ruleMethod)) { - throw new \InvalidArgumentException("Rule '" . $rule . "' has not been registered with " . __CLASS__ . "::addRule()."); - } - } - - // Ensure rule has an accompanying message - $msgs = $this->getRuleMessages(); - $message = isset($msgs[$rule]) ? $msgs[$rule] : self::ERROR_DEFAULT; + return $this; + } - $this->_validations[] = array( - 'rule' => $rule, - 'fields' => (array) $fields, - 'params' => (array) $params, - 'message' => '{field} ' . $message - ); + /** + * Chain a rule to a previous rule. This rule won't be evaluated unless the parent rule passes. + * + * @param $rule + * @param $_ + * @return Validator + */ + public function condition($rule, $_) + { + call_user_func_array(array($this->_validationSet, 'condition'), func_get_args()); return $this; } @@ -1131,8 +1152,7 @@ public function rule($rule, $fields) */ public function label($value) { - $lastRules = $this->_validations[count($this->_validations) - 1]['fields']; - $this->labels(array($lastRules[0] => $value)); + $this->_validationSet->label($value); return $this; } @@ -1143,7 +1163,7 @@ public function label($value) */ public function labels($labels = array()) { - $this->_labels = array_merge($this->_labels, $labels); + $this->_validationSet->labels($labels); return $this; } @@ -1156,6 +1176,9 @@ public function labels($labels = array()) */ protected function checkAndSetLabel($field, $msg, $params) { + // Collect all labels from child validation sets + $this->collectLabels($this->_validationSet); + if (isset($this->_labels[$field])) { $msg = str_replace('{field}', $this->_labels[$field], $msg); @@ -1175,6 +1198,23 @@ protected function checkAndSetLabel($field, $msg, $params) return $msg; } + /** + * @param ValidationSetInterface $validationSet + * @return $this + */ + protected function collectLabels(ValidationSetInterface $validationSet) + { + foreach ($validationSet->getValidations() as $v) { + if (isset($v['dependent'])) { + $this->collectLabels($v['dependent']); + } + } + + $this->_labels = array_merge($validationSet->getLabels(), $this->_labels); + + return $this; + } + /** * Convenience method to add multiple validation rules with an array * @@ -1182,16 +1222,7 @@ protected function checkAndSetLabel($field, $msg, $params) */ public function rules($rules) { - foreach ($rules as $ruleType => $params) { - if (is_array($params)) { - foreach ($params as $innerParams) { - array_unshift($innerParams, $ruleType); - call_user_func_array(array($this, 'rule'), $innerParams); - } - } else { - $this->rule($ruleType, $params); - } - } + $this->_validationSet->rules($rules); } /** @@ -1199,7 +1230,7 @@ public function rules($rules) * * @param array $data * @param array $fields - * @return Valitron + * @return Validator */ public function withData($data, $fields = array()) { @@ -1208,4 +1239,20 @@ public function withData($data, $fields = array()) $clone->_errors = array(); return $clone; } + + /** + * @return array + */ + public function getValidations() + { + return $this->_validationSet->getValidations(); + } + + /** + * @return array + */ + public function getLabels() + { + return $this->_validationSet->getLabels(); + } } diff --git a/tests/Valitron/ConditionTest.php b/tests/Valitron/ConditionTest.php new file mode 100644 index 0000000..5070dab --- /dev/null +++ b/tests/Valitron/ConditionTest.php @@ -0,0 +1,87 @@ + 'John Doe', 'age' => '17')); + $v->condition('min', 'age', 17, function(ValidationSetInterface $c) { + $c->rule('required', 'name'); + }) + ->condition('max', 'age', 15, function(ValidationSetInterface $c) { + $c->rule('required', 'foo'); + }); + $this->assertTrue($v->validate()); + } + + public function testDependentRequired() + { + $v = new Validator(array('pets' => 'gerbil')); + $v->rule('required', 'pets'); + $v->condition('required', 'name', function(ValidationSetInterface $c) { + $c->rule('required', 'age'); + }); + $this->assertTrue($v->validate()); + } + + public function testDependentSuccessfulValidationTwoLevels() + { + $v = new Validator(array('name' => 'John Doe', 'age' => '17', 'pets' => 'dog')); + $v->condition('min', 'age', 17, function(ValidationSetInterface $c) { + $c->condition('required', 'name', function(ValidationSetInterface $c) { + $c->rule('in', 'pets', array('dog', 'cat', 'goldfish')); + }); + }) + ->condition('max', 'age', 15, function(ValidationSetInterface $c) { + $c->rule('required', 'foo'); + }); + $this->assertTrue($v->validate()); + } + + public function testDependentFailedValidation() + { + $v = new Validator(array('name' => 'John Doe', 'age' => '17')); + $v->condition('min', 'age', 17, function(ValidationSetInterface $c) { + $c->rule('required', 'name') + ->rule('required', 'foo'); + }); + $this->assertFalse($v->validate()); + } + + public function testDependentFailedValidationTwoLevels() + { + $v = new Validator(array('name' => 'John Doe', 'age' => '17', 'pets' => 'gerbil')); + $v->condition('min', 'age', 17, function(ValidationSetInterface $c) { + $c->condition('required', 'name', function(ValidationSetInterface $c) { + $c->rule('in', 'pets', array('dog', 'cat', 'goldfish')); + }); + }) + ->condition('max', 'age', 15, function(ValidationSetInterface $c) { + $c->rule('required', 'foo'); + }); + $this->assertFalse($v->validate()); + } + + public function testDependentSetLabel() + { + $v = new Validator(array('age' => '17')); + $v->condition('min', 'age', 17, function(ValidationSetInterface $c) { + $c->rule('required', 'name')->label('Your name') + ->rule('required', 'bar')->message('{field} Ouch.') + ->rule('required', 'buzz'); + }); + + $v->labels(array('bar' => 'A man walks into a bar.')); + + $v->validate(); + $expectedErrors = array( + 'name' => array('Your name is required'), + 'bar' => array('A man walks into a bar. Ouch.'), + 'buzz' => array('Buzz is required'), + ); + $this->assertEquals($expectedErrors, $v->errors()); + } +}