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}
Samotná struktura je povinná. Pokud je tedy vnořená v jiné struktuře a vstup ji neobsahuje, přesto se vytvoří – a
ohlásí chybu ve chvíli, kdy obsahuje povinnou vlastnost. Pomocí required(false) učiníte celou vnořenou
strukturu volitelnou. Když ve vstupu chybí, na výstupu bude null, ale pokud je přítomna, její povinné
vlastnosti se vyžadují:
$schema = Expect::structure([
'db' => Expect::structure([
'dsn' => Expect::string()->required(),
])->required(false),
]);
$processor->process($schema, []);
// OK, vrací {'db' => null}
$processor->process($schema, ['db' => []]);
// CHYBA: chybí 'db › dsn'
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.