Nette Documentation Preview

syntax
Nette Schema
************

.[perex]
Praktyczna biblioteka do walidacji i normalizacji struktur danych względem podanego schematu, ze sprytnym i łatwym do zrozumienia API.

Instalacja:

```shell
composer require nette/schema
```


Podstawowe użycie
-----------------

W zmiennej `$schema` mamy schemat walidacyjny (za chwilę wyjaśnimy, co to znaczy i jak go utworzyć), a w zmiennej `$data` strukturę danych, którą chcemy zwalidować i znormalizować. Mogą to być na przykład dane wysłane przez użytkownika przez API, plik konfiguracyjny itd.

Zadaniem zajmuje się klasa [api:Nette\Schema\Processor], która przetwarza wejście i albo zwraca znormalizowane dane, albo rzuca w razie błędu wyjątek [api:Nette\Schema\ValidationException].

```php
$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Data is invalid: ' . $e->getMessage();
}
```

Metoda `$e->getMessages()` zwraca tablicę wszystkich komunikatów jako ciągów, a `$e->getMessageObjects()` zwraca wszystkie komunikaty jako obiekty "Nette\Schema\Message":https://api.nette.org/schema/master/Nette/Schema/Message.html.


Definiowanie schematu
---------------------

A teraz utwórzmy schemat. Do jego zdefiniowania służy klasa [api:Nette\Schema\Expect]; w istocie definiujemy oczekiwania co do tego, jak dane mają wyglądać. Powiedzmy, że dane wejściowe muszą być strukturą (np. tablicą) zawierającą elementy `processRefund` typu bool i `refundAmount` typu int.

```php
use Nette\Schema\Expect;

$schema = Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
]);
```

Wierzymy, że definicja schematu wygląda zrozumiale, nawet jeśli widzisz ją po raz pierwszy.

Wyślijmy do walidacji poniższe dane:

```php
$data = [
	'processRefund' => true,
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, przechodzi walidację
```

Wyjściem, czyli wartością `$normalized`, jest obiekt `stdClass`. Gdybyśmy chcieli, żeby wyjściem była tablica, dodalibyśmy do schematu rzutowanie `->castTo('array')`.

Wszystkie elementy struktury są opcjonalne i mają wartość domyślną `null`. Przykład:

```php
$data = [
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, przechodzi walidację
// $normalized = {'processRefund' => null, 'refundAmount' => 17}
```

To, że wartością domyślną jest `null`, nie oznacza, że w danych wejściowych zostałoby przyjęte `'processRefund' => null`. Nie, wejście musi być wartością logiczną, czyli wyłącznie `true` albo `false`. Musielibyśmy jawnie dopuścić `null` za pomocą `Expect::bool()->nullable()`.

Pozycję można uczynić obowiązkową za pomocą `Expect::bool()->required()`. Wartość domyślną możemy zmienić na przykład na `false` za pomocą `Expect::bool()->default(false)` albo skrótem `Expect::bool(false)`.

A co, gdybyśmy chcieli oprócz wartości logicznych przyjmować też `1` i `0`? Wtedy wypisujemy wartości, które chcemy również normalizować do wartości logicznej:

```php
$schema = Expect::structure([
	'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
	'refundAmount' => Expect::int(),
]);

$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true
```

Teraz znasz podstawy definiowania schematu i to, jak zachowują się elementy struktury. Pokażemy teraz, jakich innych elementów możesz używać przy definiowaniu schematu.


Typy danych: type()
-------------------

W schemacie można podać wszystkie standardowe typy danych PHP:

```php
Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])
Expect::list($default = [])
```

