Schema: walidacja danych

Praktyczna biblioteka do walidacji i normalizacji struktur danych względem danego schematu z inteligentnym, zrozumiałym API.

Instalacja:

composer require nette/schema

Podstawowe użycie

W zmiennej $schema mamy schemat walidacji (co to dokładnie oznacza i jak taki schemat utworzyć, powiemy za chwilę), a w zmiennej $data strukturę danych, którą chcemy walidować i normalizować. Mogą to być na przykład dane przesłane przez użytkownika przez interfejs API, plik konfiguracyjny itp.

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

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Dane są nieprawidłowe: ' . $e->getMessage();
}

Metoda $e->getMessages() zwraca tablicę wszystkich wiadomości jako ciągi znaków, a $e->getMessageObjects() zwraca wszystkie wiadomości jako obiekty Nette\Schema\Message.

Definiowanie schematu

A teraz utworzymy schemat. Do jego definiowania służy klasa Nette\Schema\Expect, definiujemy właściwie oczekiwania, jak mają wyglądać dane. Powiedzmy, że dane wejściowe muszą tworzyć strukturę (na przykład 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.

Prześlemy do walidacji następujące dane:

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

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

Wyjściem, czyli wartością $normalized, jest obiekt stdClass. Jeśli chcielibyśmy, aby wyjściem była tablica, uzupełnimy schemat o rzutowanie Expect::structure([...])->castTo('array').

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

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

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

To, że domyślną wartością jest null, nie oznacza, że akceptowałoby się w danych wejściowych 'processRefund' => null. Nie, wejściem musi być boolean, czyli tylko true lub false. Pozwolenie na null musielibyśmy jawnie określić za pomocą Expect::bool()->nullable().

Element można uczynić obowiązkowym za pomocą Expect::bool()->required(). Wartość domyślną zmienimy na przykład na false za pomocą Expect::bool()->default(false) lub skrótowo Expect::bool(false).

A co gdybyśmy chcieli oprócz boolean akceptować jeszcze 1 i 0? Wtedy podamy wyliczenie wartości, które dodatkowo pozwolimy znormalizować na boolean:

$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 już znasz podstawy tego, jak definiuje się schemat i jak zachowują się poszczególne elementy struktury. Teraz pokażemy, jakie wszystkie inne elementy można użyć przy definicji 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 = [])

A dalej wszystkie typy, wspierane przez klasę Validators, na przykład Expect::type('scalar') lub skrótowo Expect::scalar(). Także nazwy klas czy interfejsów, na przykład Expect::type('AddressEntity').

Można użyć również zapisu union:

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

Wartość domyślna to zawsze null z wyjątkiem dla array i list, gdzie jest to pusta tablica. (List to tablica indeksowana według rosnącej serii kluczy numerycznych od zera, czyli tablica nieasocjacyjna).

Tablice wartości: arrayOf() listOf()

Tablica reprezentuje zbyt ogólną strukturę, bardziej użyteczne jest określenie, jakie dokładnie może zawierać elementy. Na przykład tablica, której elementy mogą być tylko ciągami znaków:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // BŁĄD: 123 nie jest stringiem

Drugim parametrem można określić klucze (od wersji 1.2):

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

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

List to tablica indeksowana:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // BŁĄD: 123 nie jest stringiem
$processor->process($schema, ['key' => 'a']); // BŁĄD: nie jest listą
$processor->process($schema, [1 => 'a', 0 => 'b']); // BŁĄD: również nie jest listą

Parametrem może być również schemat, możemy więc zapisać:

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

Wartość domyślna to pusta tablica. Jeśli podasz wartość domyślną, zostanie ona połączona z przekazanymi danymi. Można to zdezaktywować za pomocą mergeDefaults(false) (od wersji 1.1).

Wyliczenie: anyOf()

anyOf() reprezentuje wyliczenie wartości lub schematów, które może przyjmować wartość. W ten sposób zapiszemy tablicę elementów, które mogą być albo 'a', true lub null:

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // BŁĄD: false tu nie pasuje

Elementy wyliczenia mogą być również schematami:

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

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

Metoda anyOf() przyjmuje warianty jako pojedyncze parametry, a nie tablicę. Jeśli chcesz przekazać jej tablicę wartości, użyj operatora unpacking anyOf(...$variants).

