diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..25adc95 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,2 @@ +github: dg +custom: "https://nette.org/donate" diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml new file mode 100644 index 0000000..1fe1626 --- /dev/null +++ b/.github/workflows/coding-style.yml @@ -0,0 +1,31 @@ +name: Coding Style + +on: [push, pull_request] + +jobs: + nette_cc: + name: Nette Code Checker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.4 + coverage: none + + - run: composer create-project nette/code-checker temp/code-checker ^3 --no-progress + - run: php temp/code-checker/code-checker --strict-types --ignore "tests/*/fixtures" + + + nette_cs: + name: Nette Coding Standard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.4 + coverage: none + + - run: composer create-project nette/coding-standard temp/coding-standard ^2 --no-progress + - run: php temp/coding-standard/ecs check src tests --config temp/coding-standard/coding-standard-php71.yml diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..e129028 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,21 @@ +name: Static Analysis (only informative) + +on: + push: + branches: + - master + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.4 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer phpstan + continue-on-error: true # is only informative diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..983b2a2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.1', '7.2', '7.3', '7.4', '8.0'] + + fail-fast: false + + name: PHP ${{ matrix.php }} tests + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: vendor/bin/tester tests -s -C + - if: failure() + uses: actions/upload-artifact@v2 + with: + name: output + path: tests/**/output + + + lowest_dependencies: + name: Lowest Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.1 + coverage: none + + - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable + - run: vendor/bin/tester tests -s -C + + + code_coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar + - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src + - run: php coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/.travis.yml b/.travis.yml index 9599553..4072ec2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ php: - 7.2 - 7.3 - 7.4 + - nightly before_install: # turn off XDebug @@ -27,6 +28,7 @@ jobs: - name: Nette Code Checker + php: 7.4 install: - travis_retry composer create-project nette/code-checker temp/code-checker ^3 --no-progress script: @@ -34,6 +36,7 @@ jobs: - name: Nette Coding Standard + php: 7.4 install: - travis_retry composer create-project nette/coding-standard temp/coding-standard ^2 --no-progress script: @@ -47,7 +50,7 @@ jobs: - stage: Code Coverage - php: 7.3 + php: 7.4 script: - vendor/bin/tester -p phpdbg tests -s --coverage ./coverage.xml --coverage-src ./src after_script: @@ -60,7 +63,7 @@ jobs: - stage: Code Coverage -sudo: false +dist: xenial cache: directories: diff --git a/composer.json b/composer.json index ae24819..02a5ea5 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.1-dev" } } } diff --git a/readme.md b/readme.md index e291596..822729f 100644 --- a/readme.md +++ b/readme.md @@ -11,27 +11,33 @@ Nette Schema Introduction ============ -Handy library for validating data structures against a given Schema. +A practical library for validation and normalization of data structures against a given schema with a smart & easy-to-understand API. -Documentation can be found on the [website](https://doc.nette.org/schema). +Documentation can be found on the [website](https://doc.nette.org/schema). If you like it, **[please make a donation now](https://github.com/sponsors/dg)**. Thank you! -If you like Nette, **[please make a donation now](https://nette.org/donate)**. Thank you! +Installation: +```shell +composer require nette/schema +``` -Installation -============ +It requires PHP version 7.1 and supports PHP up to 8.0. -The recommended way to install is via Composer: -``` -composer require nette/schema -``` +Support Project +--------------- + +Do you like Schema? Are you looking forward to the new features? -It requires PHP version 7.1 and supports PHP up to 7.4. +[![Donate](https://files.nette.org/icons/donation-1.svg?)](https://nette.org/make-donation?to=schema) -Usage -===== +Basic Usage +----------- + +In variable `$schema` we have a validation schema (what exactly this means and how to create it we will say later) and in variable `$data` we have a data structure that we want to validate and normalize. This can be, for example, data sent by the user through an API, configuration file, etc. + +The task is handled by the [Nette\Schema\Processor](https://api.nette.org/3.0/Nette/Schema/Processor.html) class, which processes the input and either returns normalized data or throws an [Nette\Schema\ValidationException](https://api.nette.org/3.0/Nette/Schema/ValidationException.html) exception on error. ```php $processor = new Nette\Schema\Processor; @@ -39,15 +45,18 @@ $processor = new Nette\Schema\Processor; try { $normalized = $processor->process($schema, $data); } catch (Nette\Schema\ValidationException $e) { - echo 'Data are not valid: ' . $e->getMessage(); + echo 'Data is invalid: ' . $e->getMessage(); } - -// in case of error it throws Nette\Schema\ValidationException ``` -Defining schema +Method `$e->getMessages()` returns array of all message strings and `$e->getMessageObjects()` return all messages as [Nette\Schema\Message](https://api.nette.org/3.0/Nette/Schema/Message.html) objects. + + +Defining Schema --------------- +And now let's create a schema. The class [Nette\Schema\Expect](https://api.nette.org/3.0/Nette/Schema/Expect.html) is used to define it, we actually define expectations of what the data should look like. Let's say that the input data must be a structure (e.g. an array) containing elements `processRefund` of type bool and `refundAmount` of type int. + ```php use Nette\Schema\Expect; @@ -55,118 +64,163 @@ $schema = Expect::structure([ 'processRefund' => Expect::bool(), 'refundAmount' => Expect::int(), ]); +``` +We believe that the schema definition looks clear, even if you see it for the very first time. + +Lets send the following data for validation: + +```php $data = [ 'processRefund' => true, 'refundAmount' => 17, ]; -$normalized = $processor->process($schema, $data); // it passes +$normalized = $processor->process($schema, $data); // OK, it passes ``` -If you're validating data passed, you can cast strings and booleans to the expected types defined by your schema: +The output, i.e. the value `$normalized`, is the object `stdClass`. If we want the output to be an array, we add a cast to schema `Expect::structure([...])->castTo('array')`. -```php -$schema = Expect::structure([ - 'processRefund' => Expect::scalar()->castTo('bool'), - 'refundAmount' => Expect::scalar()->castTo('int'), -]); +All elements of the structure are optional and have a default value `null`. Example: +```php $data = [ - 'processRefund' => 1, - 'refundAmount' => '17', + 'refundAmount' => 17, ]; -$normalized = $processor->process($schema, $data); // it passes - -is_bool($normalized->processRefund); // true -is_int($normalized->refundAmount); // true +$normalized = $processor->process($schema, $data); // OK, it passes +// $normalized = {'processRefund' => null, 'refundAmount' => 17} ``` -By default, all properties are optional and have default value `null`, or `[]` in the case of arrays. +The fact that the default value is `null` does not mean that it would be accepted in the input data `'processRefund' => null`. No, the input must be boolean, i.e. only `true` or `false`. We would have to explicitly allow `null` via `Expect::bool()->nullable()`. + +An item can be made mandatory using `Expect::bool()->required()`. We change the default value to `false` using `Expect::bool()->default(false)` or shortly using `Expect::bool(false)`. -You can change the default value as follows: +And what if we wanted to accept `1` and `0` besides booleans? Then we list the allowed values, which we will also normalize to boolean: ```php $schema = Expect::structure([ - 'processRefund' => Expect::bool()->default(true), // or Expect::bool(true) + 'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'), + 'refundAmount' => Expect::int(), ]); -$data = []; - -// validates, and sets defaults for missing properties $normalized = $processor->process($schema, $data); +is_bool($normalized->processRefund); // true +``` + +Now you know the basics of how the schema is defined and how the individual elements of the structure behave. We will now show what all the other elements can be used in defining a schema. + + + +Data Types: type() +------------------ + +All standard PHP data types can be listed in the schema: -// $normalized->processRefund === true; +```php +Expect::string($default = null) +Expect::int($default = null) +Expect::float($default = null) +Expect::bool($default = null) +Expect::null() +Expect::array($default = []) ``` +And then all types [supported by the Validators](https://doc.nette.org/validators#toc-validation-rules) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`. + +You can also use union notation: + +```php +Expect::type('bool|string|array') +``` + +The default value is always `null` except for `array` and `list`, where it is an empty array. (A list is an array indexed in ascending order of numeric keys from zero, that is, a non-associative array). -Arrays of items ---------------- -Array where only string items are allowed: +Array of Values: arrayOf() listOf() +----------------------------------- + +The array is too general structure, it is more useful to specify exactly what elements it can contain. For example, an array whose elements can only be strings: ```php $schema = Expect::arrayOf('string'); -$processor->process($schema, ['key1' => 'a', 'key2' => 'b']); // it passes -$processor->process($schema, ['key' => 123]); // error: The option 'key' expects to be string, int 123 given. +$processor->process($schema, ['hello', 'world']); // OK +$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK +$processor->process($schema, ['key' => 123]); // ERROR: 123 is not a string ``` -Indexed array (ie. with numeric keys) where only string items are allowed: +The list is an indexed array: ```php $schema = Expect::listOf('string'); -$processor->process($schema, ['a', 'b']); // it passes -$processor->process($schema, ['key' => 'a']); // error, unexpected 'key' +$processor->process($schema, ['a', 'b']); // OK +$processor->process($schema, ['a', 123]); // ERROR: 123 is not a string +$processor->process($schema, ['key' => 'a']); // ERROR: is not a list +$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: is not a list ``` -Enumerated values and anyOf() ------------------------------ +The parameter can also be a schema, so we can write: + +```php +Expect::arrayOf(Expect::bool()) +``` + +The default value is an empty array. + + +Enumeration: anyOf() +-------------------- -The `anyOf()` is used to restrict a value to a fixed set of variants or subschemes: +`anyOf()` is a set of values ​​or schemas that a value can be. Here's how to write an array of elements that can be either `'a'`, `true`, or `null`: ```php $schema = Expect::listOf( Expect::anyOf('a', true, null) ); -$processor->process($schema, ['a', true, null, 'a']); // it passes -$processor->process($schema, ['a', false]); // error: The option '1' expects to be 'a'|true|null, false given. +$processor->process($schema, ['a', true, null, 'a']); // OK +$processor->process($schema, ['a', false]); // ERROR: false does not belong there ``` -Elements can be schema: +The enumeration elements can also be schemas: ```php $schema = Expect::listOf( Expect::anyOf(Expect::string(), true, null) ); -$processor->process($schema, ['foo', true, null, 'bar']); // it passes -$processor->process($schema, [123]); // error: The option '0' expects to be string|true|null, 123 given. +$processor->process($schema, ['foo', true, null, 'bar']); // OK +$processor->process($schema, [123]); // ERROR ``` +The default value is `null`. + + Structures ---------- -Structures are objects with defined keys. Each of these key => pairs is conventionally referred to as a “property”. +Structures are objects with defined keys. Each of these key => value pairs is referred to as a "property": -Structures accept arrays and objects and return `stdClass` objects (unless you change it with `castTo('array')` etc). +Structures accept arrays and objects and return objects `stdClass` (unless you change it with `castTo('array')`, etc.). -By default, all properties are optional and have default value `null`. You can define mandatory properties via `required()`: +By default, all properties are optional and have a default value of `null`. You can define mandatory properties using `required()`: ```php $schema = Expect::structure([ 'required' => Expect::string()->required(), - 'optional' => Expect::string(), // default is null + 'optional' => Expect::string(), // the default value is null ]); -$processor->process($schema, ['optional' => '']); // error: option 'required' is missing -$processor->process($schema, ['required' => 'foo']); // it passes, returns (object) ['required' => 'foo', 'optional' => null] +$processor->process($schema, ['optional' => '']); +// ERROR: option 'required' is missing + +$processor->process($schema, ['required' => 'foo']); +// OK, returns {'required' => 'foo', 'optional' => null} ``` -You can define nullable properties via `nullable()`: +Although `null` is the default value of the `optional` property, it is not allowed in the input data (the value must be a string). Properties accepting `null` are defined using `nullable()`: ```php $schema = Expect::structure([ @@ -174,158 +228,212 @@ $schema = Expect::structure([ 'nullable' => Expect::string()->nullable(), ]); -$processor->process($schema, ['optional' => null]); // error: 'optional' expects to be string, null given. -$processor->process($schema, ['nullable' => null]); // it passes, returns (object) ['optional' => null, 'nullable' => null] +$processor->process($schema, ['optional' => null]); +// ERROR: 'optional' expects to be string, null given. + +$processor->process($schema, ['nullable' => null]); +// OK, returns {'optional' => null, 'nullable' => null} ``` -By default, providing additional properties is forbidden: +By default, there can be no extra items in the input data: ```php $schema = Expect::structure([ 'key' => Expect::string(), ]); -$processor->process($schema, ['additional' => 1]); // error: Unexpected option 'additional' +$processor->process($schema, ['additional' => 1]); +// ERROR: Unexpected option 'additional' ``` -The `otherItems()` is used to control the handling of extra stuff, that is, properties whose names are not listed in `Expect::structure()`: +Which we can change with `otherItems()`. As a parameter, we will specify the schema for each extra element: ```php $schema = Expect::structure([ 'key' => Expect::string(), ])->otherItems(Expect::int()); -$processor->process($schema, ['additional' => 1]); // it passes +$processor->process($schema, ['additional' => 1]); // OK +$processor->process($schema, ['additional' => true]); // ERROR ``` -Size and ranges ---------------- +Deprecations +------------ -You can limit the number of elements or properties using the `min()` and `max()`: +You can deprecate property using the `deprecated([string $message])` method. Deprecation notices are returned by `$processor->getWarnings()`. + +```php +$schema = Expect::structure([ + 'old' => Expect::int()->deprecated('The option %path% is deprecated'), +]); + +$processor->process($schema, ['old' => 1]); // OK +$processor->getWarnings(); // ["The option 'old' is deprecated"] +``` + +Ranges: min() max() +------------------- + +Use `min()` and `max()` to limit the number of elements for arrays: ```php // array, at least 10 items, maximum 20 items -$schema = Expect::array()->min(10)->max(20); +Expect::array()->min(10)->max(20); ``` -The length of a string can be constrained using the `min()` and `max()`: +For strings, limit their length: ```php // string, at least 10 characters long, maximum 20 characters -$schema = Expect::string()->min(10)->max(20); +Expect::string()->min(10)->max(20); ``` -Ranges of numbers are specified using a combination of `min()` and `max()`: +For numbers, limit their value: ```php -// integer, between 10 and 20 -$schema = Expect::int()->min(10)->max(20); +// integer, between 10 and 20 inclusive +Expect::int()->min(10)->max(20); ``` -Regular expressions -------------------- +Of course, it is possible to mention only `min()`, or only `max()`: + +```php +// string, maximum 20 characters +Expect::string()->max(20); +``` + + +Regular Expressions: pattern() +------------------------------ -String can be restricted by regular expression using the `pattern()`: +Using `pattern()`, you can specify a regular expression which the **whole** input string must match (i.e. as if it were wrapped in characters `^` a `$`): ```php // just 9 digits -$schema = Expect::string()->pattern('\d{9}'); +Expect::string()->pattern('\d{9}'); ``` -Data mapping to objects ------------------------ -Schema can be generated from class: +Custom Assertions: assert() +--------------------------- + +You can add any other restrictions using `assert(callable $fn)`. + +```php +$countIsEven = function ($v) { return count($v) % 2 === 0; }; + +$schema = Expect::arrayOf('string') + ->assert($countIsEven); // the count must be even + +$processor->process($schema, ['a', 'b']); // OK +$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 is not even +``` + +Or + +```php +Expect::string()->assert('is_file'); // the file must exist +``` + +You can add your own description for each assertions. It will be part of the error message. + +```php +$schema = Expect::arrayOf('string') + ->assert($countIsEven, 'Even items in array'); + +$processor->process($schema, ['a', 'b', 'c']); +// Failed assertion "Even items in array" for option with value array. +``` + +The method can be called repeatedly to add more assertions. + + +Mapping to Objects: from() +-------------------------- + +You can generate structure schema from the class. Example: ```php class Config { /** @var string */ - public $dsn; - - /** @var string|null */ - public $user; - + public $name; /** @var string|null */ public $password; - /** @var bool */ - public $debugger = true; + public $admin = false; } $schema = Expect::from(new Config); $data = [ - 'dsn' => 'sqlite', - 'user' => 'root' + 'name' => 'jeff', ]; $normalized = $processor->process($schema, $data); -// $normalized is Config class -// $normalized->dsn === 'sqlite' -// $normalized->user === 'root' -// $normalized->password === null -// $normalized->debugger === true +// $normalized instanceof Config +// $normalized = {'name' => 'jeff', 'password' => null, 'admin' => false} ``` -You can even use PHP 7.4 notation: - +If you are using PHP 7.4 or higher, you can use native types: ```php class Config { - public string $dsn; - public ?string $user; + public string $name; public ?string $password; - public bool $debugger = true; + public bool $admin = false; } $schema = Expect::from(new Config); ``` -Or use anonymous class: +Anonymous classes are also supported: ```php $schema = Expect::from(new class { - public string $dsn; - public ?string $user; + public string $name; public ?string $password; - public bool $debugger = true; + public bool $admin = false; }); ``` -Custom normalization --------------------- +Because the information obtained from the class definition may not be sufficient, you can add a custom schema for the elements with the second parameter: ```php -$schema = Expect::arrayOf('string') - ->before(function ($v) { return explode(' ', $v); }); - -$normalized = $processor->process($schema, 'a b c'); // it passes and returns ['a', 'b', 'c'] +$schema = Expect::from(new Config, [ + 'name' => Expect::string()->pattern('\w:.*'), +]); ``` -Custom constraints ------------------- -```php -$schema = Expect::arrayOf('string') - ->assert(function ($v) { return count($v) % 2 === 0; }); // count must be even number +Casting: castTo() +----------------- -$processor->process($schema, ['a', 'b']); // it passes, 2 is even number -$processor->process($schema, ['a', 'b', 'c']); // error, 3 is not even number +Successfully validated data can be cast: + +```php +Expect::scalar()->castTo('string'); ``` -Or +In addition to native PHP types, you can also cast to classes: ```php -$schema = Expect::string()->assert('is_file'); // file must exist +Expect::scalar()->castTo('AddressEntity'); ``` -You can add custom description for every assert. This description will be part of error message. + +Normalization: before() +----------------------- + +Prior to the validation itself, the data can be normalized using the method `before()`. As an example, let's have an element that must be an array of strings (eg `['a', 'b', 'c']`), but receives input in the form of a string `a b c`: ```php +$explode = function ($v) { return explode(' ', $v); }; + $schema = Expect::arrayOf('string') - ->assert(function ($v) { return count($v) % 2 === 0; }, 'Even items in array'); + ->before($explode); -$processor->process($schema, ['a', 'b', 'c']); // Failed assertion "Even items in array" for option with value array. +$normalized = $processor->process($schema, 'a b c'); +// OK, returns ['a', 'b', 'c'] ``` diff --git a/src/Schema/Context.php b/src/Schema/Context.php index e665d29..fdbe292 100644 --- a/src/Schema/Context.php +++ b/src/Schema/Context.php @@ -25,15 +25,24 @@ final class Context /** @var string[] */ public $path = []; - /** @var \stdClass[] */ + /** @var Message[] */ public $errors = []; + /** @var Message[] */ + public $warnings = []; + /** @var array[] */ public $dynamics = []; - public function addError($message, $hint = null) + public function addError(string $message, string $code, array $variables = []): Message + { + return $this->errors[] = new Message($message, $code, $this->path, $variables); + } + + + public function addWarning(string $message, string $code, array $variables = []): Message { - $this->errors[] = (object) ['message' => $message, 'path' => $this->path, 'hint' => $hint]; + return $this->warnings[] = new Message($message, $code, $this->path, $variables); } } diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index ab75fbc..2e25f3c 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -68,35 +68,42 @@ public function merge($value, $base) public function complete($value, Nette\Schema\Context $context) { - $hints = $innerErrors = []; + $expecteds = $innerErrors = []; foreach ($this->set as $item) { if ($item instanceof Schema) { $dolly = new Context; $dolly->path = $context->path; $res = $item->complete($value, $dolly); if (!$dolly->errors) { + $context->warnings = array_merge($context->warnings, $dolly->warnings); return $this->doFinalize($res, $context); } foreach ($dolly->errors as $error) { - if ($error->path !== $context->path || !$error->hint) { + if ($error->path !== $context->path || empty($error->variables['expected'])) { $innerErrors[] = $error; } else { - $hints[] = $error->hint; + $expecteds[] = $error->variables['expected']; } } } else { if ($item === $value) { return $this->doFinalize($value, $context); } - $hints[] = static::formatValue($item); + $expecteds[] = Nette\Schema\Message::formatValue($item); } } if ($innerErrors) { $context->errors = array_merge($context->errors, $innerErrors); } else { - $hints = implode('|', array_unique($hints)); - $context->addError("The option %path% expects to be $hints, " . static::formatValue($value) . ' given.'); + $context->addError( + 'The option %path% expects to be %expected%, %value% given.', + Nette\Schema\Message::UNEXPECTED_VALUE, + [ + 'value' => $value, + 'expected' => implode('|', array_unique($expecteds)), + ] + ); } } @@ -104,7 +111,10 @@ public function complete($value, Nette\Schema\Context $context) public function completeDefault(Context $context) { if ($this->required) { - $context->addError('The mandatory option %path% is missing.'); + $context->addError( + 'The mandatory option %path% is missing.', + Nette\Schema\Message::OPTION_MISSING + ); return null; } if ($this->default instanceof Schema) { diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index ede2aaf..bdf6e63 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -33,6 +33,9 @@ trait Base /** @var string|null */ private $castTo; + /** @var string|null */ + private $deprecated; + public function default($value): self { @@ -69,10 +72,21 @@ public function assert(callable $handler, string $description = null): self } + /** Marks option as deprecated */ + public function deprecated(string $message = 'Option %path% is deprecated.'): self + { + $this->deprecated = $message; + return $this; + } + + public function completeDefault(Context $context) { if ($this->required) { - $context->addError('The mandatory option %path% is missing.'); + $context->addError( + 'The mandatory option %path% is missing.', + Nette\Schema\Message::OPTION_MISSING + ); return null; } return $this->default; @@ -92,11 +106,22 @@ private function doValidate($value, string $expected, Context $context): bool { try { Nette\Utils\Validators::assert($value, $expected, 'option %path%'); - return true; } catch (Nette\Utils\AssertionException $e) { - $context->addError($e->getMessage(), $expected); + $context->addError( + $e->getMessage(), + Nette\Schema\Message::UNEXPECTED_VALUE, + ['value' => $value, 'expected' => $expected] + ); return false; } + + if ($this->deprecated !== null) { + $context->addWarning( + $this->deprecated, + Nette\Schema\Message::DEPRECATED + ); + } + return true; } @@ -113,25 +138,15 @@ private function doFinalize($value, Context $context) foreach ($this->asserts as $i => [$handler, $description]) { if (!$handler($value)) { $expected = $description ? ('"' . $description . '"') : (is_string($handler) ? "$handler()" : "#$i"); - $context->addError("Failed assertion $expected for option %path% with value " . static::formatValue($value) . '.'); + $context->addError( + 'Failed assertion %assertion% for option %path% with value %value%.', + Nette\Schema\Message::FAILED_ASSERTION, + ['value' => $value, 'assertion' => $expected] + ); return; } } return $value; } - - - private static function formatValue($value): string - { - if (is_string($value)) { - return "'$value'"; - } elseif (is_bool($value)) { - return $value ? 'true' : 'false'; - } elseif (is_scalar($value)) { - return (string) $value; - } else { - return strtolower(gettype($value)); - } - } } diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index f0d187d..dac5851 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -140,11 +140,15 @@ public function complete($value, Nette\Schema\Context $context) if ($this->otherItems) { $items += array_fill_keys($extraKeys, $this->otherItems); } else { - $hint = Nette\Utils\Helpers::getSuggestion(array_map('strval', array_keys($items)), (string) $extraKeys[0]); - $s = implode("', '", array_map(function ($key) use ($context) { - return implode(' › ', array_merge($context->path, [$key])); - }, $hint ? [$extraKeys[0]] : $extraKeys)); - $context->addError("Unexpected option '$s'" . ($hint ? ", did you mean '$hint'?" : '.')); + $keys = array_map('strval', array_keys($items)); + foreach ($extraKeys as $key) { + $hint = Nette\Utils\Helpers::getSuggestion($keys, (string) $key); + $context->addError( + 'Unexpected option %path%' . ($hint ? ", did you mean '%hint%'?" : '.'), + Nette\Schema\Message::UNEXPECTED_KEY, + ['hint' => $hint] + )->path[] = $key; + } } } diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index 9874f65..1998e9f 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -33,6 +33,9 @@ final class Type implements Schema /** @var string|null */ private $pattern; + /** @var bool */ + private $preventMergingDefaults = false; + public function __construct(string $type) { @@ -72,6 +75,7 @@ public function max(?float $max): self /** * @param string|Schema $type + * @internal use arrayOf() or listOf() */ public function items($type = 'mixed'): self { @@ -87,6 +91,13 @@ public function pattern(?string $pattern): self } + public function preventMergingDefaults(bool $prevent = true): self + { + $this->preventMergingDefaults = $prevent; + return $this; + } + + /********************* processing ****************d*g**/ @@ -140,7 +151,11 @@ public function complete($value, Context $context) return; } if ($this->pattern !== null && !preg_match("\x01^(?:$this->pattern)$\x01Du", $value)) { - $context->addError("The option %path% expects to match pattern '$this->pattern', '$value' given."); + $context->addError( + "The option %path% expects to match pattern '%pattern%', %value% given.", + Nette\Schema\Message::PATTERN_MISMATCH, + ['value' => $value, 'pattern' => $this->pattern] + ); return; } @@ -160,6 +175,10 @@ public function complete($value, Context $context) } } + if ($this->preventMergingDefaults && is_array($value) && count($value) > 0) { + $value[Helpers::PREVENT_MERGING] = true; + } + $value = Helpers::merge($value, $this->default); return $this->doFinalize($value, $context); } diff --git a/src/Schema/Message.php b/src/Schema/Message.php new file mode 100644 index 0000000..4051f36 --- /dev/null +++ b/src/Schema/Message.php @@ -0,0 +1,84 @@ +message = $message; + $this->code = $code; + $this->path = $path; + $this->variables = $variables; + } + + + public function toString(): string + { + $vars = $this->variables; + $vars['path'] = $this->path ? "'" . implode(' › ', $this->path) . "'" : null; + $vars['value'] = self::formatValue($vars['value'] ?? null); + + return preg_replace_callback('~( ?)%(\w+)%~', function ($m) use ($vars) { + [, $space, $key] = $m; + return $vars[$key] === null ? '' : $space . $vars[$key]; + }, $this->message); + } + + + public static function formatValue($value): string + { + if (is_string($value)) { + return "'$value'"; + } elseif (is_bool($value)) { + return $value ? 'true' : 'false'; + } elseif (is_scalar($value)) { + return (string) $value; + } else { + return strtolower(gettype($value)); + } + } +} diff --git a/src/Schema/Processor.php b/src/Schema/Processor.php index ee77662..392a3de 100644 --- a/src/Schema/Processor.php +++ b/src/Schema/Processor.php @@ -22,6 +22,9 @@ final class Processor /** @var array */ public $onNewContext = []; + /** @var Context|null */ + private $context; + /** @var bool */ private $skipDefaults; @@ -39,11 +42,11 @@ public function skipDefaults(bool $value = true) */ public function process(Schema $schema, $data) { - $context = $this->createContext(); - $data = $schema->normalize($data, $context); - $this->throwsErrors($context); - $data = $schema->complete($data, $context); - $this->throwsErrors($context); + $this->createContext(); + $data = $schema->normalize($data, $this->context); + $this->throwsErrors(); + $data = $schema->complete($data, $this->context); + $this->throwsErrors(); return $data; } @@ -55,39 +58,46 @@ public function process(Schema $schema, $data) */ public function processMultiple(Schema $schema, array $dataset) { - $context = $this->createContext(); + $this->createContext(); $flatten = null; $first = true; foreach ($dataset as $data) { - $data = $schema->normalize($data, $context); - $this->throwsErrors($context); + $data = $schema->normalize($data, $this->context); + $this->throwsErrors(); $flatten = $first ? $data : $schema->merge($data, $flatten); $first = false; } - $data = $schema->complete($flatten, $context); - $this->throwsErrors($context); + $data = $schema->complete($flatten, $this->context); + $this->throwsErrors(); return $data; } - private function throwsErrors(Context $context): void + /** + * @return string[] + */ + public function getWarnings(): array { - $messages = []; - foreach ($context->errors as $error) { - $pathStr = " '" . implode(' › ', $error->path) . "'"; - $messages[] = str_replace(' %path%', $error->path ? $pathStr : '', $error->message); + $res = []; + foreach ($this->context->warnings as $message) { + $res[] = $message->toString(); } - if ($messages) { - throw new ValidationException($messages[0], $messages); + return $res; + } + + + private function throwsErrors(): void + { + if ($this->context->errors) { + throw new ValidationException(null, $this->context->errors); } } - private function createContext(): Context + private function createContext() { - $context = new Context; - $context->skipDefaults = $this->skipDefaults; - $this->onNewContext($context); - return $context; + $this->context = new Context; + $this->context->skipDefaults = $this->skipDefaults; + $this->onNewContext($this->context); } } diff --git a/src/Schema/ValidationException.php b/src/Schema/ValidationException.php index 6ffe52e..4c2af02 100644 --- a/src/Schema/ValidationException.php +++ b/src/Schema/ValidationException.php @@ -17,18 +17,37 @@ */ class ValidationException extends Nette\InvalidStateException { - /** @var array */ + /** @var Message[] */ private $messages; - public function __construct(string $message, array $messages = []) + /** + * @param Message[] $messages + */ + public function __construct(?string $message, array $messages = []) { - parent::__construct($message); - $this->messages = $messages ?: [$message]; + parent::__construct($message ?: $messages[0]->toString()); + $this->messages = $messages; } + /** + * @return string[] + */ public function getMessages(): array + { + $res = []; + foreach ($this->messages as $message) { + $res[] = $message->toString(); + } + return $res; + } + + + /** + * @return Message[] + */ + public function getMessageObjects(): array { return $this->messages; } diff --git a/tests/Schema/Expect.anyOf.phpt b/tests/Schema/Expect.anyOf.phpt index a04b557..f66462e 100644 --- a/tests/Schema/Expect.anyOf.phpt +++ b/tests/Schema/Expect.anyOf.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // with scalars +test('with scalars', function () { $schema = Expect::anyOf('one', true, Expect::int()); Assert::same('one', (new Processor)->process($schema, 'one')); @@ -37,7 +37,7 @@ test(function () { // with scalars }); -test(function () { // with complex structure +test('with complex structure', function () { $schema = Expect::anyOf(Expect::listOf('string'), true, Expect::int()); checkValidationErrors(function () use ($schema) { @@ -52,7 +52,7 @@ test(function () { // with complex structure }); -test(function () { // with asserts +test('with asserts', function () { $schema = Expect::anyOf(Expect::string()->assert('strlen'), true); checkValidationErrors(function () use ($schema) { @@ -67,7 +67,7 @@ test(function () { // with asserts }); -test(function () { // no default value +test('no default value', function () { $schema = Expect::structure([ 'key1' => Expect::anyOf(Expect::string(), Expect::int()), 'key2' => Expect::anyOf(Expect::string('default'), true, Expect::int()), @@ -81,7 +81,7 @@ test(function () { // no default value }); -test(function () { // required +test('required', function () { $schema = Expect::structure([ 'key1' => Expect::anyOf(Expect::string(), Expect::int())->required(), 'key2' => Expect::anyOf(Expect::string('default'), true, Expect::int())->required(), @@ -102,7 +102,7 @@ test(function () { // required }); -test(function () { // required as argument +test('required as argument', function () { $schema = Expect::structure([ 'key1' => Expect::anyOf(Expect::string(), Expect::int())->required(), 'key1nr' => Expect::anyOf(Expect::string(), Expect::int())->required(false), @@ -128,7 +128,7 @@ test(function () { // required as argument }); -test(function () { // not nullable +test('not nullable', function () { $schema = Expect::structure([ 'key1' => Expect::anyOf(Expect::string(), Expect::int()), 'key2' => Expect::anyOf(Expect::string('default'), true, Expect::int()), @@ -145,7 +145,7 @@ test(function () { // not nullable }); -test(function () { // required & nullable +test('required & nullable', function () { $schema = Expect::structure([ 'key1' => Expect::anyOf(Expect::string()->nullable(), Expect::int())->required(), 'key2' => Expect::anyOf(Expect::string('default'), true, Expect::int(), null)->required(), @@ -159,7 +159,22 @@ test(function () { // required & nullable }); -test(function () { // nullable anyOf +test('deprecated item', function () { + $schema = Expect::anyOf('one', true, Expect::int()->deprecated()); + + $processor = new Processor; + Assert::same('one', $processor->process($schema, 'one')); + Assert::same([], $processor->getWarnings()); + + Assert::same(true, $processor->process($schema, true)); + Assert::same([], $processor->getWarnings()); + + Assert::same(123, $processor->process($schema, 123)); + Assert::same(['Option is deprecated.'], $processor->getWarnings()); +}); + + +test('nullable anyOf', function () { $schema = Expect::anyOf(Expect::string(), true)->nullable(); Assert::same('one', (new Processor)->process($schema, 'one')); @@ -172,7 +187,7 @@ test(function () { // nullable anyOf }); -test(function () { // processing +test('processing', function () { $schema = Expect::anyOf(Expect::string(), true)->nullable(); $processor = new Processor; @@ -189,7 +204,7 @@ test(function () { // processing }); -test(function () { // Schema as default value +test('Schema as default value', function () { $default = Expect::structure([ 'key2' => Expect::string(), ])->castTo('array'); diff --git a/tests/Schema/Expect.array.phpt b/tests/Schema/Expect.array.phpt index 16bc44a..22cd7fa 100644 --- a/tests/Schema/Expect.array.phpt +++ b/tests/Schema/Expect.array.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // without default value +test('without default value', function () { $schema = Expect::array(); Assert::same([], (new Processor)->process($schema, [])); @@ -35,7 +35,7 @@ test(function () { // without default value }); -test(function () { // merging +test('merging', function () { $schema = Expect::array([ 'key1' => 'val1', 'key2' => 'val2', @@ -77,7 +77,7 @@ test(function () { // merging }); -test(function () { // merging & other items validation +test('merging & other items validation', function () { $schema = Expect::array([ 'key1' => 'val1', 'key2' => 'val2', @@ -114,7 +114,7 @@ test(function () { // merging & other items validation }); -test(function () { // merging & other items validation +test('merging & other items validation', function () { $schema = Expect::array()->items('string'); Assert::same([ @@ -147,7 +147,7 @@ test(function () { // merging & other items validation }); -test(function () { // items() & scalar +test('items() & scalar', function () { $schema = Expect::array([ 'a' => 'defval', ])->items('string'); @@ -180,7 +180,7 @@ test(function () { // items() & scalar }); -test(function () { // items() & structure +test('items() & structure', function () { $schema = Expect::array([ 'a' => 'defval', ])->items(Expect::structure(['k' => Expect::string()])); @@ -214,7 +214,7 @@ test(function () { // items() & structure }); -test(function () { // arrayOf() & scalar +test('arrayOf() & scalar', function () { $schema = Expect::arrayOf('string|int'); Assert::same([], (new Processor)->process($schema, [])); @@ -231,14 +231,29 @@ test(function () { // arrayOf() & scalar }); -test(function () { // arrayOf() error +test('arrayOf() with defaults', function () { + $schema = Expect::arrayOf('string|int')->default(['foo', 42]); + + Assert::same(['foo', 42], (new Processor)->process($schema, null)); + Assert::same(['foo', 42], (new Processor)->process($schema, [])); + Assert::same(['foo', 42, 'bar'], (new Processor)->process($schema, ['bar'])); + + $schema->preventMergingDefaults(); + + Assert::same(['foo', 42], (new Processor)->process($schema, null)); + Assert::same(['foo', 42], (new Processor)->process($schema, [])); + Assert::same(['bar'], (new Processor)->process($schema, ['bar'])); +}); + + +test('arrayOf() error', function () { Assert::exception(function () { Expect::arrayOf(['a' => Expect::string()]); }, TypeError::class); }); -test(function () { // type[] +test('type[]', function () { $schema = Expect::type('int[]'); Assert::same([], (new Processor)->process($schema, null)); diff --git a/tests/Schema/Expect.assert.phpt b/tests/Schema/Expect.assert.phpt index 49d23c8..4338182 100644 --- a/tests/Schema/Expect.assert.phpt +++ b/tests/Schema/Expect.assert.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // single assertion +test('single assertion', function () { $schema = Expect::string()->assert('is_file'); checkValidationErrors(function () use ($schema) { @@ -21,7 +21,7 @@ test(function () { // single assertion }); -test(function () { // multiple assertions +test('multiple assertions', function () { $schema = Expect::string()->assert('ctype_digit')->assert(function ($s) { return strlen($s) >= 3; }); checkValidationErrors(function () use ($schema) { @@ -36,7 +36,7 @@ test(function () { // multiple assertions }); -test(function () { // multiple assertions with custom descriptions +test('multiple assertions with custom descriptions', function () { $schema = Expect::string() ->assert('ctype_digit', 'Is number') ->assert(function ($s) { return strlen($s) >= 3; }, 'Minimal lenght'); diff --git a/tests/Schema/Expect.before.phpt b/tests/Schema/Expect.before.phpt index 0a8cd1d..fce72f3 100644 --- a/tests/Schema/Expect.before.phpt +++ b/tests/Schema/Expect.before.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $schema = Expect::array() ->before(function ($val) { return explode(',', $val); }); @@ -21,7 +21,7 @@ test(function () { }); -test(function () { // structure property +test('structure property', function () { $schema = Expect::structure([ 'key' => Expect::string()->before('strrev'), ]); @@ -33,7 +33,7 @@ test(function () { // structure property }); -test(function () { // order in structure +test('order in structure', function () { $schema = Expect::structure([ 'a' => Expect::string()->before(function ($val) use (&$order) { $order[] = 'a'; return $val; }), 'b' => Expect::string()->before(function ($val) use (&$order) { $order[] = 'b'; return $val; }), @@ -63,7 +63,7 @@ test(function () { // order in structure }); -test(function () { // order in array +test('order in array', function () { $schema = Expect::array() ->items(Expect::string()->before(function ($val) use (&$order) { $order[] = 'item'; return $val; })) ->before(function ($val) use (&$order) { $order[] = 'array'; return $val; }); diff --git a/tests/Schema/Expect.castTo.phpt b/tests/Schema/Expect.castTo.phpt index bf044ee..0d0d5bb 100644 --- a/tests/Schema/Expect.castTo.phpt +++ b/tests/Schema/Expect.castTo.phpt @@ -10,21 +10,21 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $schema = Expect::int()->castTo('string'); Assert::same('10', (new Processor)->process($schema, 10)); }); -test(function () { +test('', function () { $schema = Expect::string()->castTo('array'); Assert::same(['foo'], (new Processor)->process($schema, 'foo')); }); -test(function () { +test('', function () { $schema = Expect::array()->castTo('stdClass'); Assert::equal((object) ['a' => 1, 'b' => 2], (new Processor)->process($schema, ['a' => 1, 'b' => 2])); diff --git a/tests/Schema/Expect.dynamic.phpt b/tests/Schema/Expect.dynamic.phpt index a0b3b97..83c376c 100644 --- a/tests/Schema/Expect.dynamic.phpt +++ b/tests/Schema/Expect.dynamic.phpt @@ -23,7 +23,7 @@ class DynamicParameter implements Nette\Schema\DynamicParameter } -test(function () { +test('', function () { $schema = Expect::structure([ 'a' => Expect::string()->dynamic(), 'b' => Expect::string('def')->dynamic(), diff --git a/tests/Schema/Expect.list.phpt b/tests/Schema/Expect.list.phpt index e58bac2..201f91f 100644 --- a/tests/Schema/Expect.list.phpt +++ b/tests/Schema/Expect.list.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // without default value +test('without default value', function () { $schema = Expect::list(); Assert::same([], (new Processor)->process($schema, [])); @@ -37,7 +37,7 @@ test(function () { // without default value }); -test(function () { // merging +test('merging', function () { $schema = Expect::list([1, 2, 3]); Assert::same([1, 2, 3], (new Processor)->process($schema, [])); @@ -48,7 +48,7 @@ test(function () { // merging }); -test(function () { // merging & other items validation +test('merging & other items validation', function () { $schema = Expect::list([1, 2, 3])->items('string'); Assert::same([1, 2, 3], (new Processor)->process($schema, [])); @@ -67,7 +67,7 @@ test(function () { // merging & other items validation }); -test(function () { // listOf() & scalar +test('listOf() & scalar', function () { $schema = Expect::listOf('string'); Assert::same([], (new Processor)->process($schema, [])); @@ -88,7 +88,22 @@ test(function () { // listOf() & scalar }); -test(function () { // listOf() & error +test('listOf() with defaults', function () { + $schema = Expect::listOf('string|int')->default(['foo', 42]); + + Assert::same(['foo', 42], (new Processor)->process($schema, null)); + Assert::same(['foo', 42], (new Processor)->process($schema, [])); + Assert::same(['foo', 42, 'bar'], (new Processor)->process($schema, ['bar'])); + + $schema->preventMergingDefaults(); + + Assert::same(['foo', 42], (new Processor)->process($schema, null)); + Assert::same(['foo', 42], (new Processor)->process($schema, [])); + Assert::same(['bar'], (new Processor)->process($schema, ['bar'])); +}); + + +test('listOf() & error', function () { Assert::exception(function () { Expect::listOf(['a' => Expect::string()]); }, TypeError::class); diff --git a/tests/Schema/Expect.minmax.phpt b/tests/Schema/Expect.minmax.phpt index b87ec8b..fe74eb9 100644 --- a/tests/Schema/Expect.minmax.phpt +++ b/tests/Schema/Expect.minmax.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // int & min +test('int & min', function () { $schema = Expect::int()->min(10); Assert::same(10, (new Processor)->process($schema, 10)); @@ -21,7 +21,7 @@ test(function () { // int & min }); -test(function () { // int & max +test('int & max', function () { $schema = Expect::int()->max(20); Assert::same(20, (new Processor)->process($schema, 20)); @@ -32,7 +32,7 @@ test(function () { // int & max }); -test(function () { // int & min & max +test('int & min & max', function () { $schema = Expect::int()->min(10)->max(20); Assert::same(10, (new Processor)->process($schema, 10)); @@ -48,7 +48,7 @@ test(function () { // int & min & max }); -test(function () { // string +test('string', function () { $schema = Expect::string()->min(1)->max(5); Assert::same('hello', (new Processor)->process($schema, 'hello')); @@ -64,7 +64,7 @@ test(function () { // string }); -test(function () { // array +test('array', function () { $schema = Expect::array()->min(1)->max(3); Assert::same([1], (new Processor)->process($schema, [1])); @@ -80,7 +80,7 @@ test(function () { // array }); -test(function () { // structure +test('structure', function () { $schema = Expect::structure([])->otherItems('int')->min(1)->max(3); Assert::equal((object) [1], (new Processor)->process($schema, [1])); diff --git a/tests/Schema/Expect.pattern.phpt b/tests/Schema/Expect.pattern.phpt index cd802e6..73aadcd 100644 --- a/tests/Schema/Expect.pattern.phpt +++ b/tests/Schema/Expect.pattern.phpt @@ -10,14 +10,14 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $schema = Expect::string()->pattern('\d{9}'); Assert::same('123456789', (new Processor)->process($schema, '123456789')); }); -test(function () { +test('', function () { $schema = Expect::string()->pattern('\d{9}'); checkValidationErrors(function () use ($schema) { diff --git a/tests/Schema/Expect.scalars.phpt b/tests/Schema/Expect.scalars.phpt index 066be8b..669e964 100644 --- a/tests/Schema/Expect.scalars.phpt +++ b/tests/Schema/Expect.scalars.phpt @@ -10,7 +10,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $schema = Expect::scalar(); Assert::same('hello', (new Processor)->process($schema, 'hello')); @@ -27,7 +27,7 @@ test(function () { }); -test(function () { +test('', function () { $schema = Expect::string(); Assert::same('hello', (new Processor)->process($schema, 'hello')); @@ -50,7 +50,7 @@ test(function () { }); -test(function () { +test('', function () { $schema = Expect::type('string|bool'); Assert::same('one', (new Processor)->process($schema, 'one')); @@ -71,7 +71,7 @@ test(function () { }); -test(function () { +test('', function () { $schema = Expect::type('string')->nullable(); Assert::same('one', (new Processor)->process($schema, 'one')); diff --git a/tests/Schema/Expect.structure.phpt b/tests/Schema/Expect.structure.phpt index 088d248..2d49160 100644 --- a/tests/Schema/Expect.structure.phpt +++ b/tests/Schema/Expect.structure.phpt @@ -10,14 +10,14 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { // without items +test('without items', function () { $schema = Expect::structure([]); Assert::equal((object) [], (new Processor)->process($schema, [])); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, [1, 2, 3]); - }, ["Unexpected option '0', '1', '2'."]); + }, ["Unexpected option '0'.", "Unexpected option '1'.", "Unexpected option '2'."]); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, ['key' => 'val']); @@ -39,7 +39,7 @@ test(function () { // without items }); -test(function () { // accepts object +test('accepts object', function () { $schema = Expect::structure(['a' => Expect::string()]); Assert::equal((object) ['a' => null], (new Processor)->process($schema, (object) [])); @@ -61,7 +61,7 @@ test(function () { // accepts object }); -test(function () { // scalar items +test('scalar items', function () { $schema = Expect::structure([ 'a' => Expect::string(), 'b' => Expect::int(), @@ -82,7 +82,7 @@ test(function () { // scalar items }); -test(function () { // array items +test('array items', function () { $schema = Expect::structure([ 'a' => Expect::array(), 'b' => Expect::array([]), @@ -101,14 +101,14 @@ test(function () { // array items }); -test(function () { // default value must be readonly +test('default value must be readonly', function () { Assert::exception(function () { $schema = Expect::structure([])->default([]); }, Nette\InvalidStateException::class); }); -test(function () { // with indexed item +test('with indexed item', function () { $schema = Expect::structure([ 'key1' => Expect::string(), 'key2' => Expect::string(), @@ -141,7 +141,8 @@ test(function () { // with indexed item checkValidationErrors(function () use ($processor, $schema) { $processor->process($schema, [1, 2, 3]); }, [ - "Unexpected option '1', '2'.", + "Unexpected option '1'.", + "Unexpected option '2'.", "The option '0' expects to be string, int 1 given.", ]); @@ -161,7 +162,7 @@ test(function () { // with indexed item }); -test(function () { // with indexed item & otherItems +test('with indexed item & otherItems', function () { $schema = Expect::structure([ 'key1' => Expect::string(), 'key2' => Expect::string(), @@ -218,7 +219,7 @@ test(function () { // with indexed item & otherItems }); -test(function () { // item with default value +test('item with default value', function () { $schema = Expect::structure([ 'b' => Expect::string(123), ]); @@ -227,7 +228,11 @@ test(function () { // item with default value checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, [1, 2, 3]); - }, ["Unexpected option '0', did you mean 'b'?"]); + }, [ + "Unexpected option '0', did you mean 'b'?", + "Unexpected option '1', did you mean 'b'?", + "Unexpected option '2', did you mean 'b'?", + ]); Assert::equal((object) ['b' => 123], (new Processor)->process($schema, [])); @@ -242,8 +247,51 @@ test(function () { // item with default value Assert::equal((object) ['b' => 'val'], (new Processor)->process($schema, ['b' => 'val'])); }); +test('list with default value', function () { + $schema = Expect::structure([ + 'b' => Expect::listOf('int')->default([1, 2, 3]), + ]); + + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, null)); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, [])); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, ['b' => null])); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, ['b' => []])); + Assert::equal((object) ['b' => [1, 2, 3, 4]], (new Processor)->process($schema, ['b' => [4]])); + + $schema = Expect::structure([ + 'b' => Expect::listOf('int')->default([1, 2, 3])->preventMergingDefaults(), + ]); + + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, null)); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, [])); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, ['b' => null])); + Assert::equal((object) ['b' => [1, 2, 3]], (new Processor)->process($schema, ['b' => []])); + Assert::equal((object) ['b' => [4]], (new Processor)->process($schema, ['b' => [4]])); +}); + +test('array with default value', function () { + $schema = Expect::structure([ + 'b' => Expect::arrayOf('int')->default(['x' => 1]), + ]); + + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, null)); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, [])); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, ['b' => null])); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, ['b' => []])); + Assert::equal((object) ['b' => ['x' => 1, 'y' => 2]], (new Processor)->process($schema, ['b' => ['y' => 2]])); + + $schema = Expect::structure([ + 'b' => Expect::arrayOf('int')->default(['x' => 1])->preventMergingDefaults(), + ]); + + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, null)); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, [])); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, ['b' => null])); + Assert::equal((object) ['b' => ['x' => 1]], (new Processor)->process($schema, ['b' => []])); + Assert::equal((object) ['b' => ['y' => 2]], (new Processor)->process($schema, ['b' => ['y' => 2]])); +}); -test(function () { // item without default value +test('item without default value', function () { $schema = Expect::structure([ 'b' => Expect::string(), ]); @@ -262,7 +310,7 @@ test(function () { // item without default value }); -test(function () { // required item +test('required item', function () { $schema = Expect::structure([ 'b' => Expect::string()->required(), 'c' => Expect::array()->required(), @@ -286,7 +334,7 @@ test(function () { // required item }); -test(function () { // other items +test('other items', function () { $schema = Expect::structure([ 'key' => Expect::string(), ])->otherItems(Expect::string()); @@ -300,7 +348,7 @@ test(function () { // other items }); -test(function () { // structure items +test('structure items', function () { $schema = Expect::structure([ 'a' => Expect::structure([ 'x' => Expect::string('defval'), @@ -318,6 +366,8 @@ test(function () { // structure items (new Processor)->process($schema, [1, 2, 3]); }, [ "Unexpected option '0', did you mean 'a'?", + "Unexpected option '1', did you mean 'a'?", + "Unexpected option '2', did you mean 'a'?", "The mandatory option 'b › y' is missing.", ]); @@ -354,7 +404,8 @@ test(function () { // structure items checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, ['b' => ['x1' => 'val', 'x2' => 'val']]); }, [ - "Unexpected option 'b › x1', 'b › x2'.", + "Unexpected option 'b › x1'.", + "Unexpected option 'b › x2'.", "The mandatory option 'b › y' is missing.", ]); @@ -369,7 +420,7 @@ test(function () { // structure items }); -test(function () { // processing +test('processing', function () { $schema = Expect::structure([ 'a' => Expect::structure([ 'x' => Expect::string('defval'), @@ -398,7 +449,7 @@ test(function () { // processing }); -test(function () { // processing without default values +test('processing without default values', function () { $schema = Expect::structure([ 'a' => Expect::string(), // implicit default 'b' => Expect::string('hello'), // explicit default @@ -418,3 +469,31 @@ test(function () { // processing without default values $processor->process($schema, ['d' => 'newval']) ); }); + + +test('deprecated item', function () { + $schema = Expect::structure([ + 'b' => Expect::string()->deprecated('depr %path%'), + ]); + + $processor = new Processor; + Assert::equal( + (object) ['b' => 'val'], + $processor->process($schema, ['b' => 'val']) + ); + Assert::same(["depr 'b'"], $processor->getWarnings()); +}); + + +test('deprecated other items', function () { + $schema = Expect::structure([ + 'key' => Expect::string(), + ])->otherItems(Expect::string()->deprecated()); + + $processor = new Processor; + Assert::equal((object) ['key' => null], $processor->process($schema, [])); + Assert::same([], $processor->getWarnings()); + + Assert::equal((object) ['key' => null, 'other' => 'foo'], $processor->process($schema, ['other' => 'foo'])); + Assert::same(["Option 'other' is deprecated."], $processor->getWarnings()); +}); diff --git a/tests/Schema/Processor.context.phpt b/tests/Schema/Processor.context.phpt index 193cbf9..982e424 100644 --- a/tests/Schema/Processor.context.phpt +++ b/tests/Schema/Processor.context.phpt @@ -5,12 +5,13 @@ declare(strict_types=1); use Nette\Schema\Context; use Nette\Schema\Expect; use Nette\Schema\Processor; +use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $schema = Expect::structure([ 'r' => Expect::string()->required(), ]); @@ -20,7 +21,18 @@ test(function () { $context->path = ['first']; }; - checkValidationErrors(function () use ($schema, $processor) { + $e = checkValidationErrors(function () use ($schema, $processor) { $processor->process($schema, []); }, ["The mandatory option 'first › r' is missing."]); + + Assert::equal( + [ + new Nette\Schema\Message( + 'The mandatory option %path% is missing.', + Nette\Schema\Message::OPTION_MISSING, + ['first', 'r'] + ), + ], + $e->getMessageObjects() + ); }); diff --git a/tests/Schema/heterogenous.phpt b/tests/Schema/heterogenous.phpt index 89c994b..de2f8df 100644 --- a/tests/Schema/heterogenous.phpt +++ b/tests/Schema/heterogenous.phpt @@ -39,7 +39,7 @@ class MySchema implements Schema } -test(function () { +test('', function () { $schema = Expect::arrayOf(new MySchema); $processor = new Processor; @@ -49,7 +49,7 @@ test(function () { }); -test(function () { +test('', function () { $schema = Expect::arrayOf(new MySchema); $processor = new Processor; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ca6b68f..0d44959 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,15 +18,16 @@ date_default_timezone_set('Europe/Prague'); -function test(\Closure $function): void +function test(string $title, Closure $function): void { $function(); } -function checkValidationErrors(\Closure $function, array $messages): void +function checkValidationErrors(\Closure $function, array $messages): Nette\Schema\ValidationException { $e = Assert::exception($function, Nette\Schema\ValidationException::class); Assert::same($messages, $e->getMessages()); Assert::same($messages[0], $e->getMessage()); + return $e; }