diff --git a/README.md b/README.md index f35eaa4..471ebb2 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,33 @@ See [rules-aliasing](https://github.com/koorchik/LIVR#rules-aliasing) in LIVR sp ## $validator->getRules() returns array containing all ruleBuilders for the validator. You can register new rule or update existing one with "registerRules" method. +## $validator->getFieldRule($fieldName) +returns all rules of the field as one rule: `function ($value, $allValues, &$output)`, which returns an error code. It runs the rules like "validate" does for the field: in order, each rule gets the output of the previous one. `$output` gets the output of the rules, or null if the field has no rules. + +Use it in meta rules that validate many values with the same rules: it skips the result and errors arrays that "validate" makes for every call. Built-in "list\_of" and "or" work this way. + +```php + Validator\LIVR::registerDefaultRules([ 'my_list_of' => function ($rules, $ruleBuilders) { + $validator = new Validator\LIVR([ 'item' => $rules ]); + $validateItem = $validator->registerRules($ruleBuilders)->getFieldRule('item'); + + return function ($values, $params, &$outputArr) use ($validateItem) { + $output = []; + foreach ($values as $value) { + $itemOutput = null; + $errorCode = $validateItem($value, [ 'item' => $value ], $itemOutput); + if ($errorCode) { + return $errorCode; + } + $output[] = $itemOutput; + } + $outputArr = $output; + }; + } ]); + + $validator = new Validator\LIVR([ 'tags' => [ 'my_list_of' => [ [ 'required', 'to_lc' ] ] ] ]); +``` + # AUTHORS * antonfin (Anton Morozov) * k0stik (Konstantin Dvornik) diff --git a/lib/Validator/LIVR.php b/lib/Validator/LIVR.php index 4a45d7b..72606d1 100644 --- a/lib/Validator/LIVR.php +++ b/lib/Validator/LIVR.php @@ -19,6 +19,8 @@ class LIVR private $isAutoTrim = false; private static $IS_DEFAULT_AUTO_TRIM = 0; + private static $BUILDER_CLOSURES = array(); // rule builders given as strings => closures + private static $DEFAULT_RULES = array( 'required' => 'Validator\LIVR\Rules\Common::required', 'not_empty' => 'Validator\LIVR\Rules\Common::notEmpty', @@ -99,7 +101,7 @@ public function __construct($livrRules, $isAutoTrim = false) } $this->livrRules = $livrRules; - $this->registerRules(self::$DEFAULT_RULES); + $this->validatorBuilders = self::$DEFAULT_RULES; } public function prepare() @@ -119,7 +121,7 @@ public function prepare() foreach ($fieldRules as $rule) { list($name, $args) = $this->parseRule($rule); - array_push($validators, $this->buildValidator($name, $args)); + $validators[] = $this->buildValidator($name, $args); } $this->validators[$field] = $validators; @@ -148,34 +150,35 @@ public function validate($data) $result = array(); foreach ($this->validators as $fieldName => $validators) { - if (count($validators) == 0) { - continue; - } - $value = isset($data[$fieldName]) ? $data[$fieldName] : null; - $isOk = true; + // The output of the previous rules, the same as $result[$fieldName] + $hasOutput = false; + $output = null; foreach ($validators as $vCb) { - $fieldResult = array_key_exists($fieldName, $result) ? $result[$fieldName] : $value; + $input = $hasOutput ? $output : $value; + $fieldResult = $input; - $errCode = $vCb( - ( array_key_exists($fieldName, $result) ? $result[$fieldName] : $value ), - $data, - $fieldResult - ); + $errCode = $vCb($input, $data, $fieldResult); if ($errCode) { $errors[$fieldName] = $errCode; - $isOk = false; + $hasOutput = false; break; } elseif ($fieldResult !== null) { - $result[$fieldName] = $fieldResult; + $hasOutput = true; + $output = $fieldResult; } elseif (array_key_exists($fieldName, $data)) { - $result[$fieldName] = $value; + $hasOutput = true; + $output = $value; } } + + if ($hasOutput) { + $result[$fieldName] = $output; + } } if (count($errors) > 0) { @@ -195,8 +198,10 @@ public function getErrors() public function registerRules($rules) { - - $this->validatorBuilders = array_merge($this->validatorBuilders, $rules); + // Meta rules register the rules of their validator, usually the same as these ones + if ($rules !== $this->validatorBuilders) { + $this->validatorBuilders = array_merge($this->validatorBuilders, $rules); + } return $this; } @@ -215,13 +220,63 @@ public function getRules() return $this->validatorBuilders; } + /** + * Returns the rules of the field as one rule: function ($value, $allValues, &$output), + * which returns an error code. It runs the rules like validate() does for the field: + * in order, each rule gets the output of the previous one. $output gets the output + * of the rules, or null if the field has no rules. + * + * Meta rules use it to validate many values without the arrays that validate() makes. + */ + public function getFieldRule($fieldName) + { + if (!$this->isPrepared) { + $this->prepare(); + } + + $validators = isset($this->validators[$fieldName]) ? $this->validators[$fieldName] : array(); + + return function ($value, $allValues, &$output) use ($validators) { + // A field without rules is not in the output of validate() + if (!$validators) { + $output = null; + return null; + } + + if ($this->isAutoTrim) { + $value = $this->autoTrim($value); + $allValues = $this->autoTrim($allValues); + } + + $output = $value; + + foreach ($validators as $vCb) { + $input = $output; + $fieldResult = $input; + + $errCode = $vCb($input, $allValues, $fieldResult); + + if ($errCode) { + return $errCode; + } + + // Like in validate(), where the field is in the data + $output = $fieldResult !== null ? $fieldResult : $value; + } + + return null; + }; + } + private function parseRule($livrRule) { if (\Validator\LIVR\Util::isAssocArray($livrRule)) { - reset($livrRule); - $name = key($livrRule); - - $args = $livrRule[$name]; + // The first key and value, without reset(), which copies the array + $name = null; + $args = null; + foreach ($livrRule as $name => $args) { + break; + } if (!is_array($args) || \Validator\LIVR\Util::isAssocArray($args)) { $args = array($args); @@ -241,10 +296,20 @@ private function buildValidator($name, $args) throw new \Exception("Rule [$name] not registered"); } - $funcArgs = $args; - array_push($funcArgs, $this->validatorBuilders); + $builder = $this->validatorBuilders[$name]; + + // A "Class::method" string is resolved on every call, a closure is not + if (is_string($builder)) { + if (!isset(self::$BUILDER_CLOSURES[$builder])) { + self::$BUILDER_CLOSURES[$builder] = \Closure::fromCallable($builder); + } + + $builder = self::$BUILDER_CLOSURES[$builder]; + } + + $args[] = $this->validatorBuilders; - return call_user_func_array($this->validatorBuilders[$name], $funcArgs); + return $builder(...$args); } private static function buildAliasedRule($alias) @@ -258,18 +323,19 @@ private static function buildAliasedRule($alias) return function ($ruleBuilders) use ($alias) { $validator = new \Validator\LIVR(array('value' => $alias['rules'])); - $validator->registerRules($ruleBuilders)->prepare(); + $validateValue = $validator->registerRules($ruleBuilders)->getFieldRule('value'); + $aliasError = isset($alias['error']) ? $alias['error'] : null; - return function ($value, $params, &$outputArr) use ($validator, $alias) { - $result = $validator->validate(array('value' => $value)); + return function ($value, $params, &$outputArr) use ($validateValue, $aliasError) { + $output = null; + $errCode = $validateValue($value, array('value' => $value), $output); - if ($result) { - $outputArr = $result['value']; - return; - } else { - $errors = $validator->getErrors(); - return isset($alias['error']) ? $alias['error'] : $errors['value']; + if ($errCode) { + return $aliasError !== null ? $aliasError : $errCode; } + + $outputArr = $output; + return; }; }; } diff --git a/lib/Validator/LIVR/Rules/Meta.php b/lib/Validator/LIVR/Rules/Meta.php index 180f401..351a9a1 100644 --- a/lib/Validator/LIVR/Rules/Meta.php +++ b/lib/Validator/LIVR/Rules/Meta.php @@ -42,9 +42,9 @@ public static function listOf() } $validator = new \Validator\LIVR(array('field' => $livr)); - $validator->registerRules($ruleBuilders)->prepare(); + $validateItem = $validator->registerRules($ruleBuilders)->getFieldRule('field'); - return function ($values, $params, &$outputArr) use ($validator) { + return function ($values, $params, &$outputArr) use ($validateItem) { if (!isset($values) || $values === '') { return; } @@ -58,17 +58,17 @@ public static function listOf() $hasErrors = false; foreach ($values as $value) { - $result = $validator->validate(array('field' => $value)); + // Rules of an item see it as the only field of the data, like in validate() + $output = null; + $errCode = $validateItem($value, array('field' => $value), $output); - // The result is empty without rules - if ($result !== false && $result !== null) { - $results[] = isset($result['field']) ? $result['field'] : null; - $errors[] = null; - } else { + if ($errCode) { $results[] = null; - $validatorErrors = $validator->getErrors(); - $errors[] = $validatorErrors['field']; + $errors[] = $errCode; $hasErrors = true; + } else { + $results[] = $output; + $errors[] = null; } } @@ -212,27 +212,28 @@ public static function __or() $livrs = func_get_args(); $ruleBuilders = array_pop($livrs); - $validators = array(); + $fieldRules = array(); foreach ($livrs as $livr) { $validator = new \Validator\LIVR(array('field' => $livr)); - $validator->registerRules($ruleBuilders)->prepare(); - $validators[] = $validator; + $fieldRules[] = $validator->registerRules($ruleBuilders)->getFieldRule('field'); } - return function ($value, $params, &$outputArr) use ($validators) { + return function ($value, $params, &$outputArr) use ($fieldRules) { // Empty values go to the rule sets too, so "required" and "not_empty" in them work $lastError = null; - foreach ($validators as $validator) { - $result = $validator->validate(array('field' => $value)); + $allValues = array('field' => $value); - if ($result !== false && $result !== null) { - $outputArr = $result['field']; + foreach ($fieldRules as $fieldRule) { + $output = null; + $errCode = $fieldRule($value, $allValues, $output); + + if (!$errCode) { + $outputArr = $output; return; - } else { - $errors = $validator->getErrors(); - $lastError = $errors['field']; } + + $lastError = $errCode; } if ($lastError) { diff --git a/lib/Validator/LIVR/Rules/Special.php b/lib/Validator/LIVR/Rules/Special.php index b73e4d1..342cf6d 100644 --- a/lib/Validator/LIVR/Rules/Special.php +++ b/lib/Validator/LIVR/Rules/Special.php @@ -82,16 +82,14 @@ public static function isoDate() return 'FORMAT_ERROR'; } - $isoDateReg = '/^(\d{4})-(\d{2})-(\d{2})$/'; - - if (preg_match($isoDateReg, $value)) { - try { - $date = new \DateTime($value); - if ($date->format("Y-m-d") == $value) { - return; - } - } catch (\Exception $e) { - return "WRONG_DATE"; + // checkdate() instead of DateTime, which is several times slower. + // \z, not $: "$" also matches before a trailing newline + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})\z/', $value, $matches)) { + // checkdate() starts with year 1. Year 0 is a leap year, like 2000. + $year = (int) $matches[1] ?: 2000; + + if (checkdate((int) $matches[2], (int) $matches[3], $year)) { + return; } } diff --git a/lib/Validator/LIVR/Rules/Text.php b/lib/Validator/LIVR/Rules/Text.php index ddc2cda..0391f88 100644 --- a/lib/Validator/LIVR/Rules/Text.php +++ b/lib/Validator/LIVR/Rules/Text.php @@ -15,12 +15,13 @@ public static function oneOf() array_pop($allowedValues); # pop rule_builders } - $modifiedAllowedValues = array(); + // The allowed values as keys: isset() does not go through the whole list like in_array() + $allowedValuesSet = array(); foreach ($allowedValues as $v) { - $modifiedAllowedValues[] = (string) $v; + $allowedValuesSet[(string) $v] = true; } - return function ($value) use ($modifiedAllowedValues) { + return function ($value) use ($allowedValuesSet) { if (!isset($value) or $value === '') { return; } @@ -29,7 +30,7 @@ public static function oneOf() return 'FORMAT_ERROR'; } - if (! in_array((string) $value, $modifiedAllowedValues, true)) { + if (!isset($allowedValuesSet[(string) $value])) { return 'NOT_ALLOWED_VALUE'; } diff --git a/lib/Validator/LIVR/Util.php b/lib/Validator/LIVR/Util.php index 610e845..c03a7f5 100644 --- a/lib/Validator/LIVR/Util.php +++ b/lib/Validator/LIVR/Util.php @@ -11,7 +11,24 @@ public static function isAssocArray($arr) return false; } - return array_keys($arr) !== range(0, count($arr) - 1); + // An empty array is an object too, as PHP decodes {} and [] to the same array + if (!$arr) { + return true; + } + + if (\PHP_VERSION_ID >= 80100) { + return !\array_is_list($arr); + } + + // Without array_keys() and range(), which make two arrays + $index = 0; + foreach ($arr as $key => $value) { + if ($key !== $index++) { + return true; + } + } + + return false; } public static function isList($arr) diff --git a/tests/05-rules_edge_cases.php b/tests/05-rules_edge_cases.php index 40536b5..395cdf1 100644 --- a/tests/05-rules_edge_cases.php +++ b/tests/05-rules_edge_cases.php @@ -51,6 +51,50 @@ public function testRegisterDefaultRulesReplacesRules() } } + public function testGetFieldRule() + { + $validator = new Validator\LIVR(array( + 'name' => array('required', 'trim', 'to_uc'), + 'no_rules' => array(), + )); + + $nameRule = $validator->getFieldRule('name'); + + $output = null; + $this->assertNull($nameRule(' ab ', array('name' => ' ab '), $output), 'Should return no error'); + $this->assertSame('AB', $output, 'Should return the output of the last rule'); + + $this->assertSame('REQUIRED', $nameRule('', array('name' => ''), $output), 'Should return the error'); + + $noRules = $validator->getFieldRule('no_rules'); + $this->assertNull($noRules('x', array('no_rules' => 'x'), $output), 'Should return no error'); + $this->assertNull($output, 'Should return no output for a field without rules'); + + // The example from the README + $validator = new Validator\LIVR(array('tags' => array('my_list_of' => array(array('required', 'to_lc'))))); + $validator->registerRules(array('my_list_of' => function ($rules, $ruleBuilders) { + $validator = new Validator\LIVR(array('item' => $rules)); + $validateItem = $validator->registerRules($ruleBuilders)->getFieldRule('item'); + + return function ($values, $params, &$outputArr) use ($validateItem) { + $output = array(); + foreach ($values as $value) { + $itemOutput = null; + $errorCode = $validateItem($value, array('item' => $value), $itemOutput); + if ($errorCode) { + return $errorCode; + } + $output[] = $itemOutput; + } + $outputArr = $output; + }; + })); + + $this->assertEquals(array('tags' => array('a', 'b')), $validator->validate(array('tags' => array('A', 'B')))); + $this->assertFalse($validator->validate(array('tags' => array('A', ''))), 'Should return false'); + $this->assertEquals(array('tags' => 'REQUIRED'), $validator->getErrors(), 'Should return the item error'); + } + public function testEmptyListsOfRules() { $validator = new Validator\LIVR(array(