Schema: zatwierdzanie danych

Poręczna biblioteka do walidacji i normalizacji struktur danych względem danego schematu z inteligentnym, łatwym do zrozumienia API.

Instalacja:

composer require nette/schema

Zastosowanie podstawowe

W zmiennej $schema mamy schemat walidacji (o tym, co to dokładnie znaczy i jak stworzyć taki schemat powiemy później), a w zmiennej $data mamy strukturę danych, którą chcemy zwalidować i znormalizować. Mogą to być np. dane przesłane przez użytkownika poprzez API, plik konfiguracyjny itp.

Zadanie to jest obsługiwane przez klasę Nette\Schema\Processor, która przetwarza dane wejściowe 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 'Data nejsou platná: ' . $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\Message.

Definiowanie schematu

Teraz stwórzmy schemat. Do jego zdefiniowania służy klasa Nette\Schema\Expect, tak naprawdę definiujemy oczekiwania co do tego, jak powinny wyglądać dane. Powiedzmy, że dane wejściowe muszą być strukturą (na przykład tablicą) zawierającą elementy processRefund typu bool oraz refundAmount typu int.

use Nette\Schema\Expect;

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

Wierzymy, że definicja schematu wygląda na łatwą do zrozumienia, nawet jeśli widzisz ją po raz pierwszy.

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

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

$normalized = $processor->process($schema, $data); // OK, projde validací

Wyjściem, czyli wartością $normalized, jest obiekt stdClass. Jeśli chcielibyśmy, aby wyjście było tablicą, dodamy nadpisanie schematu 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, projde validací
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Fakt, że wartością domyślną jest null, nie oznacza, że 'processRefund' => null zostałaby zaakceptowana w danych wejściowych. Nie, dane wejściowe muszą być booleanem, czyli tylko true lub false. Aby dopuścić null musielibyśmy wyraźnie użyć Expect::bool()->nullable().

Wpis może być obowiązkowy za pomocą Expect::bool()->required(). Możemy zmienić wartość domyślną na false za pomocą Expect::bool()->default(false) lub w skrócie Expect::bool(false).

A co by było gdybyśmy chcieli oprócz boolean przyjąć 1 a 0? Następnie wymieniamy wartości, które dodatkowo pozwalamy znormalizować do 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 znasz już podstawy definiowania schematu i tego, jak zachowują się elementy struktury. Teraz zobaczymy, jakie pozostałe elementy mogą być użyte podczas definiowania schematu.

Typy danych: type()

Wszystkie standardowe typy danych PHP mogą być wymienione w schemacie:

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

Jak również wszystkie typy obsługiwane przez klasę Validators, na przykład Expect::type('scalar') lub w skrócie Expect::scalar(). Również nazwy klas lub interfejsów, na przykład Expect::type('AddressEntity').

Można również zastosować notację unijną:

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

Domyślnie zawsze jest to null z wyjątkiem array i list, gdzie jest to puste pole. (Lista to tablica indeksowana przez rosnący szereg kluczy numerycznych od zera, czyli tablica nieasocjacyjna).

Tablica wartości: arrayOf() listOf()

Tablica jest zbyt ogólną strukturą; bardziej przydatne jest określenie dokładnie, jakie elementy może zawierać. Na przykład tablica, której elementami mogą być tylko ciągi 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]); // CHYBA: 123 není string

Drugi parametr może być użyty do określenia kluczy (od wersji 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // CHYBA: 'a' není int

Lista jest tablicą indeksowaną:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 nie jest ciągiem znaków
$processor->process($schema, ['key' => 'a']); // ERROR: not a list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: również nie lista

Parametr może być również schematem, więc możemy napisać:

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

Domyślną wartością jest puste pole. Jeśli określisz wartość domyślną, zostanie ona połączona z przekazanymi danymi. Można to wyłączyć za pomocą mergeDefaults(false) (od wersji 1.1).

Wyliczenie: anyOf()

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

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // CHYBA: false tam nepatří

Elementami wyliczenia mogą być również schematy:

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

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

Metoda anyOf() przyjmuje warianty jako pojedyncze parametry, a nie tablice. Aby przekazać mu tablicę wartości, użyj operatora rozpakowywania anyOf(...$variants).

Wartość domyślna to null. Użyj metody firstIsDefault(), aby pierwszy element był domyślny:

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

Struktury

Struktury to obiekty o zdefiniowanych kluczach. Każda z par klucz ⇒ wartość jest określana jako “właściwość”:

Struktury przyjmują tablice i obiekty i zwracają stdClass obiekty (chyba że zmienisz to za pomocą castTo('array') itp.).

Domyślnie wszystkie właściwości są opcjonalne i domyślnie ustawione na null. Możesz zdefiniować właściwości obowiązkowe używając required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // výchozí hodnota je null
]);

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

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