A także wszystkie typy [wspierane przez klasę Validators |utils:validators#Oczekiwane typy], na przykład `Expect::type('scalar')` albo skrótem `Expect::scalar()`. Także nazwy klas albo interfejsów, np. `Expect::type('AddressEntity')`.

Można też użyć składni unii:

```php
Expect::type('bool|string|array')
```

Wartością domyślną jest zawsze `null`, z wyjątkiem `array` i `list`, gdzie jest to pusta tablica. (Lista to tablica indeksowana szeregiem kluczy numerycznych zaczynających się od zera, czyli tablica nieasocjacyjna).


Tablica wartości: arrayOf() listOf()
------------------------------------

Tablica reprezentuje zbyt ogólną strukturę; przydatniej jest podać dokładnie, jakie elementy może zawierać. Na przykład tablica, której elementami mogą być tylko ciągi:

```php
$schema = Expect::arrayOf('string');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 nie jest ciągiem
```

Drugim parametrem można podać klucze (od wersji 1.2):

```php
$schema = Expect::arrayOf('string', 'int');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' nie jest int
```

Lista to tablica indeksowana:

```php
$schema = Expect::listOf('string');

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 nie jest ciągiem
$processor->process($schema, ['key' => 'a']); // ERROR: to nie lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: to też nie lista
```

Parametrem może być też schemat, więc możemy napisać:

```php
Expect::arrayOf(Expect::bool())
```

Wartością domyślną jest pusta tablica. Jeśli podasz wartość domyślną, zostanie scalona z przekazanymi danymi. Można to wyłączyć za pomocą `mergeDefaults(false)` (od wersji 1.1).


Wyliczenie: anyOf()
-------------------

`anyOf()` reprezentuje zbiór wartości albo schematów, które wartość może przyjąć. Oto jak zapisać tablicę elementów, które mogą być albo `'a'`, albo `true`, albo `null`:

```php
$schema = Expect::listOf(
	Expect::anyOf('a', true, null),
);

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false tam nie należy
```

Elementami wyliczenia mogą być też schematy:

```php
$schema = Expect::listOf(
	Expect::anyOf(Expect::string(), true, null),
);

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR
```

Metoda `anyOf()` przyjmuje warianty jako osobne parametry, a nie jako tablicę. Żeby przekazać jej tablicę wartości, użyj operatora rozpakowującego `anyOf(...$variants)`.

Wartością domyślną jest `null`. Metodą `firstIsDefault()` uczynisz domyślną pierwszą pozycję:

```php
// domyślną jest 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();
```


Struktury
---------

Struktury to obiekty ze zdefiniowanymi kluczami. Każdą parę klucz-wartość nazywamy "właściwością".

Struktury przyjmują tablice i obiekty, a zwracają obiekty `stdClass`.

Domyślnie wszystkie właściwości są opcjonalne i mają wartość domyślną `null`. Właściwości obowiązkowe definiujesz za pomocą `required()`:

```php
$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // wartość domyślna to null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: brakuje opcji 'required'

$processor->process($schema, ['required' => 'foo']);
// OK, zwraca {'required' => 'foo', 'optional' => null}
```

Sama struktura jest obowiązkowa. Jeśli więc jest zagnieżdżona w innej strukturze, a wejście jej nie zawiera, i tak zostaje utworzona, a błąd zgłasza wtedy, gdy zawiera właściwość wymaganą. Użyj `required(false)`, żeby uczynić całą zagnieżdżoną strukturę opcjonalną. Jeśli brakuje jej w wejściu, w wyjściu pojawia się `null`, ale jeśli jest obecna, jej wymagane właściwości są egzekwowane:

```php
$schema = Expect::structure([
	'db' => Expect::structure([
		'dsn' => Expect::string()->required(),
	])->required(false),
]);

$processor->process($schema, []);
// OK, zwraca {'db' => null}

$processor->process($schema, ['db' => []]);
// ERROR: brakuje 'db › dsn'
```

Jeśli nie chcesz mieć w wyjściu właściwości z wartością domyślną, użyj `skipDefaults()`:

```php
$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(),
])->skipDefaults();

$processor->process($schema, ['required' => 'foo']);
// OK, zwraca {'required' => 'foo'}
```

Choć `null` jest wartością domyślną właściwości `optional`, nie jest dozwolony w danych wejściowych (wartość musi być ciągiem). Właściwości przyjmujące `null` definiuje się za pomocą `nullable()`:

```php
$schema = Expect::structure([
	'optional' => Expect::string(),
	'nullable' => Expect::string()->nullable(),
]);

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.

$processor->process($schema, ['nullable' => null]);
// OK, zwraca {'optional' => null, 'nullable' => null}
```

Tablicę wszystkich właściwości struktury zwraca metoda `getShape()`.

Domyślnie w danych wejściowych nie mogą być obecne dodatkowe pozycje:

```php
$schema = Expect::structure([
	'key' => Expect::string(),
]);

$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'
```

Można to zmienić za pomocą `otherItems()`. Jako parametr przekaż schemat walidujący każdą nadmiarową pozycję:

```php
$schema = Expect::structure([
	'key' => Expect::string(),
])->otherItems(Expect::int());

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR
```

Nową strukturę możesz utworzyć, rozszerzając inną za pomocą `extend()`:

```php
$dog = Expect::structure([
	'name' => Expect::string(),
	'age' => Expect::int(),
]);

$dogWithBreed = $dog->extend([
	'breed' => Expect::string(),
]);
```


Tablica .{data-version:1.3.2}
-----------------------------

Tablica ze zdefiniowanymi kluczami. Dotyczy jej wszystko to, co dotyczy [struktur |#Struktury].

```php
$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // wartość domyślna to null
]);
```

Możesz też zdefiniować tablicę indeksowaną, zwaną krotką:

```php
$schema = Expect::array([
	Expect::int(),
	Expect::string(),
	Expect::bool(),
]);

$processor->process($schema, [1, 'hello', true]); // OK
```


Właściwości przestarzałe
------------------------

Właściwość możesz oznaczyć jako przestarzałą metodą `deprecated([string $message])`. Informacja o przestarzałości zwracana jest przez `$processor->getWarnings()`:

```php
$schema = Expect::structure([
	'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]
```


Zakresy: min() max()
--------------------

Za pomocą `min()` i `max()` ogranicz liczbę elementów tablic:

```php
// tablica, co najmniej 10 pozycji, maksymalnie 20 pozycji
Expect::array()->min(10)->max(20);
```

Dla ciągów ogranicz ich długość:

```php
// ciąg, długości co najmniej 10 znaków, maksymalnie 20 znaków
Expect::string()->min(10)->max(20);
```

Dla liczb ogranicz ich wartość:

```php
// liczba całkowita, między 10 a 20 włącznie
Expect::int()->min(10)->max(20);
```

Oczywiście można podać samo `min()` albo samo `max()`:

```php
// ciąg, maksymalnie 20 znaków
Expect::string()->max(20);
```


Wyrażenia regularne: pattern()
------------------------------

Za pomocą `pattern()` możesz podać wyrażenie regularne, do którego musi pasować **cały** ciąg wejściowy (czyli tak, jakby było otoczone znakami `^` i `$`):

```php
// dokładnie 9 cyfr
Expect::string()->pattern('\d{9}');
```


Własne asercje: assert()
------------------------

Dowolne inne ograniczenia możesz dodać za pomocą `assert(callable $fn)`.

```php
$countIsEven = fn($v) => count($v) % 2 === 0;

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // liczba musi być parzysta

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 to nie liczba parzysta
```

Albo

```php
Expect::string()->assert('is_file'); // plik musi istnieć
```

Każdej asercji możesz dodać własny opis. Stanie się on częścią komunikatu o błędzie.

```php
$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Even items in array');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.
```

Metodę można wywoływać wielokrotnie, żeby dodać wiele ograniczeń. Można ją przeplatać z wywołaniami `transform()` i `castTo()`.


Transformacja: transform() .{data-version:1.2.5}
------------------------------------------------

Pomyślnie zwalidowane dane można zmodyfikować własną funkcją:

```php
// konwersja na wielkie litery:
Expect::string()->transform(fn(string $s) => strtoupper($s));
```

Metodę można wywoływać wielokrotnie, żeby dodać wiele transformacji. Można ją przeplatać z wywołaniami `assert()` i `castTo()`. Operacje wykonywane są w kolejności, w jakiej zostały zadeklarowane:

```php
Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // konwersja na wielkie litery
```

Metoda `transform()` może jednocześnie transformować i walidować wartość. Często jest to prostsze i mniej duplikuje kod niż łączenie `transform()` i `assert()`. W tym celu funkcja otrzymuje obiekt [Context |api:Nette\Schema\Context] z metodą `addError()`, którą można dodać informację o problemach walidacyjnych:

```php
Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('All characters must be lowercased', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});
```


Rzutowanie: castTo()
--------------------

Pomyślnie zwalidowane dane można rzutować:

```php
Expect::scalar()->castTo('string');
```

Oprócz natywnych typów PHP możesz rzutować także na klasy. Rozróżniana jest prosta klasa bez konstruktora i klasa z konstruktorem. Jeśli klasa nie ma konstruktora, tworzona jest instancja, a wszystkie elementy struktury zapisywane są do właściwości:

```php
class Info
{
	public bool $processRefund;
	public int $refundAmount;
}

Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
])->castTo(Info::class);

// tworzy '$obj = new Info' i zapisuje do $obj->processRefund i $obj->refundAmount
```

Jeśli klasa ma konstruktor, elementy struktury przekazywane są konstruktorowi jako argumenty nazwane:

```php
class Info
{
	public function __construct(
		public bool $processRefund,
		public int $refundAmount,
	) {
	}
}

// tworzy $obj = new Info(processRefund: ..., refundAmount: ...)
```

Rzutowanie w połączeniu z parametrem skalarnym tworzy obiekt i przekazuje wartość konstruktorowi jako jedyny argument:

```php
Expect::string()->castTo(DateTime::class);
// tworzy new DateTime(...)
```


Normalizacja: before()
----------------------

Przed samą walidacją dane można znormalizować metodą `before()`. Jako przykład weźmy element, który musi być tablicą ciągów (np. `['a', 'b', 'c']`), ale przyjmuje wejście w postaci ciągu `a b c`:

```php
$explode = fn($v) => explode(' ', $v);

$schema = Expect::arrayOf('string')
	->before($explode);

$normalized = $processor->process($schema, 'a b c');
// OK i zwraca ['a', 'b', 'c']
```


Mapowanie na obiekty: from()
----------------------------

Możesz pozwolić wygenerować schemat struktury z klasy. Przykład:

```php
class Config
{
	public string $name;
	public string|null $password = null;
	public bool $admin = false;
}

$schema = Expect::from(new Config);

$data = [
	'name' => 'Frank',
];

$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'Frank', 'password' => null, 'admin' => false}
```

Wspierane są też klasy anonimowe:

```php
$schema = Expect::from(new class {
	public string $name;
	public ?string $password = null;
	public bool $admin = false;
});
```

Ponieważ informacje uzyskane z definicji klasy mogą nie wystarczyć, możesz uzupełnić elementy własnym schematem za pomocą drugiego parametru:

```php
$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);
```


Scalanie wielu konfiguracji
---------------------------

Aplikacje często składają swoją konfigurację warstwami: są wbudowane wartości domyślne, a na nich użytkownik dostarcza własne ustawienia, które powinny nadpisywać tylko te pozycje, które faktycznie poda. Dokładnie to robi `processMultiple()`: bierze kilka zbiorów danych, scala je po kolei tak, że późniejsze mają pierwszeństwo, i waliduje ostateczny wynik jako całość:

```php
$schema = Expect::structure([
	'host' => Expect::string(),
	'port' => Expect::int(),
	'logging' => Expect::bool(),
]);

$defaults = ['host' => 'localhost', 'port' => 3306, 'logging' => false];
$userConfig = ['port' => 5432, 'logging' => true];

$config = $processor->processMultiple($schema, [$defaults, $userConfig]);
// $config = {'host' => 'localhost', 'port' => 5432, 'logging' => true}
```

Pozycja `host` zachowuje swoją wartość domyślną, bo użytkownik jej nie ustawił, podczas gdy `port` i `logging` są nadpisane przez późniejszy zbiór danych. Wartości przechowywane pod kluczami tekstowymi scalane są w ten sposób; pozycje indeksowane liczbowo (listy) są dołączane jedna po drugiej zamiast nadpisywane.


Pod maską: normalize, merge, complete
-------------------------------------

Każdy element schematu, czy to wbudowany, czy napisany przez Ciebie, implementuje cztery metody, które razem definiują, jak obchodzi się z danymi. Trzy z nich tworzą potok przetwarzania:

1. **normalize()** - przygotowuje surowe wejście. Tutaj uruchamiają się hooki `before()` i tutaj na przykład obiekt zamieniany jest na tablicę. Uruchamia się jako pierwsza, osobno na każdym zbiorze danych.
2. **merge()** - łączy dwa już znormalizowane zbiory danych, przy czym późniejszy ma priorytet. Ten krok wykorzystuje tylko `processMultiple()`; `process()` go pomija, bo ma tylko jeden zbiór danych.
3. **complete()** - przeprowadza właściwą walidację, uzupełnia wartości domyślne brakujących pozycji i stosuje `assert()`, `transform()` oraz `castTo()`. Uruchamia się jako ostatnia, na scalonym wyniku.

Czwarta metoda, **completeDefault()**, wywoływana jest przez element nadrzędny dla pozycji całkowicie brakującej w wejściu: albo dostarcza wartość domyślną, albo zgłasza, że brakuje pozycji `required()`.

`process()` uruchamia więc *normalize → complete*, a `processMultiple()` *normalize (każdy zbiór danych) → merge → complete*. Ta kolejność sprawia, że `before()` widzi surowe wejście, podczas gdy `transform()` widzi wartość już zwalidowaną.


Własne elementy schematu
------------------------

Za pomocą `assert()`, `transform()` i `before()` da się zajść daleko, więc rzadko musisz budować cokolwiek od zera. Gdy jednak chcesz mieć element nadający się do ponownego użycia, samodzielny, z własną logiką walidacji i scalania, możesz go utworzyć, implementując interfejs [api:Nette\Schema\Schema]. Ma dokładnie te cztery opisane wyżej metody:

```php
interface Schema
{
	function normalize(mixed $value, Context $context);
	function merge(mixed $value, mixed $base);
	function complete(mixed $value, Context $context);
	function completeDefault(Context $context);
}
```

Błędów się nie rzuca; zamiast tego zgłaszasz je przez obiekt [Context |api:Nette\Schema\Context] za pomocą `$context->addError()` i zwracasz `null`. `Processor` zbiera wszystkie błędy i rzuca je razem na końcu.

Jako przykład zbudujmy element nadający się do ponownego użycia, który przyjmuje wartość bazową enuma (np. ciąg `'hearts'`) i zwraca instancję enuma:

```php
use Nette\Schema\Context;
use Nette\Schema\Schema;

class EnumSchema implements Schema
{
	public function __construct(
		private string $enumClass,
	) {
	}

	public function normalize(mixed $value, Context $context): mixed
	{
		return $value; // żadne wstępne przetwarzanie nie jest potrzebne
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // wygrywa wartość późniejsza
	}

	public function complete(mixed $value, Context $context): mixed
	{
		$enum = is_string($value) ? ($this->enumClass)::tryFrom($value) : null;
		if ($enum === null) {
			$context->addError('The item %path% is not a valid value.', 'enum.value');
			return null;
		}

		return $enum;
	}

	public function completeDefault(Context $context): mixed
	{
		return null; // wartość używana, gdy pozycji brakuje w wejściu
	}
}
```

Możesz go użyć wszędzie tam, gdzie oczekiwany jest element wbudowany: samodzielnie albo jako część większej struktury:

```php
enum Suit: string
{
	case Hearts = 'hearts';
	case Spades = 'spades';
}

$schema = Expect::structure([
	'suit' => new EnumSchema(Suit::class),
]);

$processor->process($schema, ['suit' => 'hearts']);
// OK, zwraca {'suit' => Suit::Hearts}
```

Ponieważ element implementuje cały interfejs, działa też automatycznie wewnątrz `processMultiple()`: `Processor` wywołuje jego metodę `merge()` tak samo jak dla każdego innego elementu.

Nette Schema

Praktyczna biblioteka do walidacji i normalizacji struktur danych względem podanego schematu, ze sprytnym i łatwym do zrozumienia API.

Instalacja:

composer require nette/schema

Podstawowe użycie

W zmiennej $schema mamy schemat walidacyjny (za chwilę wyjaśnimy, co to znaczy i jak go utworzyć), a w zmiennej $data strukturę danych, którą chcemy zwalidować i znormalizować. Mogą to być na przykład dane wysłane przez użytkownika przez API, plik konfiguracyjny itd.

Zadaniem zajmuje się klasa Nette\Schema\Processor, która przetwarza wejście i albo zwraca znormalizowane dane, albo rzuca w razie błędu wyjątek Nette\Schema\ValidationException.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Data is invalid: ' . $e->getMessage();
}

Metoda $e->getMessages() zwraca tablicę wszystkich komunikatów jako ciągów, a $e->getMessageObjects() zwraca wszystkie komunikaty jako obiekty Nette\Schema\Message.

Definiowanie schematu

A teraz utwórzmy schemat. Do jego zdefiniowania służy klasa Nette\Schema\Expect; w istocie definiujemy oczekiwania co do tego, jak dane mają wyglądać. Powiedzmy, że dane wejściowe muszą być strukturą (np. tablicą) zawierającą elementy processRefund typu bool i refundAmount typu int.

use Nette\Schema\Expect;

$schema = Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
]);

