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:

  1. 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.
  2. 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.
  3. complete() – provede samotnou validaci, doplní výchozí hodnoty za chybějící položky a aplikuje assert(), transform() a castTo(). 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.

verze: 2.x 1.x