Jeśli nie chcesz, aby właściwości z wartością domyślną znalazły się na wyjściu, użyj skipDefaults():

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

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

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

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

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

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

Domyślnie w danych wejściowych nie może być żadnych dodatkowych elementów:

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

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

Które można zmienić za pomocą otherItems(). Jako parametr podajemy 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]); // CHYBA

Właściwości przestarzałe

Możesz oznaczyć właściwość jako przestarzałą używając metody deprecated([string $message]). Informacje o deprecjacji są zwracane za pomocą $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()

Użyj min() i max(), aby ograniczyć liczbę elementów dla tablic:

// pole, minimum 10 pozycji, maksimum 20 pozycji
Expect::array()->min(10)->max(20);

W przypadku ciągów, ogranicz ich długość:

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

W przypadku liczb, ogranicz ich wartość:

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

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

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

Wyrażenia regularne: pattern()

Możesz użyć pattern() do określenia wyrażenia regularnego, do którego musi pasować cały łańcuch wejściowy (tj. tak jakby zawinięty przez ^ a $):

// tylko 9 liczb
Expect::string()->pattern('\d{9}');

Niestandardowe ograniczenie: assert()

Wszelkie dodatkowe ograniczenia można określić 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 nie jest liczbą parzystą

Albo

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

Do każdego ograniczenia można dodać własny opis. Zostanie to uwzględnione w komunikacie 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.

Metoda ta może być wywoływana wielokrotnie w celu dodania wielu ograniczeń. Można ją łączyć z wywołaniami transform() i castTo().

Transformacja: transform()

Pomyślnie zweryfikowane dane można modyfikować za pomocą funkcji niestandardowej:

// conversion to uppercase:
Expect::string()->transform(fn(string $s) => strtoupper($s));

Metoda ta może być wywoływana wielokrotnie w celu dodania wielu przekształceń. Można ją mieszać z wywołaniami assert() i castTo(). Operacje będą wykonywane 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)); // conversion to uppercase

Metoda transform() może jednocześnie przekształcać i weryfikować wartość. Jest to często prostsze i mniej zbędne niż łączenie metod transform() i assert(). W tym celu funkcja otrzymuje obiekt Context z metodą addError(), która może być użyta do dodania informacji o kwestiach walidacji:

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);
	});

Casting: castTo()

Pomyślnie zweryfikowane dane mogą być rzutowane:

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

Oprócz natywnych typów PHP, można również rzutować na klasy. Rozróżnia to, czy jest to prosta klasa bez konstruktora, czy klasa z konstruktorem. Jeśli klasa nie ma konstruktora, tworzona jest jej instancja, a wszystkie elementy struktury są zapisywane w jej właściwościach:

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

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

// creates '$obj = new Info' and writes to $obj->processRefund and $obj->refundAmount

Jeśli klasa ma konstruktor, elementy struktury są przekazywane jako nazwane parametry do konstruktora:

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

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

Rzutowanie w połączeniu z parametrem skalarnym tworzy obiekt i przekazuje wartość jako jedyny parametr do konstruktora:

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

Normalizacja: before()

Przed walidacją dane mogą zostać znormalizowane przy użyciu metody before(). Jako przykład rozważmy element, który musi być tablicą łańcuchów (np. ['a', 'b', 'c']), ale otrzymuje dane wejściowe 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 a vrátí ['a', 'b', 'c']

Mapowanie do obiektów: from()

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

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

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

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

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

Obsługiwane 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ą nie być wystarczające, można dodać niestandardowy schemat dla elementów za pomocą drugiego parametru:

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