Wierzymy, że definicja schematu wygląda zrozumiale, nawet jeśli widzisz ją po raz pierwszy.

Wyślijmy do walidacji poniższe dane:

$data = [
	'processRefund' => true,
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, przechodzi walidację

Wyjściem, czyli wartością $normalized, jest obiekt stdClass. Gdybyśmy chcieli, żeby wyjściem była tablica, dodalibyśmy do schematu rzutowanie ->castTo('array').

Wszystkie elementy struktury są opcjonalne i mają wartość domyślną null. Przykład:

$data = [
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, przechodzi walidację
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

To, że wartością domyślną jest null, nie oznacza, że w danych wejściowych zostałoby przyjęte 'processRefund' => null. Nie, wejście musi być wartością logiczną, czyli wyłącznie true albo false. Musielibyśmy jawnie dopuścić null za pomocą Expect::bool()->nullable().

Pozycję można uczynić obowiązkową za pomocą Expect::bool()->required(). Wartość domyślną możemy zmienić na przykład na false za pomocą Expect::bool()->default(false) albo skrótem Expect::bool(false).

A co, gdybyśmy chcieli oprócz wartości logicznych przyjmować też 1 i 0? Wtedy wypisujemy wartości, które chcemy również normalizować do wartości logicznej:

$schema = Expect::structure([
	'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
	'refundAmount' => Expect::int(),
]);

$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true

Teraz znasz podstawy definiowania schematu i to, jak zachowują się elementy struktury. Pokażemy teraz, jakich innych elementów możesz używać przy definiowaniu schematu.

Typy danych: type()

W schemacie można podać wszystkie standardowe typy danych PHP:

Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])
Expect::list($default = [])

