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