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:
- 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. - 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. - complete() – przeprowadza właściwą walidację, uzupełnia wartości domyślne brakujących pozycji i stosuje
assert(),transform()orazcastTo(). 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.