A także wszystkie typy wspierane przez klasę Validators, na przykład Expect::type('scalar') albo skrótem Expect::scalar(). Także nazwy klas albo interfejsów, np. Expect::type('AddressEntity').

Można też użyć składni unii:

Expect::type('bool|string|array')

Wartością domyślną jest zawsze null, z wyjątkiem array i list, gdzie jest to pusta tablica. (Lista to tablica indeksowana szeregiem kluczy numerycznych zaczynających się od zera, czyli tablica nieasocjacyjna).

Tablica wartości: arrayOf() listOf()

Tablica reprezentuje zbyt ogólną strukturę; przydatniej jest podać dokładnie, jakie elementy może zawierać. Na przykład tablica, której elementami mogą być tylko ciągi:

$schema = Expect::arrayOf('string');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 nie jest ciągiem

Drugim parametrem można podać klucze (od wersji 1.2):

$schema = Expect::arrayOf('string', 'int');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' nie jest int

Lista to tablica indeksowana:

$schema = Expect::listOf('string');

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 nie jest ciągiem
$processor->process($schema, ['key' => 'a']); // ERROR: to nie lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: to też nie lista

Parametrem może być też schemat, więc możemy napisać:

Expect::arrayOf(Expect::bool())

Wartością domyślną jest pusta tablica. Jeśli podasz wartość domyślną, zostanie scalona z przekazanymi danymi. Można to wyłączyć za pomocą mergeDefaults(false) (od wersji 1.1).

