diff --git a/README.md b/README.md index 471ebb2..bb00d55 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,31 @@ Features: * Rules are be able to change results output ("trim", "nested\_object", for example) * Multipurpose (user input validation, configs validation, contracts programming etc) +# LIVR 2.5 + +LIVR 2.5 is supported. It adds new rules to LIVR 2.0, and every LIVR 2.0 schema keeps working, except for the small fixes of LIVR 2.0 rules below. See the [specification](https://github.com/koorchik/LIVR/blob/master/drafts/v2.5.md), the [changes](https://livr-spec.org/changes.html#v2-5) and the [rules](https://livr-spec.org/validation-rules.html). + +Rules added in LIVR 2.5: + + * types and numbers: "boolean", "not\_one\_of", "max\_decimals" + * strings: "digits", "starts\_with", "ends\_with", "contains" + * dates and times: "iso\_date" with "min" and "max", "iso\_datetime", "iso\_time" + * presence and metarules: "required\_if", "when", "values", "keys", "any" + * lists and modifiers: "list\_length", "unique", "split" + * formats: "uuid", "ip", "cidr", "hostname", "base64", "hex", "digest", "mongo\_id", "hex\_color", "mac", "e164", "semver", "jwt" + * checksums: "luhn", "credit\_card", "iban" + +A schema with wrong arguments of these rules throws an exception when the validator is built. Arguments after the ones a rule takes are ignored, and a number and a string are interchangeable in them (`{"max_decimals": "2"}`, `{"split": 1}`). `{"rule": null}` is one `null` argument, which none of these rules takes, so `{"boolean": null}` and `{"uuid": null}` throw, like `null` as a value in the arguments (`{"not_one_of": ["a", null]}`). `{"iso_date": null}` is the plain "iso\_date", as in LIVR 2.0. The other LIVR 2.0 rules and the registered rules get `null` as one argument, as before (`{"default": null}`). + +Changes of LIVR 2.0 rules in LIVR 2.5: + + * "iso\_date": the first argument, if any, must be an options object or `null` (`{"iso_date": "today"}` throws), a number or a boolean gives "WRONG\_DATE" instead of "FORMAT\_ERROR"; + * "eq" and "one\_of" compare booleans as strings: `{"eq": true}` accepts `true` and `"true"`, `{"eq": "false"}` accepts `false`. They still accept what they accepted in LIVR 2.0, where `true` is `"1"`: `{"eq": true}` accepts `"1"` and `1`, `{"one_of": ["1"]}` accepts `true`; + * "equal\_to\_field" in aliases and "or" compares with a sibling of the field, in an item of "list\_of" the other field is missing; + * meta rules ignore extra arguments instead of losing the custom rules of the validator. + +PHP decodes `{}` and `[]` to the same empty array, so "values", "keys", "list\_length" and "unique" can't tell an empty object from an empty list, and an object with the keys "0", "1"... is a list. + # INSTALL Use Composer for installation LIVR. Create composer.json file with following content: @@ -91,6 +116,33 @@ $livr - validations rules. Rules description is available here - https://github. $isAutoTrim - asks validator to trim all values before validation. Output will be also trimmed. if isAutoTrim is undefined(or null) than defaultAutoTrim value will be used. +## new Validator\LIVR($livr, [ 'autoTrim' => $isAutoTrim ]); +The second argument can be an array of options. "autoTrim" is the same as $isAutoTrim above, but `[ 'autoTrim' => false ]` turns auto trim off even when defaultAutoTrim is on. + +## The current date +The bounds "today", "yesterday" and "tomorrow" of "iso\_date" and "iso\_datetime" are the current date in UTC, never in the local time zone. The rules read it with `time()` in the namespace `Validator\LIVR\Rules`, so tests can fix the clock with [php-mock](https://github.com/php-mock/php-mock-phpunit) (a dev dependency only): + +```php + use phpmock\phpunit\PHPMock; + + class BirthDateTest extends PHPUnit\Framework\TestCase + { + use PHPMock; + + public function testBirthDateIsInThePast() + { + $time = $this->getFunctionMock('Validator\LIVR\Rules', 'time'); + $time->expects($this->any())->willReturn(strtotime('2026-10-18T12:00:00Z')); + + $validator = new Validator\LIVR([ 'birth_date' => [ 'iso_date' => [ 'max' => 'today' ] ] ]); + $this->assertFalse($validator->validate([ 'birth_date' => '2026-10-19' ])); + } + } + + // The mock function must be defined before the rules call time() for the first time + BirthDateTest::defineFunctionMock('Validator\LIVR\Rules', 'time'); +``` + ## Validator\LIVR::registerDefaultRules([ "rule\_name" => ruleBuilder ]) ruleBuilder - is a function reference which will be called for building single rule validator. ```php @@ -213,7 +265,8 @@ Use it in meta rules that validate many values with the same rules: it skips the $output = []; foreach ($values as $value) { $itemOutput = null; - $errorCode = $validateItem($value, [ 'item' => $value ], $itemOutput); + // Items have no containing object: a reference to another field is a missing value + $errorCode = $validateItem($value, [], $itemOutput); if ($errorCode) { return $errorCode; } diff --git a/composer.json b/composer.json index 1302b90..12638b9 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ }, "require-dev": { "phpunit/phpunit": "^8.5.21 || ^9.6", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7", + "php-mock/php-mock-phpunit": "^2.6" }, "autoload": { "psr-4": { "Validator\\": "lib/Validator/" } diff --git a/composer.lock b/composer.lock index d3c1178..7bcfacc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0590609c2ffd88c71af1b1f14b6c4930", + "content-hash": "f3fed813bf49c3775be9ab457716b3cb", "packages": [], "packages-dev": [ { @@ -311,6 +311,213 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-mock/php-mock", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock.git", + "reference": "b59734f19765296bb0311942850d02288a224890" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock/zipball/b59734f19765296bb0311942850d02288a224890", + "reference": "b59734f19765296bb0311942850d02288a224890", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0 || ^8.0", + "phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6" + }, + "replace": { + "malkusch/php-mock": "*" + }, + "require-dev": { + "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "php-mock/php-mock-phpunit": "Allows integration into PHPUnit testcase with the trait PHPMock." + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "phpmock\\": [ + "classes/", + "tests/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "PHP-Mock can mock built-in PHP functions (e.g. time()). PHP-Mock relies on PHP's namespace fallback policy. No further extension is needed.", + "homepage": "https://github.com/php-mock/php-mock", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock/issues", + "source": "https://github.com/php-mock/php-mock/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-02-06T07:39:37+00:00" + }, + { + "name": "php-mock/php-mock-integration", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock-integration.git", + "reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock-integration/zipball/cbbf39705ec13dece5b04133cef4e2fd3137a345", + "reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "php-mock/php-mock": "^2.5", + "phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6" + }, + "require-dev": { + "phpunit/phpunit": "^5.7.27 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13" + }, + "type": "library", + "autoload": { + "psr-4": { + "phpmock\\integration\\": "classes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "Integration package for PHP-Mock", + "homepage": "https://github.com/php-mock/php-mock-integration", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "stub", + "test", + "test double" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock-integration/issues", + "source": "https://github.com/php-mock/php-mock-integration/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-02-06T07:44:43+00:00" + }, + { + "name": "php-mock/php-mock-phpunit", + "version": "2.16.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock-phpunit.git", + "reference": "b10a6eff57cd1636f8f06abe5c8d28a0ec2bf8b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock-phpunit/zipball/b10a6eff57cd1636f8f06abe5c8d28a0ec2bf8b3", + "reference": "b10a6eff57cd1636f8f06abe5c8d28a0ec2bf8b3", + "shasum": "" + }, + "require": { + "php": ">=7", + "php-mock/php-mock-integration": "^3.0", + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10.0.17 || ^11 || ^12.0.9 || ^13" + }, + "require-dev": { + "mockery/mockery": "^1.3.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "phpmock\\phpunit\\": "classes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "Mock built-in PHP functions (e.g. time()) with PHPUnit. This package relies on PHP's namespace fallback policy. No further extension is needed.", + "homepage": "https://github.com/php-mock/php-mock-phpunit", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "phpunit", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock-phpunit/issues", + "source": "https://github.com/php-mock/php-mock-phpunit/tree/2.16.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-10-02T09:10:45+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.32", diff --git a/lib/Validator/LIVR.php b/lib/Validator/LIVR.php index 72606d1..582fd65 100644 --- a/lib/Validator/LIVR.php +++ b/lib/Validator/LIVR.php @@ -64,6 +64,51 @@ class LIVR 'leave_only' => 'Validator\LIVR\Rules\Modifiers::leaveOnly', ); + // The rules of LIVR 2.5. They are looked up after the rules of a validator, so a + // registered rule with the same name replaces them, and validators copy and merge only the + // rules above: meta rules and aliases merge the rules of a validator into every nested one + private static $RULES_2_5 = array( + 'boolean' => 'Validator\LIVR\Rules\Types::boolean', + 'any' => 'Validator\LIVR\Rules\Types::any', + 'not_one_of' => 'Validator\LIVR\Rules\Types::notOneOf', + 'max_decimals' => 'Validator\LIVR\Rules\Types::maxDecimals', + + 'digits' => 'Validator\LIVR\Rules\Strings::digits', + 'starts_with' => 'Validator\LIVR\Rules\Strings::startsWith', + 'ends_with' => 'Validator\LIVR\Rules\Strings::endsWith', + 'contains' => 'Validator\LIVR\Rules\Strings::contains', + + 'iso_datetime' => 'Validator\LIVR\Rules\Dates::isoDatetime', + 'iso_time' => 'Validator\LIVR\Rules\Dates::isoTime', + + 'required_if' => 'Validator\LIVR\Rules\Conditional::requiredIf', + 'when' => 'Validator\LIVR\Rules\Conditional::when', + + 'values' => 'Validator\LIVR\Rules\Collections::values', + 'keys' => 'Validator\LIVR\Rules\Collections::keys', + 'list_length' => 'Validator\LIVR\Rules\Collections::listLength', + 'unique' => 'Validator\LIVR\Rules\Collections::unique', + 'split' => 'Validator\LIVR\Rules\Collections::split', + + 'uuid' => 'Validator\LIVR\Rules\Format::uuid', + 'ip' => 'Validator\LIVR\Rules\Format::ip', + 'cidr' => 'Validator\LIVR\Rules\Format::cidr', + 'hostname' => 'Validator\LIVR\Rules\Format::hostname', + 'base64' => 'Validator\LIVR\Rules\Format::base64', + 'hex' => 'Validator\LIVR\Rules\Format::hex', + 'digest' => 'Validator\LIVR\Rules\Format::digest', + 'mongo_id' => 'Validator\LIVR\Rules\Format::mongoId', + 'hex_color' => 'Validator\LIVR\Rules\Format::hexColor', + 'mac' => 'Validator\LIVR\Rules\Format::mac', + 'e164' => 'Validator\LIVR\Rules\Format::e164', + 'semver' => 'Validator\LIVR\Rules\Format::semver', + 'jwt' => 'Validator\LIVR\Rules\Format::jwt', + + 'luhn' => 'Validator\LIVR\Rules\Checksum::luhn', + 'credit_card' => 'Validator\LIVR\Rules\Checksum::creditCard', + 'iban' => 'Validator\LIVR\Rules\Checksum::iban', + ); + public static function registerDefaultRules($rules) { @@ -84,7 +129,7 @@ public static function registerAliasedDefaultRule($alias) public static function getDefaultRules() { - return self::$DEFAULT_RULES; + return self::$DEFAULT_RULES + self::$RULES_2_5; } public static function defaultAutoTrim($isAutoTrim) @@ -92,16 +137,22 @@ public static function defaultAutoTrim($isAutoTrim) self::$IS_DEFAULT_AUTO_TRIM = !!$isAutoTrim; } - public function __construct($livrRules, $isAutoTrim = false) + /** + * $options is the auto trim flag, or an array of options: 'autoTrim' => bool, defaultAutoTrim() + * when not given. Unlike the flag, array('autoTrim' => false) turns auto trim off when it is + * the default + */ + public function __construct($livrRules, $options = false) { - if ($isAutoTrim) { - $this->isAutoTrim = $isAutoTrim; - } else { - $this->isAutoTrim = self::$IS_DEFAULT_AUTO_TRIM; - } - $this->livrRules = $livrRules; $this->validatorBuilders = self::$DEFAULT_RULES; + + if (!is_array($options)) { + $this->isAutoTrim = $options ? $options : self::$IS_DEFAULT_AUTO_TRIM; + return; + } + + $this->isAutoTrim = isset($options['autoTrim']) ? !!$options['autoTrim'] : self::$IS_DEFAULT_AUTO_TRIM; } public function prepare() @@ -217,7 +268,7 @@ public function registerAliasedRule($alias) public function getRules() { - return $this->validatorBuilders; + return $this->validatorBuilders + self::$RULES_2_5; } /** @@ -243,9 +294,9 @@ public function getFieldRule($fieldName) return null; } + // $allValues come from the caller as they are, usually already trimmed data if ($this->isAutoTrim) { - $value = $this->autoTrim($value); - $allValues = $this->autoTrim($allValues); + $value = $this->autoTrim($value); } $output = $value; @@ -292,12 +343,14 @@ private function parseRule($livrRule) private function buildValidator($name, $args) { - if (!array_key_exists($name, $this->validatorBuilders)) { + if (array_key_exists($name, $this->validatorBuilders)) { + $builder = $this->validatorBuilders[$name]; + } elseif (isset(self::$RULES_2_5[$name])) { + $builder = self::$RULES_2_5[$name]; + } else { throw new \Exception("Rule [$name] not registered"); } - $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])) { @@ -326,9 +379,10 @@ private static function buildAliasedRule($alias) $validateValue = $validator->registerRules($ruleBuilders)->getFieldRule('value'); $aliasError = isset($alias['error']) ? $alias['error'] : null; + // Aliases are transparent: their rules see the siblings of the field that uses the alias return function ($value, $params, &$outputArr) use ($validateValue, $aliasError) { $output = null; - $errCode = $validateValue($value, array('value' => $value), $output); + $errCode = $validateValue($value, $params, $output); if ($errCode) { return $aliasError !== null ? $aliasError : $errCode; diff --git a/lib/Validator/LIVR/Arguments.php b/lib/Validator/LIVR/Arguments.php new file mode 100644 index 0000000..ac5c7d9 --- /dev/null +++ b/lib/Validator/LIVR/Arguments.php @@ -0,0 +1,72 @@ + 1 && self::get($args)) { + throw new \Exception("LIVR: \"$ruleName\" takes no arguments"); + } + } + + /** + * A string argument: a string, or the string form of a number. null for anything else + * (booleans, null, objects and lists are neither strings nor numbers) + */ + public static function toString($arg) + { + if (is_string($arg)) { + return $arg; + } + + return is_int($arg) || is_float($arg) ? Values::stringForm($arg) : null; + } + + /** + * A non-negative integer argument: 2, 2.0 (JSON does not tell 2.0 from 2) or "2". null for + * anything else + */ + public static function toCount($arg) + { + if (is_int($arg)) { + return $arg >= 0 ? $arg : null; + } + + if (is_float($arg)) { + return $arg >= 0 && $arg < PHP_INT_MAX && $arg == floor($arg) ? (int) $arg : null; + } + + // A string in the grammar of "decimal": "2.0" is 2 too + if (is_string($arg) && preg_match('/^[0-9]+(?:\.0+)?\z/', $arg)) { + return self::toCount((float) $arg); + } + + return null; + } +} diff --git a/lib/Validator/LIVR/Reference.php b/lib/Validator/LIVR/Reference.php new file mode 100644 index 0000000..70dae81 --- /dev/null +++ b/lib/Validator/LIVR/Reference.php @@ -0,0 +1,79 @@ + ['a', 'b', '0']. Not a string, an empty reference, + * a leading or a trailing "/", an empty segment ("a//b") and a wrong escape ("~2", a trailing + * "~") are schema errors. A leading "/" is reserved for references from the root + */ + public static function parse($reference, $ruleName) + { + if (!is_string($reference) || $reference === '') { + throw new \Exception("LIVR: a reference of \"$ruleName\" must be a non-empty string"); + } + + // The common case: a field name without escapes + if (strpbrk($reference, '/~') === false) { + return array($reference); + } + + $path = explode('/', $reference); + + foreach ($path as $i => $segment) { + if ($segment === '') { + throw new \Exception("LIVR: the reference \"$reference\" of \"$ruleName\" has an empty segment"); + } + + if (strpos($segment, '~') !== false) { + if (preg_match('/~(?![01])/', $segment)) { + throw new \Exception("LIVR: the reference \"$reference\" of \"$ruleName\" has a wrong escape"); + } + + $path[$i] = str_replace(array('~1', '~0'), array('/', '~'), $segment); + } + } + + return $path; + } + + /** + * The input value at the path from the containing object, null when a segment is missing or a + * value on the way is not an object (or a list for an index segment). On a list a segment is + * an index only in the form "0" or [1-9][0-9]* + */ + public static function resolve($data, $path) + { + $value = $data; + + foreach ($path as $segment) { + if (!is_array($value)) { + return null; + } + + // PHP has no empty object or list to tell apart: an empty array has no segments + if ($value && !Util::isAssocArray($value)) { + if (!preg_match('/^(?:0|[1-9][0-9]*)\z/', $segment)) { + return null; + } + + $segment = (int) $segment; + } + + if (!array_key_exists($segment, $value)) { + return null; + } + + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/lib/Validator/LIVR/Rules/Checksum.php b/lib/Validator/LIVR/Rules/Checksum.php new file mode 100644 index 0000000..4051b05 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Checksum.php @@ -0,0 +1,115 @@ += 0; $i--) { + $digit = ord($digits[$i]) - 48; + + if ($isDoubled) { + $digit *= 2; + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $isDoubled = !$isDoubled; + } + + return $sum % 10 === 0; + } + + // The first four characters moved to the end, letters replaced by two digits (A = 10 ... + // Z = 35): the number modulo 97 is 1. Computed a character at a time, without big numbers + private static function hasIbanChecksum($iban) + { + $length = strlen($iban); + $remainder = 0; + + for ($i = 0; $i < $length; $i++) { + $code = ord($iban[($i + 4) % $length]); + + if ($code <= 57) { + $remainder = ($remainder * 10 + $code - 48) % 97; + } else { + $remainder = ($remainder * 100 + $code - 55) % 97; + } + } + + return $remainder === 1; + } +} diff --git a/lib/Validator/LIVR/Rules/Collections.php b/lib/Validator/LIVR/Rules/Collections.php new file mode 100644 index 0000000..3f48c37 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Collections.php @@ -0,0 +1,319 @@ + $value) { + $output = null; + $errCode = $validateValue($value, array(), $output); + + if ($errCode) { + $errors[$key] = $errCode; + } elseif (!$errors) { + $result[$key] = $output; + } + } + + if ($errors) { + return $errors; + } + + $outputArr = $result; + }; + } + + /** + * The arguments are the same as in list_of: keys(['one_of' => ['uk', 'en']]), + * keys('required', ['max_length' => 2]) or keys(['required', ['max_length' => 2]]) + */ + public static function keys() + { + // Keys are checked as they are: no autoTrim, even when it is the default + $validateKey = self::buildItemRule(func_get_args(), 'keys', array('autoTrim' => false)); + + return function ($object) use ($validateKey) { + if ($object === null || $object === '') { + return; + } + + // An empty array is {}: PHP decodes {} and [] to the same array + if (!Util::isAssocArray($object)) { + return 'FORMAT_ERROR'; + } + + $errors = array(); + + // The rules only check: their output is ignored and the value stays unchanged. Keys + // have no containing object: a reference in their rules is a missing value + foreach ($object as $key => $value) { + $output = null; + + // PHP turns keys like "1" into integers + if ($validateKey((string) $key, array(), $output)) { + $errors[$key] = 'WRONG_KEY'; + } + } + + if ($errors) { + return $errors; + } + }; + } + + // The rules of values and keys as one rule, the arguments are the same as in list_of + private static function buildItemRule($args, $ruleName, $options) + { + $ruleBuilders = array_pop($args); + + if (count($args) === 1 && Util::isList($args[0])) { + $args = $args[0]; + } + + // {"values": null} is one null argument (LIVR 2.5, 2.6), and null in place of a rule is not a rule + if (!$args || in_array(null, $args, true)) { + throw new \Exception("LIVR: \"$ruleName\" needs rules"); + } + + $validator = new \Validator\LIVR(array('field' => $args), $options); + + return $validator->registerRules($ruleBuilders)->getFieldRule('field'); + } + + /** + * list_length(length), list_length(min, max), list_length({ min, max }). Numbers are + * non-negative integers, also as strings ("2"). The arguments after them are ignored + */ + public static function listLength() + { + $args = Arguments::get(func_get_args()); + + $min = 0; + $max = null; + + if ($args && is_array($args[0])) { + // An empty array is {}: PHP decodes {} and [] to the same array + $options = $args[0]; + + if (!$options || !Util::isAssocArray($options)) { + throw new \Exception('LIVR: "list_length" takes non-negative integers as "min" and "max"'); + } + + foreach ($options as $key => $option) { + if ($key !== 'min' && $key !== 'max') { + throw new \Exception("LIVR: \"list_length\" got an unknown option \"$key\""); + } + + if (Arguments::toCount($option) === null) { + throw new \Exception('LIVR: "list_length" takes non-negative integers as "min" and "max"'); + } + } + + if (isset($options['min'])) { + $min = Arguments::toCount($options['min']); + } + + if (isset($options['max'])) { + $max = Arguments::toCount($options['max']); + } + } else { + $min = $args ? Arguments::toCount($args[0]) : null; + $max = count($args) > 1 ? Arguments::toCount($args[1]) : $min; + + if ($min === null || $max === null) { + throw new \Exception( + 'LIVR: "list_length" takes a length, min and max, or an object with "min" and "max"' + ); + } + } + + if ($max !== null && $min > $max) { + throw new \Exception('LIVR: "list_length" got "min" greater than "max"'); + } + + return function ($list) use ($min, $max) { + // An empty list is a value: { min: 1 } rejects it + if ($list === null || $list === '') { + return; + } + + if (!Util::isList($list)) { + return 'FORMAT_ERROR'; + } + + $count = count($list); + + if ($count < $min) { + return 'TOO_FEW_ITEMS'; + } + + if ($max !== null && $count > $max) { + return 'TOO_MANY_ITEMS'; + } + }; + } + + /** + * unique(): primitive items, unique(...keys): objects with unique values of the keys. + * Items are compared in the string form, so 1 and "1" are duplicates. A type error wins over + * duplicates, wherever it is in the list + */ + public static function unique() + { + $keys = Arguments::get(func_get_args()); + + // Key names are strings, a number is its string form + foreach ($keys as $i => $key) { + $keys[$i] = Arguments::toString($key); + + if ($keys[$i] === null) { + throw new \Exception('LIVR: "unique" takes key names as strings'); + } + } + + if (!$keys) { + return function ($list) { + if ($list === null || $list === '') { + return; + } + + if (!Util::isList($list)) { + return 'FORMAT_ERROR'; + } + + $seen = array(); + $hasDuplicates = false; + + foreach ($list as $item) { + if ($item === null || $item === '') { + continue; + } + + if (!Values::isPrimitiveValue($item)) { + return 'FORMAT_ERROR'; + } + + $str = Values::stringForm($item); + if (isset($seen[$str])) { + $hasDuplicates = true; + } else { + $seen[$str] = true; + } + } + + if ($hasDuplicates) { + return 'NOT_UNIQUE_ITEMS'; + } + }; + } + + return function ($list) use ($keys) { + if ($list === null || $list === '') { + return; + } + + if (!Util::isList($list)) { + return 'FORMAT_ERROR'; + } + + $seen = array(); + $hasDuplicates = false; + + foreach ($list as $item) { + if ($item === null || $item === '') { + continue; + } + + if (!Util::isAssocArray($item)) { + return 'FORMAT_ERROR'; + } + + // The string forms of the key values with their lengths, so ["a,b"] and ["a", "b"] + // differ. An item with an empty key value is not compared, a key value of a wrong + // type is an error + $itemKey = ''; + $hasEmptyKeyValue = false; + + foreach ($keys as $key) { + $keyValue = isset($item[$key]) ? $item[$key] : null; + + if ($keyValue === null || $keyValue === '') { + $hasEmptyKeyValue = true; + } elseif (!Values::isPrimitiveValue($keyValue)) { + return 'FORMAT_ERROR'; + } else { + $str = Values::stringForm($keyValue); + $itemKey .= strlen($str) . ':' . $str; + } + } + + if ($hasEmptyKeyValue) { + continue; + } + + if (isset($seen[$itemKey])) { + $hasDuplicates = true; + } else { + $seen[$itemKey] = true; + } + } + + if ($hasDuplicates) { + return 'NOT_UNIQUE_ITEMS'; + } + }; + } + + public static function split() + { + // A non-empty string, a number is its string form. The arguments after it are ignored + $args = Arguments::get(func_get_args()); + $separator = $args ? Arguments::toString($args[0]) : null; + + if ($separator === null || $separator === '') { + throw new \Exception('LIVR: "split" takes a non-empty separator string'); + } + + // A modifier: lists, empty values and objects stay as they are, the next rule reports them + return function ($value, $params, &$outputArr) use ($separator) { + if (is_string($value)) { + // At every separator, empty parts are kept: "a,,b" -> ["a", "", "b"] + if ($value !== '') { + $outputArr = explode($separator, $value); + } + } elseif (is_int($value) || is_float($value) || is_bool($value)) { + // In the string form, like a string: 5 -> ["5"], true -> ["true"] + $outputArr = explode($separator, Values::stringForm($value)); + } + }; + } +} diff --git a/lib/Validator/LIVR/Rules/Conditional.php b/lib/Validator/LIVR/Rules/Conditional.php new file mode 100644 index 0000000..c5a26d7 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Conditional.php @@ -0,0 +1,201 @@ + $values) { + // PHP turns keys like "1" into integers + $reference = (string) $reference; + $paths[] = Reference::parse($reference, 'required_if'); + + // A list of values means "any of". An empty list never matches: a schema error, like "" + if ($values === array()) { + throw new \Exception( + 'LIVR: "required_if" takes a primitive value other than "" or a non-empty list of them for ' + . json_encode($reference) + ); + } + + $set = array(); + foreach (Util::isList($values) ? $values : array($values) as $value) { + if (!Values::isPrimitiveValue($value) || $value === '') { + throw new \Exception( + 'LIVR: "required_if" takes a primitive value other than "" or a list of them for ' + . json_encode($reference) + ); + } + + $set[Values::stringForm($value)] = true; + } + + $expected[] = $set; + } + + return function ($value, $params) use ($paths, $expected) { + if ($value !== null && $value !== '') { + return; + } + + // The field is required when every condition matches a non-empty primitive input value + foreach ($paths as $i => $path) { + $refValue = Reference::resolve($params, $path); + + if ($refValue === '' || !Values::isPrimitiveValue($refValue)) { + return; + } + + if (!isset($expected[$i][Values::stringForm($refValue)])) { + return; + } + } + + return 'REQUIRED'; + }; + } + + /** + * when(reference, condition, rulesIfTrue, [rulesIfFalse], ruleBuilders): the condition form, + * when(reference, cases, ruleBuilders): the switch form + */ + public static function when() + { + $args = func_get_args(); + $ruleBuilders = array_pop($args); + + if (count($args) < 2) { + throw new \Exception( + 'LIVR: "when" takes a reference and cases, or a reference, a condition, ' + . 'rules if true and optional rules if false, got ' . count($args) . ' arguments' + ); + } + + if (count($args) === 2) { + return self::whenSwitch(Reference::parse($args[0], 'when'), $args[1], $ruleBuilders); + } + + $path = Reference::parse($args[0], 'when'); + + // One validator for the condition and the branches: a validator costs more than its rules. + // The arguments after the 4th are ignored + $ruleSets = array_slice($args, 1, 3, true); + $fieldRules = self::buildRuleSets($ruleSets, $ruleBuilders); + + $conditionRule = $fieldRules[1]; + $rulesIfTrue = $fieldRules[2]; + $rulesIfFalse = isset($fieldRules[3]) ? $fieldRules[3] : null; + + return function ($value, $params, &$outputArr) use ($path, $conditionRule, $rulesIfTrue, $rulesIfFalse) { + $refValue = Reference::resolve($params, $path); + + // The condition rules see the siblings of the field, their output is discarded + $isTrue = $refValue !== null && $refValue !== ''; + if ($isTrue && $conditionRule) { + $conditionOutput = null; + $isTrue = !$conditionRule($refValue, $params, $conditionOutput); + } + + $branchRule = $isTrue ? $rulesIfTrue : $rulesIfFalse; + + // A false condition without rules if false: the value passes and stays unchanged + if (!$branchRule) { + return; + } + + $output = null; + $errCode = $branchRule($value, $params, $output); + + if ($errCode) { + return $errCode; + } + + $outputArr = $output; + }; + } + + private static function whenSwitch($path, $cases, $ruleBuilders) + { + // A list is not an object. PHP decodes {} and [] to the same array: it is an empty object + if (!is_array($cases) || ($cases && !Util::isAssocArray($cases))) { + throw new \Exception('LIVR: the cases of "when" must be an object of value to rules'); + } + + if (!$cases) { + throw new \Exception('LIVR: the cases of "when" must not be empty'); + } + + // Keys like "1" are integers in PHP arrays, and lookups by "1" find them + $caseRules = self::buildRuleSets($cases, $ruleBuilders); + + return function ($value, $params, &$outputArr) use ($path, $caseRules) { + $refValue = Reference::resolve($params, $path); + + // An empty or unknown referenced value chooses no case. An empty value passes unchanged + // (the referenced field reports its own errors), any other value would pass unchecked + if ($refValue === '' || !Values::isPrimitiveValue($refValue) + || !array_key_exists($caseValue = Values::stringForm($refValue), $caseRules) + ) { + return $value === null || $value === '' ? null : 'NOT_ALLOWED_VALUE'; + } + + $branchRule = $caseRules[$caseValue]; + + $output = null; + $errCode = $branchRule($value, $params, $output); + + if ($errCode) { + return $errCode; + } + + $outputArr = $output; + }; + } + + /** + * Rule sets in any form that a field accepts, each as one rule (see LIVR::getFieldRule()), + * all from one validator, with the keys of $ruleSets. An empty list of rules is a schema error + * (LIVR 2.5, 2.6): "any" is a rule set without rules + */ + private static function buildRuleSets($ruleSets, $ruleBuilders) + { + $livr = array(); + + foreach ($ruleSets as $key => $rules) { + if ($rules === array()) { + throw new \Exception('LIVR: an empty list of rules in "when", use "any" for no rules'); + } + + $livr["rules$key"] = $rules; + } + + $validator = new \Validator\LIVR($livr); + $validator->registerRules($ruleBuilders)->prepare(); + + $fieldRules = array(); + foreach ($ruleSets as $key => $rules) { + $fieldRules[$key] = $validator->getFieldRule("rules$key"); + } + + return $fieldRules; + } +} diff --git a/lib/Validator/LIVR/Rules/Dates.php b/lib/Validator/LIVR/Rules/Dates.php new file mode 100644 index 0000000..7e09ef6 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Dates.php @@ -0,0 +1,470 @@ + -1, 'today' => 0, 'tomorrow' => 1); + + // The day number (days since 1970-01-01) of the cached relative dates: they change once a + // day, and a validator created for every validation gets them without calendar arithmetic + private static $cachedDay = null; + private static $cachedDates = array(); + private static $cachedKeys = array(); + + // The UTC fields of the last datetime that parseDatetime() read + private static $utcYear; + private static $utcMonth; + private static $utcDay; + private static $utcMsOfDay; + + /** + * iso_date({ min, max }), called by Special::isoDate() for an options object + */ + public static function isoDateWithBounds($options) + { + self::checkOptions($options, 'iso_date', 'use "iso_datetime" for datetimes'); + + $min = array_key_exists('min', $options) ? self::parseDateBound($options['min'], 'min') : null; + $max = array_key_exists('max', $options) ? self::parseDateBound($options['max'], 'max') : null; + + // Fixed bounds are dates YYYY-MM-DD, compared as strings; relative ones are days from the + // current date. A fixed and a relative bound are not compared: that depends on the day + if ($min !== null && $max !== null && (is_int($min) ? is_int($max) && $min > $max + : is_string($max) && strcmp($min, $max) > 0) + ) { + throw new \Exception('LIVR: "iso_date" got "min" later than "max"'); + } + + $isRelative = is_int($min) || is_int($max); + + return function ($value) use ($min, $max, $isRelative) { + if (!is_string($value)) { + return self::notStringError($value, 'WRONG_DATE'); + } + + if ($value === '') { + return; + } + + if (!self::isDate($value)) { + return 'WRONG_DATE'; + } + + $today = $isRelative ? self::today() : 0; + + if ($min !== null && strcmp($value, is_int($min) ? self::relativeDate($today, $min) : $min) < 0) { + return 'DATE_TOO_EARLY'; + } + + if ($max !== null && strcmp($value, is_int($max) ? self::relativeDate($today, $max) : $max) > 0) { + return 'DATE_TOO_LATE'; + } + }; + } + + public static function isoDatetime() + { + $options = self::getOptions(func_get_args(), 'iso_datetime'); + + $min = array_key_exists('min', $options) ? self::parseDatetimeBound($options['min'], false) : null; + $max = array_key_exists('max', $options) ? self::parseDatetimeBound($options['max'], true) : null; + + // Fixed bounds are keys (cut to milliseconds), relative ones are [days] from the current + // date. A fixed and a relative bound are not compared: that depends on the day + if ($min !== null && $max !== null && (is_int($min) ? is_int($max) && $min > $max + : is_array($max) && $min[0] > $max[0]) + ) { + throw new \Exception('LIVR: "iso_datetime" got "min" later than "max"'); + } + + $isRelative = is_array($min) || is_array($max); + + return function ($value, $params, &$outputArr) use ($min, $max, $isRelative) { + if (!is_string($value)) { + return self::notStringError($value, 'WRONG_DATETIME'); + } + + if ($value === '') { + return; + } + + // An instant outside the years 0000-9999 in UTC cannot be written in the output form + if (!self::parseDatetime($value) || self::$utcYear < 0 || self::$utcYear > 9999) { + return 'WRONG_DATETIME'; + } + + if ($min !== null || $max !== null) { + $key = self::lastKey(); + $today = $isRelative ? self::today() : 0; + $lastMs = self::DAY_MS - 1; + + // A relative bound is the start of the day as "min" and its end as "max" + if ($min !== null && $key < (is_array($min) ? self::relativeKey($today, $min[0]) : $min)) { + return 'DATETIME_TOO_EARLY'; + } + + if ($max !== null && $key > (is_array($max) ? self::relativeKey($today, $max[0]) + $lastMs : $max)) { + return 'DATETIME_TOO_LATE'; + } + } + + // The instant in UTC with three fraction digits + $msOfDay = self::$utcMsOfDay; + $outputArr = sprintf( + '%04d-%02d-%02dT%02d:%02d:%02d.%03dZ', + self::$utcYear, + self::$utcMonth, + self::$utcDay, + intdiv($msOfDay, 3600000), + intdiv($msOfDay, 60000) % 60, + intdiv($msOfDay, 1000) % 60, + $msOfDay % 1000 + ); + }; + } + + /** + * "iso_time", or { iso_time: { min, max } }. With min greater than max the range goes over + * midnight: { min: "22:00", max: "06:00" } accepts 23:00 and 05:00 + */ + public static function isoTime() + { + $options = self::getOptions(func_get_args(), 'iso_time'); + + $min = array_key_exists('min', $options) ? self::parseTimeBound($options['min'], 'min') : null; + $max = array_key_exists('max', $options) ? self::parseTimeBound($options['max'], 'max') : null; + $isOverMidnight = $min !== null && $max !== null && $min > $max; + + return function ($value) use ($min, $max, $isOverMidnight) { + if (!is_string($value)) { + return self::notStringError($value, 'WRONG_TIME'); + } + + if ($value === '') { + return; + } + + $time = self::parseTime($value); + if ($time < 0) { + return 'WRONG_TIME'; + } + + if ($isOverMidnight) { + if ($time >= $min || $time <= $max) { + return; + } + + // The gap between max and min gets the error of the nearer bound, TIME_TOO_EARLY + // exactly in the middle + return $time - $max < $min - $time ? 'TIME_TOO_LATE' : 'TIME_TOO_EARLY'; + } + + if ($min !== null && $time < $min) { + return 'TIME_TOO_EARLY'; + } + + if ($max !== null && $time > $max) { + return 'TIME_TOO_LATE'; + } + }; + } + + /** + * The options object of iso_datetime and iso_time from the arguments of the rule builder: the + * first argument, if any, must be an object. The arguments after it are ignored + */ + private static function getOptions($args, $ruleName) + { + $args = \Validator\LIVR\Arguments::get($args); + + if (!$args) { + return array(); + } + + // An empty array is {}: PHP decodes {} and [] to the same array + if (!is_array($args[0])) { + throw new \Exception("LIVR: \"$ruleName\" takes no arguments or an object with \"min\" and \"max\""); + } + + self::checkOptions($args[0], $ruleName, null); + + return $args[0]; + } + + private static function checkOptions($options, $ruleName, $formatHint) + { + if ($options && !Util::isAssocArray($options)) { + throw new \Exception("LIVR: \"$ruleName\" takes no arguments or an object with \"min\" and \"max\""); + } + + foreach ($options as $key => $option) { + if ($key !== 'min' && $key !== 'max') { + $hint = $key === 'format' && $formatHint ? ", $formatHint" : ''; + throw new \Exception("LIVR: \"$ruleName\" got an unknown option \"$key\"$hint"); + } + } + } + + // A date bound: a date YYYY-MM-DD, or the days from the current date as an integer + private static function parseDateBound($bound, $name) + { + if (is_string($bound)) { + if (isset(self::$RELATIVE_DAYS[$bound])) { + return self::$RELATIVE_DAYS[$bound]; + } + + if (self::isDate($bound)) { + return $bound; + } + } + + throw new \Exception( + "LIVR: \"iso_date\" takes a date YYYY-MM-DD, \"today\", \"yesterday\" or \"tomorrow\" as \"$name\", got " + . json_encode($bound) . ($bound === 'current' ? ', use "today" instead of "current"' : '') + ); + } + + // A datetime bound: the key of an instant, or [days] from the current date. A date, given or + // relative, is the start of the UTC day as "min" and its end as "max" + private static function parseDatetimeBound($bound, $isMax) + { + if (is_string($bound)) { + if (isset(self::$RELATIVE_DAYS[$bound])) { + return array(self::$RELATIVE_DAYS[$bound]); + } + + if (strlen($bound) === 10) { + if (self::parseDatetime($bound . 'T00:00Z')) { + return $isMax ? self::lastKey() + self::DAY_MS - 1 : self::lastKey(); + } + } elseif (self::parseDatetime($bound)) { + // An instant of any year: it is only compared with values + return self::lastKey(); + } + } + + throw new \Exception( + 'LIVR: "iso_datetime" takes a datetime with an offset, a date YYYY-MM-DD, "today", "yesterday" or ' + . '"tomorrow" as "' . ($isMax ? 'max' : 'min') . '", got ' . json_encode($bound) + . ($bound === 'current' ? ', use "today" instead of "current"' : '') + ); + } + + private static function parseTimeBound($bound, $name) + { + $time = is_string($bound) ? self::parseTime($bound) : -1; + + if ($time < 0) { + throw new \Exception("LIVR: \"iso_time\" got a wrong time in \"$name\": " . json_encode($bound)); + } + + return $time; + } + + private static function isDate($str) + { + if (!preg_match(self::DATE_RE, $str, $m)) { + return false; + } + + // checkdate() starts with year 1. Year 0 is a leap year, like 2000 + return checkdate((int) $m[2], (int) $m[3], (int) $m[1] ?: 2000); + } + + /** + * YYYY-MM-DD "T" HH ":" MM [ ":" SS [ "." 1*9DIGIT ] ] ( "Z" / ( "+" / "-" ) HH ":" MM ). + * Sets the UTC fields of the instant, cut to milliseconds. False when it is not such a datetime + */ + private static function parseDatetime($str) + { + if (!preg_match(self::DATETIME_RE, $str, $m)) { + return false; + } + + $year = (int) $m[1]; + $month = (int) $m[2]; + $day = (int) $m[3]; + $hours = (int) $m[4]; + $minutes = (int) $m[5]; + $seconds = isset($m[6]) ? (int) $m[6] : 0; + + if ($hours > 23 || $minutes > 59 || $seconds > 59 || !checkdate($month, $day, $year ?: 2000)) { + return false; + } + + // The first three fraction digits, the others are cut off + $ms = isset($m[7]) && $m[7] !== '' ? (int) str_pad(substr($m[7], 0, 3), 3, '0') : 0; + + // Minutes east of UTC + $offset = 0; + if (isset($m[8]) && $m[8] !== '') { + $offsetHours = (int) $m[9]; + $offsetMinutes = (int) $m[10]; + + if ($offsetHours > 23 || $offsetMinutes > 59) { + return false; + } + + $offset = $m[8] === '-' ? -($offsetHours * 60 + $offsetMinutes) : $offsetHours * 60 + $offsetMinutes; + } + + // An offset moves the time by less than a day, so at most to the next or the previous day + $minutesOfDay = $hours * 60 + $minutes - $offset; + + if ($minutesOfDay < 0) { + $minutesOfDay += 1440; + + if (--$day === 0) { + if (--$month === 0) { + $month = 12; + $year--; + } + + $day = self::daysInMonth($year, $month); + } + } elseif ($minutesOfDay >= 1440) { + $minutesOfDay -= 1440; + + if (++$day > 28 && $day > self::daysInMonth($year, $month)) { + $day = 1; + + if (++$month === 13) { + $month = 1; + $year++; + } + } + } + + self::$utcYear = $year; + self::$utcMonth = $month; + self::$utcDay = $day; + self::$utcMsOfDay = ($minutesOfDay * 60 + $seconds) * 1000 + $ms; + + return true; + } + + // The key of the last parsed datetime + private static function lastKey() + { + return (self::$utcYear * 512 + self::$utcMonth * 32 + self::$utcDay) * self::DAY_MS + self::$utcMsOfDay; + } + + /** + * HH:MM, HH:MM:SS or HH:MM:SS.fraction (1-9 digits): nanoseconds since midnight, exact in a + * 64-bit integer, so "09:00" equals "09:00:00.0". -1 for a wrong time + */ + private static function parseTime($str) + { + if (!preg_match(self::TIME_RE, $str, $m)) { + return -1; + } + + $hours = (int) $m[1]; + $minutes = (int) $m[2]; + $seconds = isset($m[3]) ? (int) $m[3] : 0; + + if ($hours > 23 || $minutes > 59 || $seconds > 59) { + return -1; + } + + $fraction = isset($m[4]) ? (int) str_pad($m[4], 9, '0') : 0; + + return (($hours * 60 + $minutes) * 60 + $seconds) * 1000000000 + $fraction; + } + + // A value that is not a string: empty, a number or a boolean (never a date), or not primitive + private static function notStringError($value, $errorCode) + { + if ($value === null) { + return; + } + + return Values::isPrimitiveValue($value) ? $errorCode : 'FORMAT_ERROR'; + } + + // The current date in UTC as a day number, never in the local time zone. time() without a + // namespace: tests replace it in this namespace + private static function today() + { + return (int) floor(time() / 86400); + } + + // The date YYYY-MM-DD of $days from the day number $today + private static function relativeDate($today, $days) + { + if ($today !== self::$cachedDay) { + self::cacheDay($today); + } + + return self::$cachedDates[$days]; + } + + // The key of the start of the day $days from the day number $today + private static function relativeKey($today, $days) + { + if ($today !== self::$cachedDay) { + self::cacheDay($today); + } + + return self::$cachedKeys[$days]; + } + + private static function cacheDay($today) + { + foreach (self::$RELATIVE_DAYS as $days) { + list($year, $month, $day) = self::civilFromDays($today + $days); + self::$cachedDates[$days] = sprintf('%04d-%02d-%02d', $year, $month, $day); + self::$cachedKeys[$days] = ($year * 512 + $month * 32 + $day) * self::DAY_MS; + } + + self::$cachedDay = $today; + } + + private static function daysInMonth($year, $month) + { + if ($month === 2) { + return ($year % 4 === 0 && $year % 100 !== 0) || $year % 400 === 0 ? 29 : 28; + } + + return $month === 4 || $month === 6 || $month === 9 || $month === 11 ? 30 : 31; + } + + // The date of a day number, "civil_from_days" by Howard Hinnant + private static function civilFromDays($days) + { + $z = $days + 719468; + $era = intdiv($z >= 0 ? $z : $z - 146096, 146097); + $dayOfEra = $z - $era * 146097; + $yearOfEra = intdiv( + $dayOfEra - intdiv($dayOfEra, 1460) + intdiv($dayOfEra, 36524) - intdiv($dayOfEra, 146096), + 365 + ); + $dayOfYear = $dayOfEra - (365 * $yearOfEra + intdiv($yearOfEra, 4) - intdiv($yearOfEra, 100)); + $shiftedMonth = intdiv(5 * $dayOfYear + 2, 153); // March is 0 + $day = $dayOfYear - intdiv(153 * $shiftedMonth + 2, 5) + 1; + $month = $shiftedMonth < 10 ? $shiftedMonth + 3 : $shiftedMonth - 9; + + return array($yearOfEra + $era * 400 + ($month <= 2 ? 1 : 0), $month, $day); + } +} diff --git a/lib/Validator/LIVR/Rules/Format.php b/lib/Validator/LIVR/Rules/Format.php new file mode 100644 index 0000000..bc9325c --- /dev/null +++ b/lib/Validator/LIVR/Rules/Format.php @@ -0,0 +1,361 @@ + $maxPrefixLength) { + return 'WRONG_CIDR'; + } + + $outputArr = $str; + }; + } + + public static function hostname() + { + Arguments::checkNone(func_get_args(), 'hostname'); + + // RFC 1123 labels of 1-63 characters, not starting or ending with "-", no trailing dot, at + // most 253 characters. The last label is not all digits, so 192.0.2.1 is not a hostname + $re = '/^(?=.{1,253}\z)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*' + . '(?![0-9]+\z)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/s'; + + return self::buildRule($re, 'WRONG_HOSTNAME'); + } + + public static function base64() + { + $args = Arguments::get(func_get_args()); + + if (!$args) { + // Groups of 4 characters, the padding "=" or "==" only at the end + $re = '/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/'; + } elseif ($args[0] === 'url') { + // The arguments after the variant are ignored + // The URL-safe alphabet, the padding is either complete or absent + $re = '/^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}(?:==)?|[A-Za-z0-9_-]{3}=?)?\z/'; + } else { + throw new \Exception('LIVR: "base64" takes no arguments or "url"'); + } + + return self::buildRule($re, 'WRONG_BASE64'); + } + + public static function hex() + { + Arguments::checkNone(func_get_args(), 'hex'); + + return self::buildRule('/^[0-9a-fA-F]+\z/', 'WRONG_HEX'); + } + + public static function digest() + { + $algorithms = func_get_args(); + array_pop($algorithms); // pop ruleBuilders + + $hexLengths = array('md5' => 32, 'sha1' => 40, 'sha256' => 64, 'sha384' => 96, 'sha512' => 128); + + if (!$algorithms) { + throw new \Exception('LIVR: "digest" needs one or more algorithms'); + } + + $lengths = array(); + foreach ($algorithms as $algorithm) { + // A number is its string form, never an algorithm + if (!is_string($algorithm) || !isset($hexLengths[$algorithm])) { + throw new \Exception('LIVR: "digest" got an unknown algorithm ' . json_encode($algorithm)); + } + + $lengths[] = $hexLengths[$algorithm]; + } + + $re = '/^(?:' . implode('|', array_map(function ($length) { + return '[0-9a-fA-F]{' . $length . '}'; + }, array_unique($lengths))) . ')\z/'; + + return self::buildRule($re, 'WRONG_DIGEST'); + } + + public static function mongoId() + { + Arguments::checkNone(func_get_args(), 'mongo_id'); + + return self::buildRule('/^[0-9a-fA-F]{24}\z/', 'WRONG_MONGO_ID'); + } + + public static function hexColor() + { + Arguments::checkNone(func_get_args(), 'hex_color'); + + return self::buildRule('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z/', 'WRONG_HEX_COLOR'); + } + + public static function mac() + { + $args = Arguments::get(func_get_args()); + + // 48 bits are six pairs (MAC-48, EUI-48), 64 bits are eight pairs (EUI-64). 48.0 and "48" + // are 48. The arguments after it are ignored + $bits = $args ? Arguments::toCount($args[0]) : 48; + if ($bits !== 48 && $bits !== 64) { + throw new \Exception('LIVR: "mac" takes no arguments, 48 or 64'); + } + + // The back reference keeps the first separator + $re = '/^[0-9a-fA-F]{2}([:-])[0-9a-fA-F]{2}(?:\1[0-9a-fA-F]{2}){' . ($bits == 48 ? 4 : 6) . '}\z/'; + + return self::buildRule($re, 'WRONG_MAC'); + } + + public static function e164() + { + Arguments::checkNone(func_get_args(), 'e164'); + + return self::buildRule('/^\+[1-9][0-9]{6,14}\z/', 'WRONG_E164'); + } + + public static function semver() + { + Arguments::checkNone(func_get_args(), 'semver'); + + // The regular expression of semver.org 2.0.0 with [0-9] for \d + $preRelease = '(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)'; + $re = '/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)' + . '(?:-' . $preRelease . '(?:\.' . $preRelease . ')*)?' + . '(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?\z/'; + + return self::buildRule($re, 'WRONG_SEMVER'); + } + + public static function jwt() + { + Arguments::checkNone(func_get_args(), 'jwt'); + + // header.payload.signature in base64url without padding, the signature is empty in an + // unsecured JWT. No segment has a length of 1 modulo 4: no bytes give such a length + $segment = '(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?'; + $re = '/^(?=[^.])' . $segment . '\.(?=[^.])' . $segment . '\.' . $segment . '\z/'; + + return self::buildRule($re, 'WRONG_JWT'); + } + + /** + * A rule that checks the string form of a value with a pattern and outputs it + */ + private static function buildRule($re, $errorCode) + { + return function ($value, $params, &$outputArr) use ($re, $errorCode) { + if (is_string($value)) { + if ($value === '') { + return; + } + + return preg_match($re, $value) ? null : $errorCode; + } + + $str = self::toFormatString($value, $errorCode, $error); + if ($str === null) { + return $error; + } + + if (!preg_match($re, $str)) { + return $errorCode; + } + + $outputArr = $str; + }; + } + + /** + * The string form of a value for a format or a checksum rule, or null with the error in + * $error: none for an empty value, the code of the rule for a boolean, FORMAT_ERROR for an + * object or a list + */ + public static function toFormatString($value, $errorCode, &$error) + { + if (is_string($value)) { + $error = null; + return $value === '' ? null : $value; + } + + if (is_int($value) || is_float($value)) { + return Values::stringForm($value); + } + + if ($value === null) { + $error = null; + } else { + $error = is_bool($value) ? $errorCode : 'FORMAT_ERROR'; + } + + return null; + } + + // "v4" or "v6" as 4 or 6, 0 for both + private static function ipVersion($args, $ruleName) + { + $args = Arguments::get($args); + + if (!$args) { + return 0; + } + + // The arguments after the version are ignored. 4 is "4", not "v4" + if ($args[0] === 'v4' || $args[0] === 'v6') { + return $args[0] === 'v4' ? 4 : 6; + } + + throw new \Exception("LIVR: \"$ruleName\" takes no arguments, \"v4\" or \"v6\""); + } + + private static function isIp($str, $version) + { + return ($version !== 6 && preg_match(self::IPV4, $str)) || ($version !== 4 && self::isIPv6($str)); + } + + /** + * RFC 4291, section 2.2: eight groups, or fewer with one "::" for one or more groups of zeros + */ + private static function isIPv6($str) + { + $gapIndex = strpos($str, '::'); + + if ($gapIndex === false) { + return self::countIPv6Groups($str, true) === 8; + } + + // A second "::" (":::" too) is ambiguous + if (strpos($str, '::', $gapIndex + 1) !== false) { + return false; + } + + $before = self::countIPv6Groups(substr($str, 0, $gapIndex), false); + $after = self::countIPv6Groups((string) substr($str, $gapIndex + 2), true); + + return $before !== -1 && $after !== -1 && $before + $after <= 7; + } + + /** + * The number of 16-bit groups: hex groups separated by ":", the last one may be an IPv4 + * address (two groups) at the end of the address. -1 for a wrong group + */ + private static function countIPv6Groups($str, $isAddressEnd) + { + if ($str === '') { + return 0; + } + + $groups = explode(':', $str); + $last = array_pop($groups); + + foreach ($groups as $group) { + if (!self::isHexGroup($group)) { + return -1; + } + } + + if (self::isHexGroup($last)) { + return count($groups) + 1; + } + + if ($isAddressEnd && preg_match(self::IPV4, $last)) { + return count($groups) + 2; + } + + return -1; + } + + private static function isHexGroup($group) + { + $length = strlen($group); + + return $length >= 1 && $length <= 4 && strspn($group, '0123456789abcdefABCDEF') === $length; + } +} diff --git a/lib/Validator/LIVR/Rules/Meta.php b/lib/Validator/LIVR/Rules/Meta.php index 351a9a1..b87a8dc 100644 --- a/lib/Validator/LIVR/Rules/Meta.php +++ b/lib/Validator/LIVR/Rules/Meta.php @@ -6,6 +6,9 @@ class Meta { public static function nestedObject($livr, $ruleBuilders) { + // The rule builders are the last argument: extra arguments are ignored, as in LIVR 2.0 + $ruleBuilders = func_get_arg(func_num_args() - 1); + $validator = new \Validator\LIVR($livr); $validator->registerRules($ruleBuilders)->prepare(); @@ -34,8 +37,9 @@ public static function listOf() $first_arg = func_get_arg(0); if (\Validator\LIVR\Util::isList($first_arg)) { + // The rule builders are the last argument: extra arguments are ignored $livr = func_get_arg(0); - $ruleBuilders = func_get_arg(1); + $ruleBuilders = func_get_arg(func_num_args() - 1); } else { $livr = func_get_args(); $ruleBuilders = array_pop($livr); @@ -58,9 +62,9 @@ public static function listOf() $hasErrors = false; foreach ($values as $value) { - // Rules of an item see it as the only field of the data, like in validate() + // Items have no containing object: a reference in their rules is a missing value $output = null; - $errCode = $validateItem($value, array('field' => $value), $output); + $errCode = $validateItem($value, array(), $output); if ($errCode) { $results[] = null; @@ -83,6 +87,9 @@ public static function listOf() public static function listOfObjects($livr, $ruleBuilders) { + // The rule builders are the last argument: extra arguments are ignored, as in LIVR 2.0 + $ruleBuilders = func_get_arg(func_num_args() - 1); + $validator = new \Validator\LIVR($livr); $validator->registerRules($ruleBuilders)->prepare(); @@ -123,6 +130,9 @@ public static function listOfObjects($livr, $ruleBuilders) public static function listOfDifferentObjects($selectorField, $livrs, $ruleBuilders) { + // The rule builders are the last argument: extra arguments are ignored, as in LIVR 2.0 + $ruleBuilders = func_get_arg(func_num_args() - 1); + $validators = array(); foreach ($livrs as $selectorValue => $livr) { @@ -176,6 +186,9 @@ public static function listOfDifferentObjects($selectorField, $livrs, $ruleBuild public static function variableObject($selectorField, $livrs, $ruleBuilders) { + // The rule builders are the last argument: extra arguments are ignored, as in LIVR 2.0 + $ruleBuilders = func_get_arg(func_num_args() - 1); + $validators = array(); foreach ($livrs as $selectorValue => $livr) { $validator = new \Validator\LIVR($livr); @@ -220,13 +233,13 @@ public static function __or() } return function ($value, $params, &$outputArr) use ($fieldRules) { - // Empty values go to the rule sets too, so "required" and "not_empty" in them work + // Empty values go to the rule sets too, so "required" and "not_empty" in them work. + // The rule sets are transparent: their rules see the siblings of the field $lastError = null; - $allValues = array('field' => $value); foreach ($fieldRules as $fieldRule) { $output = null; - $errCode = $fieldRule($value, $allValues, $output); + $errCode = $fieldRule($value, $params, $output); if (!$errCode) { $outputArr = $output; diff --git a/lib/Validator/LIVR/Rules/Special.php b/lib/Validator/LIVR/Rules/Special.php index 342cf6d..b52a6a9 100644 --- a/lib/Validator/LIVR/Rules/Special.php +++ b/lib/Validator/LIVR/Rules/Special.php @@ -71,15 +71,29 @@ public static function url() }; } - public static function isoDate() + public static function isoDate($options = null) { + // The LIVR 2.0 forms get only ruleBuilders, or null for {"iso_date": null}. LIVR 2.5: the + // first argument, if any, must be an options object, the arguments after it are ignored. + // {} (or [], PHP decodes both to the same array) and null are the same as no arguments + if (func_num_args() > 1 && $options !== null) { + if (!is_array($options) || ($options && !\Validator\LIVR\Util::isAssocArray($options))) { + throw new \Exception('LIVR: "iso_date" takes no arguments or an object with "min" and "max"'); + } + + if ($options) { + return \Validator\LIVR\Rules\Dates::isoDateWithBounds($options); + } + } + return function ($value) { if (!isset($value) || $value === '') { return; } + // A number or a boolean is a primitive value, never a date (LIVR 2.5, 3.3) if (!is_string($value)) { - return 'FORMAT_ERROR'; + return \Validator\LIVR\Values::isPrimitiveValue($value) ? 'WRONG_DATE' : 'FORMAT_ERROR'; } // checkdate() instead of DateTime, which is several times slower. diff --git a/lib/Validator/LIVR/Rules/Strings.php b/lib/Validator/LIVR/Rules/Strings.php new file mode 100644 index 0000000..0737917 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Strings.php @@ -0,0 +1,151 @@ += 3) { #Passed regexp flag, extra arguments are ignored $flags = func_get_arg(1); if ($flags && $flags != 'i') { diff --git a/lib/Validator/LIVR/Rules/Types.php b/lib/Validator/LIVR/Rules/Types.php new file mode 100644 index 0000000..295f247 --- /dev/null +++ b/lib/Validator/LIVR/Rules/Types.php @@ -0,0 +1,145 @@ + $maxDecimals) { + return 'TOO_MANY_DECIMALS'; + } + + $outputArr = $number; + return; + } + + if (is_int($value)) { + return; + } + + if (is_float($value)) { + // In the string form, so 1.230 has 2 decimals, 1e-7 has 7 + $str = Values::floatToString($value); + $dotIndex = strpos($str, '.'); + + if ($dotIndex !== false && strlen($str) - $dotIndex - 1 > $maxDecimals) { + return 'TOO_MANY_DECIMALS'; + } + + return; + } + + if ($value === null) { + return; + } + + return is_bool($value) ? 'NOT_NUMBER' : 'FORMAT_ERROR'; + }; + } +} diff --git a/lib/Validator/LIVR/Values.php b/lib/Validator/LIVR/Values.php new file mode 100644 index 0000000..a51d3b3 --- /dev/null +++ b/lib/Validator/LIVR/Values.php @@ -0,0 +1,85 @@ + -1e15 && $number < 1e15 && $number == (int) $number) { + return (string) (int) $number; + } + + // %g strips trailing zeros: the first precision that reads back is the shortest form + $repr = sprintf('%.15g', $number); + if ((float) $repr !== $number) { + $repr = sprintf('%.16g', $number); + if ((float) $repr !== $number) { + $repr = sprintf('%.17g', $number); + } + } + + $exponentIndex = strpos($repr, 'e'); + if ($exponentIndex === false) { + return $repr; + } + + // "1.5e-7", "1.0e+20": the digits without the point, and the position of the point + $sign = $repr[0] === '-' ? '-' : ''; + $mantissa = substr($repr, strlen($sign), $exponentIndex - strlen($sign)); + $digits = rtrim(str_replace('.', '', $mantissa), '0'); + $point = 1 + (int) substr($repr, $exponentIndex + 1); + + if ($digits === '') { + return '0'; + } + + if ($point <= 0) { + return $sign . '0.' . str_repeat('0', -$point) . $digits; + } + + if ($point >= strlen($digits)) { + return $sign . $digits . str_repeat('0', $point - strlen($digits)); + } + + return $sign . substr($digits, 0, $point) . '.' . substr($digits, $point); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4b3ddea..a025c0c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,8 @@ tests/03-auto_trim.php tests/04-custom_filters.php tests/05-rules_edge_cases.php + tests/06-livr_test_suite_2_5.php + tests/07-livr_2_5_api.php diff --git a/tests/01-livr_test_suite.php b/tests/01-livr_test_suite.php index 4258ec9..41ff9bf 100644 --- a/tests/01-livr_test_suite.php +++ b/tests/01-livr_test_suite.php @@ -48,7 +48,7 @@ public function positiveTestsProvider() $entries = array(); while (false !== ( $entry = readdir($handle) )) { - if ($entry != "." && $entry != "..") { + if ($entry != "." && $entry != ".." && is_dir("$dir/$entry")) { $entries[] = $entry; } } @@ -80,7 +80,7 @@ public function negativeTestsProvider() $entries = array(); while (false !== ( $entry = readdir($handle) )) { - if ($entry != "." && $entry != "..") { + if ($entry != "." && $entry != ".." && is_dir("$dir/$entry")) { $entries[] = $entry; } } diff --git a/tests/02-livr_test_suite_aliased_rules.php b/tests/02-livr_test_suite_aliased_rules.php index 0e22f3f..e1d543e 100644 --- a/tests/02-livr_test_suite_aliased_rules.php +++ b/tests/02-livr_test_suite_aliased_rules.php @@ -53,7 +53,7 @@ public function positiveTestsProvider() $entries = array(); while (false !== ( $entry = readdir($handle) )) { - if ($entry != "." && $entry != "..") { + if ($entry != "." && $entry != ".." && is_dir("$dir/$entry")) { $entries[] = $entry; } } @@ -86,7 +86,7 @@ public function negativeTestsProvider() $entries = array(); while (false !== ( $entry = readdir($handle) )) { - if ($entry != "." && $entry != "..") { + if ($entry != "." && $entry != ".." && is_dir("$dir/$entry")) { $entries[] = $entry; } } diff --git a/tests/06-livr_test_suite_2_5.php b/tests/06-livr_test_suite_2_5.php new file mode 100644 index 0000000..50cb92a --- /dev/null +++ b/tests/06-livr_test_suite_2_5.php @@ -0,0 +1,133 @@ +skipEmptyObjectOrList($test); + + $validator = $this->buildValidator($data); + $output = $validator->validate($data['input']); + + $this->assertFalse($validator->getErrors(), 'Validator should contain no errors'); + $this->assertEquals($data['output'], $output, 'Validator should return validated data'); + } + + /** + * @dataProvider negativeTestsProvider + */ + public function testNegative($data, $test) + { + $this->skipEmptyObjectOrList($test); + + $validator = $this->buildValidator($data); + $output = $validator->validate($data['input']); + + $this->assertFalse($output ? true : false, 'Validator should return false'); + $this->assertEquals($data['errors'], $validator->getErrors(), 'Validator should contain valid errors'); + } + + /** + * @dataProvider schemaErrorsProvider + */ + public function testSchemaError($data, $test) + { + try { + $this->buildValidator($data)->prepare(); + } catch (\Exception $e) { + $this->assertNotEmpty($e->getMessage(), 'The exception should have a message'); + return; + } + + $this->fail("Building the validator should fail for $test: " . json_encode($data['rules'])); + } + + public function positiveTestsProvider() + { + return array_merge(self::readCases('positive'), self::readCases('aliases_positive')); + } + + public function negativeTestsProvider() + { + return array_merge(self::readCases('negative'), self::readCases('aliases_negative')); + } + + public function schemaErrorsProvider() + { + return self::readCases('schema_errors'); + } + + private function buildValidator($data) + { + // options.json fixes the current time for the bounds of iso_date and iso_datetime + if (isset($data['options']['now'])) { + $now = (new \DateTimeImmutable($data['options']['now']))->getTimestamp(); + $this->getFunctionMock('Validator\LIVR\Rules', 'time')->expects($this->any())->willReturn($now); + } + + $validator = new Validator\LIVR($data['rules']); + + foreach (isset($data['aliases']) ? $data['aliases'] : array() as $alias) { + $validator->registerAliasedRule($alias); + } + + return $validator; + } + + private function skipEmptyObjectOrList($test) + { + if (in_array($test, self::$EMPTY_OBJECT_OR_LIST_CASES, true)) { + $this->markTestSkipped( + "$test: PHP decodes {} and [] to the same array, so an empty object can't be told apart " + . 'from an empty list (not counted for PHP on the LIVR conformance page)' + ); + } + } + + // Every case directory of the suite as [data, "suite/case"], the data has a key for each JSON file + private static function readCases($suite) + { + $dir = __DIR__ . "/test_suite_2.5/$suite"; + $cases = array(); + + $entries = scandir($dir); + sort($entries); + + foreach ($entries as $entry) { + if ($entry[0] === '.' || !is_dir("$dir/$entry")) { + continue; + } + + $data = array(); + foreach (glob("$dir/$entry/*.json") as $file) { + $data[basename($file, '.json')] = json_decode(file_get_contents($file), true); + } + + $cases["$suite/$entry"] = array($data, "$suite/$entry"); + } + + return $cases; + } +} + +// The date rules read the clock with time() in their namespace. The mock function must be defined +// before the first call there, otherwise PHP binds the call to the global time() +LIVR25TestSuite::defineFunctionMock('Validator\LIVR\Rules', 'time'); diff --git a/tests/07-livr_2_5_api.php b/tests/07-livr_2_5_api.php new file mode 100644 index 0000000..996c9ce --- /dev/null +++ b/tests/07-livr_2_5_api.php @@ -0,0 +1,386 @@ +assertSame($vector[1], Values::stringForm($vector[0]), var_export($vector[0], true)); + } + } + + public function testOutputTypes() + { + $validator = new LIVR(array( + 'subscribe' => 'boolean', + 'unsubscribe' => 'boolean', + 'zip' => 'digits', + 'amount' => array('max_decimals' => 2), + 'count' => array('max_decimals' => 0), + 'starts_at' => 'iso_datetime', + 'code' => array('starts_with' => '1'), + 'hex' => 'hex', + 'ids' => array('split' => ','), + )); + + $output = $validator->validate(array( + 'subscribe' => '1', + 'unsubscribe' => 0.0, + 'zip' => 1.0, + 'amount' => '1.20', + 'count' => '5', + 'starts_at' => '2026-10-29T10:00:00.123456+02:00', + 'code' => 12.5, + 'hex' => 123, + 'ids' => '1,,2', + )); + + $this->assertSame(array( + 'subscribe' => true, + 'unsubscribe' => false, + 'zip' => '1', + 'amount' => 1.2, + 'count' => 5, + 'starts_at' => '2026-10-29T08:00:00.123Z', + 'code' => '12.5', + 'hex' => '123', + 'ids' => array('1', '', '2'), + ), $output); + } + + public function testRelativeBoundsAreTheCurrentDateInUtc() + { + $rules = array( + 'date' => array('iso_date' => array('max' => 'today')), + 'datetime' => array('iso_datetime' => array('min' => 'tomorrow')), + ); + $input = array('date' => '2026-10-18', 'datetime' => '2026-10-19T00:00:00Z'); + + $time = $this->getFunctionMock('Validator\LIVR\Rules', 'time'); + $time->expects($this->any())->willReturnCallback(function () use (&$now) { + return (new \DateTimeImmutable($now))->getTimestamp(); + }); + + // In UTC, never in the local time zone: 23:30 at -02:00 is the next day + $nows = array('2026-10-18T12:00:00Z' => true, '2026-10-18T23:30:00-02:00' => false); + + foreach ($nows as $now => $isValid) { + $validator = new LIVR($rules); + $this->assertSame($isValid, $validator->validate($input) !== false, $now); + } + } + + public function testClockIsReadOnValidation() + { + $now = '2026-10-18T12:00:00Z'; + $time = $this->getFunctionMock('Validator\LIVR\Rules', 'time'); + $time->expects($this->any())->willReturnCallback(function () use (&$now) { + return (new \DateTimeImmutable($now))->getTimestamp(); + }); + + $validator = new LIVR(array('date' => array('iso_date' => array('min' => 'today')))); + $this->assertNotFalse($validator->validate(array('date' => '2026-10-18'))); + + $now = '2026-10-19T00:00:00Z'; + $this->assertFalse($validator->validate(array('date' => '2026-10-18'))); + $this->assertSame(array('date' => 'DATE_TOO_EARLY'), $validator->getErrors()); + } + + public function testMetaRulesOfLivr20IgnoreExtraArguments() + { + // The rule builders are the last argument of a builder: an extra argument must not + // take their place, or the nested rules lose the custom rules of the validator + $validator = new LIVR(array( + 'nested' => array('nested_object' => array(array('code' => 'my_code'), 'extra')), + 'list' => array('list_of' => array(array('my_code'), 'extra')), + 'objects' => array('list_of_objects' => array(array('code' => 'my_code'), 'extra')), + 'variable' => array('variable_object' => array('type', array('a' => array('code' => 'my_code')), 'extra')), + 'different' => array('list_of_different_objects' => array( + 'type', + array('a' => array('code' => 'my_code')), + 'extra', + )), + 'like' => array('like' => array('^a+$', 'i', 'extra')), + )); + + $validator->registerRules(array('my_code' => function () { + return function ($value) { + return $value === 'ok' ? null : 'WRONG_CODE'; + }; + })); + + $this->assertFalse($validator->validate(array( + 'nested' => array('code' => 'bad'), + 'list' => array('bad'), + 'objects' => array(array('code' => 'bad')), + 'variable' => array('type' => 'a', 'code' => 'bad'), + 'different' => array(array('type' => 'a', 'code' => 'bad')), + 'like' => 'AAA', + ))); + + $this->assertSame(array( + 'nested' => array('code' => 'WRONG_CODE'), + 'list' => array('WRONG_CODE'), + 'objects' => array(array('code' => 'WRONG_CODE')), + 'variable' => array('code' => 'WRONG_CODE'), + 'different' => array(array('code' => 'WRONG_CODE')), + ), $validator->getErrors()); + } + + public function testAutoTrimOption() + { + $rules = array('code' => 'required'); + + $this->assertSame(array('code' => 'a'), (new LIVR($rules, true))->validate(array('code' => ' a '))); + $this->assertSame( + array('code' => 'a'), + (new LIVR($rules, array('autoTrim' => true)))->validate(array('code' => ' a ')) + ); + $this->assertSame(array('code' => ' a '), (new LIVR($rules))->validate(array('code' => ' a '))); + } + + public function testKeysAreNotTrimmed() + { + LIVR::defaultAutoTrim(true); + + try { + $validator = new LIVR(array('map' => array('keys' => array('one_of' => array('a'))))); + $this->assertFalse($validator->validate(array('map' => array(' a ' => 1)))); + $this->assertSame(array('map' => array(' a ' => 'WRONG_KEY')), $validator->getErrors()); + } finally { + LIVR::defaultAutoTrim(false); + } + } + + public function testListOfItemsHaveNoContainingObject() + { + // LIVR 2.5, section 1: equal_to_field in an item of list_of sees no other fields + $validator = new LIVR(array( + 'password' => 'required', + 'passwords' => array('list_of' => array(array('equal_to_field' => 'password'))), + )); + + $this->assertFalse($validator->validate(array('password' => 'a', 'passwords' => array('a')))); + $this->assertSame(array('passwords' => array('FIELDS_NOT_EQUAL')), $validator->getErrors()); + } + + public function testEqAndOneOfCompareBooleansAsStrings() + { + // LIVR 2.5, 2.3: true is "true" and false is "false", in both directions + $this->assertBooleanMatches(true, array(true, 'true')); + $this->assertBooleanMatches(false, array(false, 'false')); + $this->assertBooleanMatches('true', array(true)); + $this->assertBooleanMatches('false', array(false)); + + $this->assertBooleanDoesNotMatch(true, array(false, 'false', 'True', 'TRUE', '0', 0, 'yes')); + $this->assertBooleanDoesNotMatch(false, array(true, 'true', '1', '0', 0, 'no')); + $this->assertBooleanDoesNotMatch('true', array(false, '1')); + $this->assertBooleanDoesNotMatch('false', array(true, '0')); + } + + public function testEqAndOneOfKeepTheBooleanFormsOfLivr20() + { + // LIVR 2.0 compared the PHP string forms: true is "1", false is "". The data that passed + // still passes (LIVR 2.5, 2.3) + $this->assertBooleanMatches(true, array('1', 1, 1.0)); + $this->assertBooleanMatches('1', array(true)); + $this->assertBooleanMatches(1, array(true)); + $this->assertBooleanMatches(1.0, array(true)); + + $this->assertBooleanDoesNotMatch(false, array('0', 0)); + $this->assertBooleanDoesNotMatch('0', array(false)); + $this->assertBooleanDoesNotMatch(0, array(false)); + } + + public function testOneOfWithBooleansAndOtherValues() + { + $validator = new LIVR(array('flag' => array('one_of' => array(array('yes', true, 2))))); + + foreach (array('yes', true, 'true', '1', 1, 2, '2') as $value) { + $this->assertNotFalse($validator->validate(array('flag' => $value)), var_export($value, true)); + } + + foreach (array('no', false, 'false', '0', 3) as $value) { + $this->assertFalse($validator->validate(array('flag' => $value)), var_export($value, true)); + $this->assertSame(array('flag' => 'NOT_ALLOWED_VALUE'), $validator->getErrors()); + } + } + + public function testNullArgumentOfIsoDateIsNoOptions() + { + // iso_date ignored its arguments in LIVR 2.0, so {"iso_date": null} stays the plain + // iso_date (LIVR 2.5, 2.6). PHP decodes {"rule": null} and {"rule": [null]} to the same + // arguments + foreach (array(null, array(null)) as $args) { + $validator = new LIVR(array('date' => array('iso_date' => $args))); + $this->assertSame(array('date' => '2026-10-18'), $validator->validate(array('date' => '2026-10-18'))); + $this->assertFalse($validator->validate(array('date' => '2026-02-30'))); + } + } + + public function testNullArgumentOfLivr25RuleIsSchemaError() + { + // {"rule": null} is one null argument, which no LIVR 2.5 rule takes (LIVR 2.5, 2.6), and + // a null in a list of arguments is a wrong argument too + $schemas = array( + array('boolean' => null), + array('any' => null), + array('digits' => null), + array('iso_time' => null), + array('iso_datetime' => null), + array('uuid' => null), + array('ip' => null), + array('unique' => null), + array('max_decimals' => null), + array('not_one_of' => array(null)), + array('not_one_of' => null), + array('required_if' => array('type' => null)), + array('list_length' => null), + array('list_length' => array(null)), + array('list_length' => array('min' => null)), + array('list_length' => array('min' => 1, 'max' => null)), + array('split' => null), + array('when' => null), + array('values' => null), + array('keys' => array('required', null)), + ); + + foreach ($schemas as $rule) { + try { + (new LIVR(array('field' => $rule)))->prepare(); + } catch (\Exception $e) { + continue; + } + + $this->fail('Should be a schema error: ' . json_encode($rule)); + } + + $this->assertTrue(true); + } + + public function testNullArgumentOfLivr20RulesAndRegisteredRules() + { + // The rules of LIVR 2.0 and the registered rules get {"rule": null} as one null argument, + // as in LIVR 2.0: {"default": null} sets null (the output keeps the value then) + $validator = new LIVR(array( + 'missing' => array('default' => null), + 'null' => array('default' => null), + 'empty' => array('default' => null), + 'custom' => array('my_rule' => null), + )); + + $validator->registerRules(array('my_rule' => function () { + $args = func_get_args(); + + return function ($value) use ($args) { + return count($args) === 2 && $args[0] === null ? null : 'WRONG_ARGUMENTS'; + }; + })); + + $this->assertSame( + array('null' => null, 'empty' => '', 'custom' => 'x'), + $validator->validate(array('null' => null, 'empty' => '', 'custom' => 'x')) + ); + + // The builder of "default" gets null as the default value + $defaultRule = LIVR::getDefaultRules()['default']; + $builderArgs = null; + + $validator = new LIVR(array('field' => array('default' => null))); + $validator->registerRules(array('default' => function () use ($defaultRule, &$builderArgs) { + $builderArgs = func_get_args(); + return call_user_func_array($defaultRule, $builderArgs); + })); + + $validator->prepare(); + $this->assertCount(2, $builderArgs); + $this->assertNull($builderArgs[0]); + } + + public function testUniqueKeyValueThatIsObjectOrListIsFormatError() + { + // Also when another key of the same item is empty (LIVR 2.5, unique) + $validator = new LIVR(array('items' => array('unique' => array('a', 'b')))); + + $lists = array( + array(array('a' => '', 'b' => array('x' => 1))), + array(array('a' => array(1, 2), 'b' => null)), + array(array('b' => array(1)), array('a' => 1, 'b' => 1)), + array(array('a' => 1, 'b' => 1), array('a' => 1, 'b' => 1), array('b' => array())), + ); + + foreach ($lists as $list) { + $this->assertFalse($validator->validate(array('items' => $list)), json_encode($list)); + $this->assertSame(array('items' => 'FORMAT_ERROR'), $validator->getErrors()); + } + } + + public function testSchemaErrorsAreExceptions() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('"boolean" takes no arguments'); + + (new LIVR(array('flag' => array('boolean' => 1))))->prepare(); + } + + private function assertBooleanMatches($value, $allowedValues) + { + foreach ($allowedValues as $allowed) { + foreach (array(array('eq' => $allowed), array('one_of' => array($allowed))) as $rule) { + $validator = new LIVR(array('field' => $rule)); + $message = var_export($value, true) . ' should pass ' . json_encode($rule); + + $this->assertSame(array('field' => $value), $validator->validate(array('field' => $value)), $message); + } + } + } + + private function assertBooleanDoesNotMatch($value, $allowedValues) + { + foreach ($allowedValues as $allowed) { + foreach (array(array('eq' => $allowed), array('one_of' => array($allowed))) as $rule) { + $validator = new LIVR(array('field' => $rule)); + $message = var_export($value, true) . ' should fail ' . json_encode($rule); + + $this->assertFalse($validator->validate(array('field' => $value)), $message); + $this->assertSame(array('field' => 'NOT_ALLOWED_VALUE'), $validator->getErrors(), $message); + } + } + } +} + +// The date rules read the clock with time() in their namespace. The mock function must be defined +// before the first call there, otherwise PHP binds the call to the global time() +LIVR25ApiTest::defineFunctionMock('Validator\LIVR\Rules', 'time'); diff --git a/tests/test_suite/negative/08-like/errors.json b/tests/test_suite/negative/08-like/errors.json index 95e9b3f..87351f9 100644 --- a/tests/test_suite/negative/08-like/errors.json +++ b/tests/test_suite/negative/08-like/errors.json @@ -4,6 +4,11 @@ "middle_name": "WRONG_FORMAT", "age": "WRONG_FORMAT", + "zip_trailing_newline": "WRONG_FORMAT", + "zip_text_after_newline": "WRONG_FORMAT", + "zip_text_before_newline": "WRONG_FORMAT", + "code_trailing_newline": "WRONG_FORMAT", + "value_is_hash": "FORMAT_ERROR", "value_is_empty_hash": "FORMAT_ERROR", "value_is_array": "FORMAT_ERROR", diff --git a/tests/test_suite/negative/08-like/input.json b/tests/test_suite/negative/08-like/input.json index 78956ad..4e68837 100644 --- a/tests/test_suite/negative/08-like/input.json +++ b/tests/test_suite/negative/08-like/input.json @@ -7,6 +7,11 @@ "extra_name": "extra_name", + "zip_trailing_newline": "12345\n", + "zip_text_after_newline": "12345\n