Wartość domyślna to null. Metodą firstIsDefault() uczynimy pierwszy element domyślnym:

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

Struktury

Struktury to obiekty z zdefiniowanymi kluczami. Każda z par klucz ⇒ wartość jest określana jako „właściwość”:

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

Domyślnie wszystkie właściwości są opcjonalne i mają domyślną wartość null. Obowiązkowe właściwości można zdefiniować za pomocą required():

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

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

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

Jeśli nie chcesz mieć na 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'}

Chociaż null jest domyślną wartością właściwości optional, w danych wejściowych nie jest dozwolony (wartością musi być string). Właściwości akceptujące null definiujemy za pomocą nullable():

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

$processor->process($schema, ['optional' => null]);
// BŁĄD: 'optional' oczekuje stringa, podano null.

$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ą znajdować się żadne dodatkowe elementy:

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

$processor->process($schema, ['additional' => 1]);
// BŁĄD: Nieoczekiwany element 'additional'

Co możemy zmienić za pomocą otherItems(). Jako parametr podamy schemat, według którego będą walidowane dodatkowe elementy:

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

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // BŁĄD

Nową strukturę możesz utworzyć, dziedzicząc po innej za pomocą extend():

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

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

Tablice

Tablice z zdefiniowanymi kluczami. Dotyczy ich wszystko, co struktury.

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

Można zdefiniować również tablicę indeksowaną, znaną jako tuple:

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

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

Przestarzałe właściwości

Możesz oznaczyć właściwość jako przestarzałą za pomocą metody deprecated([string $message]). Informacje o zakończeniu wsparcia są zwracane za pomocą $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('Element %path% jest przestarzały'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["Element 'old' jest przestarzały"]

Zakresy: min() max()

Za pomocą min() i max() można w przypadku tablic ograniczyć liczbę elementów:

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

W przypadku ciągów znaków ograniczyć ich długość:

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

W przypadku liczb ograniczyć ich wartość:

// liczba całkowita, od 10 do 20 włącznie
Expect::int()->min(10)->max(20);

Oczywiście można podać tylko min() lub tylko max():

// ciąg znaków o maksymalnej długości 20 znaków
Expect::string()->max(20);

Wyrażenia regularne: pattern()

Za pomocą pattern() można podać wyrażenie regularne, któremu musi odpowiadać cały wejściowy ciąg znaków (tj. jakby był otoczony znakami ^ i $):

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

Własne ograniczenia: assert()

Dowolne inne ograniczenia podajemy 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']); // BŁĄD: 3 nie jest liczbą parzystą

Lub

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

Do każdego ograniczenia możesz dodać własny opis. Będzie 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, aby dodać więcej ograniczeń. Można ją przeplatać z wywołaniami transform() i castTo().

Transformacje: transform()

Pomyślnie zwalidowane dane można modyfikować za pomocą własnej funkcji:

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

Metodę można wywoływać wielokrotnie, aby dodać więcej transformacji. Można ją przeplatać z wywołaniami assert() i castTo(). Operacje zostaną wykonane w kolejności, w jakiej są 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ść. Jest to często prostsze i mniej powtarzalne niż łączenie transform() i assert(). W tym celu funkcja otrzymuje obiekt Context z metodą addError(), której można użyć do dodania informacji o problemach z walidacją:

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żna rzutować również na klasy. Rozróżnia się przy tym, czy chodzi o prostą klasę bez konstruktora, czy klasę z konstruktorem. Jeśli klasa nie ma konstruktora, tworzy się jej instancja, a wszystkie elementy struktury zapisuje się 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 przekazuje się jako nazwane parametry konstruktora:

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ść jako jedyny parametr konstruktora:

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

Normalizacja: before()

Przed samą walidacją dane można znormalizować za pomocą metody before(). Jako przykład podajmy element, który musi być tablicą ciągów znaków (na przykład ['a', 'b', 'c']), ale przyjmuje wejście w formie 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()

Schemat struktury możemy wygenerować na podstawie klasy. Przykład:

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

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

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

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

Wspierane są również klasy anonimowe:

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

Ponieważ informacje uzyskane z definicji klasy mogą być niewystarczające, możesz drugim parametrem uzupełnić elementy o własny schemat:

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