Wyliczenie: anyOf()

anyOf() reprezentuje zbiór wartości albo schematów, które wartość może przyjąć. Oto jak zapisać tablicę elementów, które mogą być albo 'a', albo true, albo null:

$schema = Expect::listOf(
	Expect::anyOf('a', true, null),
);

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false tam nie należy

Elementami wyliczenia mogą być też schematy:

$schema = Expect::listOf(
	Expect::anyOf(Expect::string(), true, null),
);

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR

Metoda anyOf() przyjmuje warianty jako osobne parametry, a nie jako tablicę. Żeby przekazać jej tablicę wartości, użyj operatora rozpakowującego anyOf(...$variants).

Wartością domyślną jest null. Metodą firstIsDefault() uczynisz domyślną pierwszą pozycję:

// domyślną jest 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Struktury

Struktury to obiekty ze zdefiniowanymi kluczami. Każdą parę klucz-wartość nazywamy „właściwością“.

Struktury przyjmują tablice i obiekty, a zwracają obiekty stdClass.

Domyślnie wszystkie właściwości są opcjonalne i mają wartość domyślną null. Właściwości obowiązkowe definiujesz za pomocą required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // wartość domyślna to null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: brakuje opcji 'required'

$processor->process($schema, ['required' => 'foo']);
// OK, zwraca {'required' => 'foo', 'optional' => null}

