Nette Schema
Praktická knihovna pro validaci a normalizaci datových struktur oproti danému schématu s chytrým srozumitelným API.
Instalace:
composer require nette/schema
Základní použití
V proměnné $schema máme validační schéma (co to přesně znamená a jak takové schéma vytvořit si
řekneme vzápětí) a v proměnné $data datovou strukturu, kterou chceme validovat a normalizovat. Může jít
například o data odeslaná uživatelem skrze rozhraní API, konfigurační soubor, atd.
Úkol obstará třída Nette\Schema\Processor, která zpracuje vstup a buď vrátí normalizovaná data, nebo v případě chyby vyhodí výjimku 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() vrací pole všech zpráv jakožto řetězců a
$e->getMessageObjects() vrací všechny zprávy jako objekty Nette\Schema\Message.
Definování schématu
A nyní vytvoříme schéma. K jeho definování slouží třída Nette\Schema\Expect, definujeme vlastně očekávání,
jak mají data vypadat. Řekněme, že vstupní data musí tvořit strukturu (například pole) obsahující prvky
processRefund typu bool a refundAmount typu int.
use Nette\Schema\Expect;
$schema = Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
]);
Věříme, že definice schématu vypadá srozumitelně, i když ji vidíte úplně poprvé.
Pošleme k validaci následující data:
$data = [
'processRefund' => true,
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, projde validací
Výstupem, tedy hodnotou $normalized, je objekt stdClass. Pokud bychom chtěli, aby výstupem bylo
pole, doplníme schéma o přetypování Expect::structure([...])->castTo('array').
Všechny prvky struktury jsou volitelné a mají výchozí hodnotu null. Příklad:
$data = [
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, projde validací
// $normalized = {'processRefund' => null, 'refundAmount' => 17}
To, že výchozí hodnotou je null, neznamená, že by se akceptovalo ve vstupních datech
'processRefund' => null. Nikoliv, vstupem musí být boolean, tedy pouze true nebo
false. Povolit null bychom museli explicitně pomoci Expect::bool()->nullable().
Položku lze učinit povinnou pomocí Expect::bool()->required(). Výchozí hodnotu změníme třeba na
false pomocí Expect::bool()->default(false) nebo zkráceně Expect::bool(false).
A co kdybychom chtěli kromě booleanu akceptovat ještě 1 a 0? Pak uvedeme výčet hodnot, které
navíc necháme normalizovat 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
Nyní už znáte základy toho, jak se definuje schéma a jak se chovají jednotlivé prvky struktury. Nyní si ukážeme, jaké všechny další prvky lze při definici schématu použít.
Datové typy: type()
Ve schématu lze uvést všechny standardní datové typy 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 dále všechny typy, podporované třídou
Validators, například Expect::type('scalar') nebo zkráceně Expect::scalar(). Také názvy tříd
či rozhraní, například Expect::type('AddressEntity').
Lze použít i union zápis:
Expect::type('bool|string|array')
Výchozí hodnota je vždy null s výjimkou pro array a list, kde je to prázdné pole.
(List je pole indexované podle vzestupné řady numerických klíčů od nuly, tedy neasociativní pole).
Pole hodnot: arrayOf() listOf()
Pole představuje příliš obecnou strukturu, užitečnější je specifikovat, jaké přesně může obsahovat prvky. Například pole, jehož prvky mohou být pouze řetězce:
$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
Druhým parametrem lze specifikovat klíče (od verze 1.2):
$schema = Expect::arrayOf('string', 'int');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // CHYBA: 'a' není int
List je indexované pole:
$schema = Expect::listOf('string');
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // CHYBA: 123 není string
$processor->process($schema, ['key' => 'a']); // CHYBA: není list
$processor->process($schema, [1 => 'a', 0 => 'b']); // CHYBA: také není list
Parametrem může být i schéma, můžeme tedy zapsat:
Expect::arrayOf(Expect::bool())
Výchozí hodnota je prázdné pole. Pokud zadáte výchozí hodnotu, bude sloučena s předanými daty. To lze deaktivovat
pomocí mergeDefaults(false) (od verze 1.1).
Výčet: anyOf()
anyOf() představuje výčet hodnot nebo schémat, které může hodnota nabývat. Takto zapíšeme pole prvků,
které mohou být buď 'a', true nebo 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ří
Prvky výčtu mohou být i schémata:
$schema = Expect::listOf(
Expect::anyOf(Expect::string(), true, null),
);
$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // CHYBA
Metoda anyOf() přijímá varianty jako jednotlivé parametry, nikoliv pole. Pokud jí chcete předat pole hodnot,
použijte unpacking operátor anyOf(...$variants).
Výchozí hodnota je null. Metodou firstIsDefault() učiníme první prvek výchozí:
// výchozí je 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();
Struktury
Struktury jsou objekty s definovanými klíči. Každá z dvojic klíč ⇒ hodnota je označována jako „vlastnost“.
Struktury přijímají pole a objekty a vrací objekty stdClass.
Ve výchozím nastavení jsou všechny vlastnosti volitelné a mají výchozí hodnotu null. Povinné vlastnosti
můžete definovat pomocí required():
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // výchozí hodnota je null
]);
$processor->process($schema, ['optional' => '']);
// CHYBA: option 'required' is missing
$processor->process($schema, ['required' => 'foo']);
// OK, vrací {'required' => 'foo', 'optional' => null}
Pokud nechcete mít na výstupu vlastnosti s výchozí hodnotou, použijte skipDefaults():
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(),
])->skipDefaults();
$processor->process($schema, ['required' => 'foo']);
// OK, vrací {'required' => 'foo'}
Ačkoliv je null výchozí hodnota vlastnosti optional, ve vstupních datech povolený není
(hodnotou musí být string). Vlastnosti akceptující null definujeme 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}
Pole všech vlastností struktury vrací metoda getShape().
Ve výchozím nastavení nemohou být ve vstupních datech žádné položky navíc:
$schema = Expect::structure([
'key' => Expect::string(),
]);
$processor->process($schema, ['additional' => 1]);
// CHYBA: Unexpected item 'additional'
Což můžeme změnit pomocí otherItems(). Jako parametr uvedeme schéma, podle kterého se budou prvky navíc
validovat:
$schema = Expect::structure([
'key' => Expect::string(),
])->otherItems(Expect::int());
$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // CHYBA
Novou strukturu můžete vytvořit odvozením od jiné pomocí extend():
$dog = Expect::structure([
'name' => Expect::string(),
'age' => Expect::int(),
]);
$dogWithBreed = $dog->extend([
'breed' => Expect::string(),
]);
Pole
Pole s definovanými klíči. Platí pro něj vše co struktury.
$schema = Expect::array([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // výchozí hodnota je null
]);
Lze definovat také indexované pole, známé jako tuple:
$schema = Expect::array([
Expect::int(),
Expect::string(),
Expect::bool(),
]);
$processor->process($schema, [1, 'hello', true]); // OK
Zastaralé vlastnosti
Můžete označit vlastnost jako deprecated pomocí metody deprecated([string $message]). Informace o ukončení
podpory jsou vrácena 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"]
Rozsahy: min() max()
Pomocí min() a max() lze u polí omezit počet prvků:
// pole, nejméně 10 položek, maximálně 20 položek
Expect::array()->min(10)->max(20);
U řetězců omezit jejich délku:
// řetězec, nejméně 10 znaků dlouhý, maximálně 20 znaků
Expect::string()->min(10)->max(20);
U čísel omezit jejich hodnotu:
// celé číslo, mezi 10 a 20 včetně
Expect::int()->min(10)->max(20);
Samozřejmě je možné uvést pouze min(), nebo pouze max():
// řetězec maximálně 20 znaků
Expect::string()->max(20);
Regulární výrazy: pattern()
Pomocí pattern() lze uvést regulární výraz, kterému musí odpovídat celý vstupní řetězec (tj.
jako by byl obalen znaky ^ a $):
// právě 9 čísel
Expect::string()->pattern('\d{9}');
Vlastní omezení: assert()
Libovolná další omezení zadáme pomocí assert(callable $fn).
$countIsEven = fn($v) => count($v) % 2 === 0;
$schema = Expect::arrayOf('string')
->assert($countIsEven); // počet musí být sudý
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // CHYBA: 3 není sudý počet
Nebo
Expect::string()->assert('is_file'); // soubor musí existovat
Ke každému omezení můžete přidat vlastní popis. Ten bude součástí chybové zprávy.
$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.
Metodu lze volat opakovaně a tak přidat více omezení. Lze ji prokládat s voláním transform() a
castTo().
Transformace: transform()
Úspěšně zvalidovaná data lze upravovat pomocí vlastní funkce:
// převod na velká písmena:
Expect::string()->transform(fn(string $s) => strtoupper($s));
Metodu lze volat opakovaně a tak přidat více transformací. Lze ji prokládat s voláním assert() a
castTo(). Operace se provedou v pořadí, v jakém jsou deklarovány:
Expect::type('string|int')
->castTo('string')
->assert('ctype_lower', 'All characters must be lowercased')
->transform(fn(string $s) => strtoupper($s)); // převod na velká písmena
Metoda transform() může současně transformovat i validovat hodnotu. To je často jednodušší a méně
duplicitní než řetězení transform() a assert(). K tomuto účelu funkce obdrží objekt Context s metodou addError(), kterou lze
použít k přidání informací o problémech s validací:
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);
});
Přetypování: castTo()
Úspěšně zvalidovaná data lze přetypovat:
Expect::scalar()->castTo('string');
Kromě nativních PHP typů lze přetypovávat i na třídy. Přitom se rozlišuje, zda jde o jednoduchou třídu bez konstruktoru, nebo třídu s konstruktorem. Pokud třída nemá konstruktor, vytvoří se její instance a všechny prvky struktury se zapíší do properties:
class Info
{
public bool $processRefund;
public int $refundAmount;
}
Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
])->castTo(Info::class);
// vytvoří '$obj = new Info' a zapíše do $obj->processRefund a $obj->refundAmount
Pokud třída konstruktor má, prvky struktury se předají jako pojmenované parametry konstruktoru:
class Info
{
public function __construct(
public bool $processRefund,
public int $refundAmount,
) {
}
}
// vytvoří $obj = new Info(processRefund: ..., refundAmount: ...)
Přetypování v kombinaci se skalárním parametrem vytvoří objekt a hodnotu předá jako jediný parametr konstruktoru:
Expect::string()->castTo(DateTime::class);
// vytvoří new DateTime(...)
Normalizace: before()
Před samotnou validací lze data normalizovat pomocí metody before(). Jako příklad uveďme prvek, který musí
být pole řetězců (například ['a', 'b', 'c']), avšak přijímá vstup ve formě řetězce
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']
Mapování na objekty: from()
Schéma struktury si můžeme nechat vygenerovat ze třídy. Příklad:
class Config
{
public string $name;
public string|null $password = null;
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}
Podporovány jsou i anonymní třídy:
$schema = Expect::from(new class {
public string $name;
public ?string $password = null;
public bool $admin = false;
});
Protože informace získané z definice třídy nemusí být dostačující, můžete druhým parametrem doplnit prvkům vlastní schéma:
$schema = Expect::from(new Config, [
'name' => Expect::string()->pattern('\w:.*'),
]);
Slučování více konfigurací
Aplikace často skládají konfiguraci ve vrstvách: existují vestavěné výchozí hodnoty a nad nimi uživatel dodává
vlastní nastavení, které má přepsat jen ty položky, jež skutečně uvede. Přesně to dělá
processMultiple() – vezme několik datových sad, sloučí je v pořadí tak, že pozdější mají přednost, a
výsledek zvaliduje jako celek:
$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}
Položka host si ponechá výchozí hodnotu, protože ji uživatel nenastavil, zatímco port a
logging přepíše pozdější datová sada. Hodnoty pod řetězcovými klíči se slučují tímto způsobem;
číselně indexované položky (listy) se místo přepsání připojují jednu za druhou.
Jak zpracování probíhá
Každý prvek schématu – ať vestavěný, nebo vlastní – implementuje čtyři metody, které dohromady určují, jak nakládá s daty. Tři z nich tvoří zpracovatelskou pipeline:
- normalize() – připraví surový vstup. Tady běží háčky
before()a tady se například objekt převede na pole. Spouští se jako první, zvlášť pro každou datovou sadu. - merge() – sloučí dvě už normalizované datové sady, přičemž pozdější má přednost. Tento krok využívá
jen
processMultiple();process()ho přeskakuje, protože má jen jednu datovou sadu. - complete() – provede samotnou validaci, doplní výchozí hodnoty za chybějící položky a aplikuje
assert(),transform()acastTo(). Běží jako poslední, nad sloučeným výsledkem.
Čtvrtou metodu, completeDefault(), volá nadřazený prvek pro položku, jež ve vstupu úplně chybí – buď dodá
výchozí hodnotu, nebo ohlásí, že chybí required() položka.
process() tedy provádí normalize → complete, zatímco processMultiple() provádí
normalize (každou sadu) → merge → complete. Kvůli tomuto pořadí vidí before() surový vstup,
kdežto transform() už zvalidovanou hodnotu.
Vlastní prvky schématu
S assert(), transform() a before() se dostaneš hodně daleko, takže málokdy
potřebuješ stavět něco od základu. Když ale chceš znovupoužitelný, samostatný prvek s vlastní logikou validace a
slučování, vytvoříš ho implementací rozhraní Nette\Schema\Schema. Má přesně ty čtyři metody
popsané výše:
interface Schema
{
function normalize(mixed $value, Context $context);
function merge(mixed $value, mixed $base);
function complete(mixed $value, Context $context);
function completeDefault(Context $context);
}
Chyby se nevyhazují; místo toho je hlásíš přes objekt Context pomocí $context->addError() a
vrátíš null. Processor všechny chyby posbírá a vyhodí je na konci najednou.
Jako příklad vytvoříme znovupoužitelný prvek, který přijme backing hodnotu enumu (např. řetězec
'hearts') a vrátí instanci enumu:
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; // není potřeba žádné předzpracování
}
public function merge(mixed $value, mixed $base): mixed
{
return $value ?? $base; // pozdější hodnota vyhrává
}
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; // hodnota použitá, když položka ve vstupu chybí
}
}
Použiješ ho kdekoli, kde se očekává vestavěný prvek – samostatně i jako součást větší struktury:
enum Suit: string
{
case Hearts = 'hearts';
case Spades = 'spades';
}
$schema = Expect::structure([
'suit' => new EnumSchema(Suit::class),
]);
$processor->process($schema, ['suit' => 'hearts']);
// OK, vrátí {'suit' => Suit::Hearts}
Protože prvek implementuje celé rozhraní, funguje automaticky i uvnitř processMultiple() –
Processor volá jeho metodu merge() stejně jako u kteréhokoli jiného prvku.