Sama struktura jest obowiązkowa. Jeśli więc jest zagnieżdżona w innej strukturze, a wejście jej nie zawiera, i tak zostaje utworzona, a błąd zgłasza wtedy, gdy zawiera właściwość wymaganą. Użyj required(false), żeby uczynić całą zagnieżdżoną strukturę opcjonalną. Jeśli brakuje jej w wejściu, w wyjściu pojawia się null, ale jeśli jest obecna, jej wymagane właściwości są egzekwowane:

$schema = Expect::structure([
	'db' => Expect::structure([
		'dsn' => Expect::string()->required(),
	])->required(false),
]);

$processor->process($schema, []);
// OK, zwraca {'db' => null}

$processor->process($schema, ['db' => []]);
// ERROR: brakuje 'db › dsn'

Jeśli nie chcesz mieć w wyjściu właściwości z wartością domyślną, użyj skipDefaults():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(),
])->skipDefaults();

$processor->process($schema, ['required' => 'foo']);
// OK, zwraca {'required' => 'foo'}

Choć null jest wartością domyślną właściwości optional, nie jest dozwolony w danych wejściowych (wartość musi być ciągiem). Właściwości przyjmujące null definiuje się za pomocą nullable():

$schema = Expect::structure([
	'optional' => Expect::string(),
	'nullable' => Expect::string()->nullable(),
]);

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.

$processor->process($schema, ['nullable' => null]);
// OK, zwraca {'optional' => null, 'nullable' => null}

Tablicę wszystkich właściwości struktury zwraca metoda getShape().

Domyślnie w danych wejściowych nie mogą być obecne dodatkowe pozycje:

$schema = Expect::structure([
	'key' => Expect::string(),
]);

$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'

Można to zmienić za pomocą otherItems(). Jako parametr przekaż schemat walidujący każdą nadmiarową pozycję:

$schema = Expect::structure([
	'key' => Expect::string(),
])->otherItems(Expect::int());

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR

Nową strukturę możesz utworzyć, rozszerzając inną za pomocą extend():

$dog = Expect::structure([
	'name' => Expect::string(),
	'age' => Expect::int(),
]);

$dogWithBreed = $dog->extend([
	'breed' => Expect::string(),
]);

Tablica

Tablica ze zdefiniowanymi kluczami. Dotyczy jej wszystko to, co dotyczy struktur.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // wartość domyślna to null
]);

Możesz też zdefiniować tablicę indeksowaną, zwaną krotką:

$schema = Expect::array([
	Expect::int(),
	Expect::string(),
	Expect::bool(),
]);

$processor->process($schema, [1, 'hello', true]); // OK

Właściwości przestarzałe

Właściwość możesz oznaczyć jako przestarzałą metodą deprecated([string $message]). Informacja o przestarzałości zwracana jest przez $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]

Zakresy: min() max()

Za pomocą min() i max() ogranicz liczbę elementów tablic:

// tablica, co najmniej 10 pozycji, maksymalnie 20 pozycji
Expect::array()->min(10)->max(20);

Dla ciągów ogranicz ich długość:

// ciąg, długości co najmniej 10 znaków, maksymalnie 20 znaków
Expect::string()->min(10)->max(20);

Dla liczb ogranicz ich wartość:

// liczba całkowita, między 10 a 20 włącznie
Expect::int()->min(10)->max(20);

Oczywiście można podać samo min() albo samo max():

// ciąg, maksymalnie 20 znaków
Expect::string()->max(20);

Wyrażenia regularne: pattern()

Za pomocą pattern() możesz podać wyrażenie regularne, do którego musi pasować cały ciąg wejściowy (czyli tak, jakby było otoczone znakami ^ i $):

// dokładnie 9 cyfr
Expect::string()->pattern('\d{9}');

Własne asercje: assert()

Dowolne inne ograniczenia możesz dodać za pomocą assert(callable $fn).

$countIsEven = fn($v) => count($v) % 2 === 0;

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // liczba musi być parzysta

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 to nie liczba parzysta

Albo

Expect::string()->assert('is_file'); // plik musi istnieć

Każdej asercji możesz dodać własny opis. Stanie się on częścią komunikatu o błędzie.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Even items in array');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.

Metodę można wywoływać wielokrotnie, żeby dodać wiele ograniczeń. Można ją przeplatać z wywołaniami transform() i castTo().

Transformacja: transform()

Pomyślnie zwalidowane dane można zmodyfikować własną funkcją:

// konwersja na wielkie litery:
Expect::string()->transform(fn(string $s) => strtoupper($s));

Metodę można wywoływać wielokrotnie, żeby dodać wiele transformacji. Można ją przeplatać z wywołaniami assert() i castTo(). Operacje wykonywane są w kolejności, w jakiej zostały zadeklarowane:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // konwersja na wielkie litery

Metoda transform() może jednocześnie transformować i walidować wartość. Często jest to prostsze i mniej duplikuje kod niż łączenie transform() i assert(). W tym celu funkcja otrzymuje obiekt Context z metodą addError(), którą można dodać informację o problemach walidacyjnych:

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('All characters must be lowercased', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

Rzutowanie: castTo()

Pomyślnie zwalidowane dane można rzutować:

Expect::scalar()->castTo('string');

Oprócz natywnych typów PHP możesz rzutować także na klasy. Rozróżniana jest prosta klasa bez konstruktora i klasa z konstruktorem. Jeśli klasa nie ma konstruktora, tworzona jest instancja, a wszystkie elementy struktury zapisywane są do właściwości:

class Info
{
	public bool $processRefund;
	public int $refundAmount;
}

Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
])->castTo(Info::class);

// tworzy '$obj = new Info' i zapisuje do $obj->processRefund i $obj->refundAmount

Jeśli klasa ma konstruktor, elementy struktury przekazywane są konstruktorowi jako argumenty nazwane:

class Info
{
	public function __construct(
		public bool $processRefund,
		public int $refundAmount,
	) {
	}
}

// tworzy $obj = new Info(processRefund: ..., refundAmount: ...)

Rzutowanie w połączeniu z parametrem skalarnym tworzy obiekt i przekazuje wartość konstruktorowi jako jedyny argument:

Expect::string()->castTo(DateTime::class);
// tworzy new DateTime(...)

Normalizacja: before()

Przed samą walidacją dane można znormalizować metodą before(). Jako przykład weźmy element, który musi być tablicą ciągów (np. ['a', 'b', 'c']), ale przyjmuje wejście w postaci ciągu a b c:

$explode = fn($v) => explode(' ', $v);

$schema = Expect::arrayOf('string')
	->before($explode);

$normalized = $processor->process($schema, 'a b c');
// OK i zwraca ['a', 'b', 'c']

Mapowanie na obiekty: from()

Możesz pozwolić wygenerować schemat struktury z klasy. Przykład:

class Config
{
	public string $name;
	public string|null $password = null;
	public bool $admin = false;
}

$schema = Expect::from(new Config);

$data = [
	'name' => 'Frank',
];

$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'Frank', 'password' => null, 'admin' => false}

Wspierane są też klasy anonimowe:

$schema = Expect::from(new class {
	public string $name;
	public ?string $password = null;
	public bool $admin = false;
});

Ponieważ informacje uzyskane z definicji klasy mogą nie wystarczyć, możesz uzupełnić elementy własnym schematem za pomocą drugiego parametru:

$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);

Scalanie wielu konfiguracji

Aplikacje często składają swoją konfigurację warstwami: są wbudowane wartości domyślne, a na nich użytkownik dostarcza własne ustawienia, które powinny nadpisywać tylko te pozycje, które faktycznie poda. Dokładnie to robi processMultiple(): bierze kilka zbiorów danych, scala je po kolei tak, że późniejsze mają pierwszeństwo, i waliduje ostateczny wynik jako całość:

$schema = Expect::structure([
	'host' => Expect::string(),
	'port' => Expect::int(),
	'logging' => Expect::bool(),
]);

$defaults = ['host' => 'localhost', 'port' => 3306, 'logging' => false];
$userConfig = ['port' => 5432, 'logging' => true];

$config = $processor->processMultiple($schema, [$defaults, $userConfig]);
// $config = {'host' => 'localhost', 'port' => 5432, 'logging' => true}

Pozycja host zachowuje swoją wartość domyślną, bo użytkownik jej nie ustawił, podczas gdy port i logging są nadpisane przez późniejszy zbiór danych. Wartości przechowywane pod kluczami tekstowymi scalane są w ten sposób; pozycje indeksowane liczbowo (listy) są dołączane jedna po drugiej zamiast nadpisywane.

Pod maską: normalize, merge, complete

Każdy element schematu, czy to wbudowany, czy napisany przez Ciebie, implementuje cztery metody, które razem definiują, jak obchodzi się z danymi. Trzy z nich tworzą potok przetwarzania:

  1. normalize() – przygotowuje surowe wejście. Tutaj uruchamiają się hooki before() i tutaj na przykład obiekt zamieniany jest na tablicę. Uruchamia się jako pierwsza, osobno na każdym zbiorze danych.
  2. merge() – łączy dwa już znormalizowane zbiory danych, przy czym późniejszy ma priorytet. Ten krok wykorzystuje tylko processMultiple(); process() go pomija, bo ma tylko jeden zbiór danych.
  3. complete() – przeprowadza właściwą walidację, uzupełnia wartości domyślne brakujących pozycji i stosuje assert(), transform() oraz castTo(). Uruchamia się jako ostatnia, na scalonym wyniku.

Czwarta metoda, completeDefault(), wywoływana jest przez element nadrzędny dla pozycji całkowicie brakującej w wejściu: albo dostarcza wartość domyślną, albo zgłasza, że brakuje pozycji required().

process() uruchamia więc normalize → complete, a processMultiple() normalize (każdy zbiór danych) → merge → complete. Ta kolejność sprawia, że before() widzi surowe wejście, podczas gdy transform() widzi wartość już zwalidowaną.

Własne elementy schematu

Za pomocą assert(), transform() i before() da się zajść daleko, więc rzadko musisz budować cokolwiek od zera. Gdy jednak chcesz mieć element nadający się do ponownego użycia, samodzielny, z własną logiką walidacji i scalania, możesz go utworzyć, implementując interfejs Nette\Schema\Schema. Ma dokładnie te cztery opisane wyżej metody:

interface Schema
{
	function normalize(mixed $value, Context $context);
	function merge(mixed $value, mixed $base);
	function complete(mixed $value, Context $context);
	function completeDefault(Context $context);
}

Błędów się nie rzuca; zamiast tego zgłaszasz je przez obiekt Context za pomocą $context->addError() i zwracasz null. Processor zbiera wszystkie błędy i rzuca je razem na końcu.

Jako przykład zbudujmy element nadający się do ponownego użycia, który przyjmuje wartość bazową enuma (np. ciąg 'hearts') i zwraca instancję enuma:

use Nette\Schema\Context;
use Nette\Schema\Schema;

class EnumSchema implements Schema
{
	public function __construct(
		private string $enumClass,
	) {
	}

	public function normalize(mixed $value, Context $context): mixed
	{
		return $value; // żadne wstępne przetwarzanie nie jest potrzebne
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // wygrywa wartość późniejsza
	}

	public function complete(mixed $value, Context $context): mixed
	{
		$enum = is_string($value) ? ($this->enumClass)::tryFrom($value) : null;
		if ($enum === null) {
			$context->addError('The item %path% is not a valid value.', 'enum.value');
			return null;
		}

		return $enum;
	}

	public function completeDefault(Context $context): mixed
	{
		return null; // wartość używana, gdy pozycji brakuje w wejściu
	}
}

Możesz go użyć wszędzie tam, gdzie oczekiwany jest element wbudowany: samodzielnie albo jako część większej struktury:

enum Suit: string
{
	case Hearts = 'hearts';
	case Spades = 'spades';
}

$schema = Expect::structure([
	'suit' => new EnumSchema(Suit::class),
]);

$processor->process($schema, ['suit' => 'hearts']);
// OK, zwraca {'suit' => Suit::Hearts}

Ponieważ element implementuje cały interfejs, działa też automatycznie wewnątrz processMultiple(): Processor wywołuje jego metodę merge() tak samo jak dla każdego innego elementu.