Schema: валидиране на данни

Практична библиотека за проверка и нормализиране на структури от данни в съответствие с дадена схема, с интелигентен и лесен за разбиране API.

Инсталация:

composer require nette/schema

Използване на

В променливата $schema имаме схема за валидиране (какво точно означава тя и как да я създадем, ще обясним по-късно), а в променливата $data имаме структура от данни, която искаме да валидираме и нормализираме. Това могат да бъдат например данни, изпратени от потребителя чрез API, конфигурационен файл и т.н.

Задачата се изпълнява от класа Nette\Schema\Processor, който обработва входните данни и връща нормализирани данни или при грешка хвърля изключение 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();
}

Методът $e->getMessages() връща масив от всички низове на съобщенията, а $e->getMessageObjects() връща всички съобщения като обекти Nette\Schema\Message.

Определяне на схема

Сега нека създадем схема. С класа Nette\Schema\Expect всъщност определяме как трябва да изглеждат данните. Да предположим, че входните данни трябва да са структура (например масив), съдържаща елементите processRefund от тип bool и refundAmount от тип int.

use Nette\Schema\Expect;

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

Смятаме, че дефиницията на схемата изглежда ясна, дори ако я виждате за първи път.

Ще изпратим следните данни за проверка:

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

$normalized = $processor->process($schema, $data); // OK, проходит

Изходът, т.е. стойността $normalized, е обектът stdClass. Ако искаме резултатът да бъде масив, добавяме преобразуване на схемата Expect::structure([...])->castTo('array').

Всички елементи на структурата са незадължителни и имат стойност по подразбиране null. Пример:

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

$normalized = $processor->process($schema, $data); // OK, проходит
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Фактът, че стойността по подразбиране е null, не означава, че тя ще бъде приета във входните данни 'processRefund' => null. Не, входните данни трябва да са булеви, т.е. само true или false. Ще трябва изрично да разрешим null чрез Expect::bool()->nullable().

Елементът може да бъде направен задължителен с помощта на Expect::bool()->required(). Променяме стойността по подразбиране на false, като използваме Expect::bool()->default(false) или за кратко Expect::bool(false).

Но какво ще стане, ако искаме да приемаме 1 и 0 в допълнение към булевите числа? Нека изброим валидните стойности, които също нормализираме в булеви:

$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

Вече знаете основите на това как се дефинира една схема и как се държат отделните елементи на структурата. Сега ще ви покажем какви други елементи могат да се използват при дефинирането на схемата.

Типове данни: type()

Всички стандартни типове данни на PHP могат да бъдат изброени в схемата:

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

И след това всички типове, поддържани от валидаторите чрез Expect::type('scalar') или съкратено Expect::scalar(). Приемат се и имена на класове или интерфейси, например: Expect::type('AddressEntity').

Можете също така да използвате запис на съюз:

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

Стойността по подразбиране винаги е null, с изключение на array и list, където тя е празен масив. (Списъкът е масив, индексиран във възходящ числов ред на ключовете от нула, т.е. неасоциативен масив).

Масив от стойности: arrayOf() listOf()

Масивът е твърде обща структура, по-полезно е да се посочи точно кои елементи може да съдържа. Например масив, чиито елементи могат да бъдат само низове:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ОШИБКА: 123 не строка

Вторият параметър може да се използва за задаване на ключове (от версия 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ОШИБКА: 'a' не int

Списъкът е индексиран масив:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ОШИБКА: 123 не строка
$processor->process($schema, ['key' => 'a']); // ОШИБКА: не список
$processor->process($schema, [1 => 'a', 0 => 'b']); // ОШИБКА: не список

Параметърът може да бъде и схема, така че можем да напишем

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

Стойността по подразбиране е празен масив. Ако посочите стойност по подразбиране, тя ще бъде обединена с предадените данни. Това може да бъде деактивирано с mergeDefaults(false) (от версия 1.1 нататък).

Изброяване: anyOf()

anyOf() – е набор от стойности или модели, които могат да бъдат стойност. Ето как да запишете масив от елементи, които могат да бъдат или 'a', или true, или null:

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ОШИБКА: false тут не место

Елементите на изброяване могат да бъдат и схеми:

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

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ОШИБКА

Методът anyOf() приема варианти като отделни параметри, а не като масив. За да му предадете масив от стойности, използвайте оператора за разопаковане anyOf(...$variants).

Стойността по подразбиране е null. Използвайте метода firstIsDefault(), за да направите първия елемент по подразбиране:

// по подразбиране е 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Структури

Структурите са обекти с определени ключове. Всяка от тези двойки ключ ⇒ стойност се нарича “свойство”:

Структурите приемат масиви и обекти и връщат обекти stdClass.

По подразбиране всички свойства са незадължителни и имат стойност по подразбиране null. Можете да дефинирате задължителни свойства, като използвате required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // стойността по подразбиране е null
]);

$processor->process($schema, ['optional' => '']);
// Грешка: липсва опцията 'required'

$processor->process($schema, ['required' => 'foo']);
// ОК, връща {'required' => 'foo', 'optional' => null}

Ако не искате да извеждате свойствата само със стойността по подразбиране, използвайте skipDefaults():

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

$processor->process($schema, ['required' => 'foo']);
// OK, возвращает {'required' => 'foo'}

Въпреки че null е стойността по подразбиране на свойството optional, тя не е разрешена във входа (стойността трябва да бъде низ). Свойствата, приемащи стойността null, се дефинират с помощта на nullable():

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

$processor->process($schema, ['optional' => null]);
// ГРЕШКА: очаква се 'optional' да бъде низ, но е предоставен null.

$processor->process($schema, ['nullable' => null]);
// ОК, връща {'optional' => null, 'nullable' => null}

Масивът от всички свойства на структурата се връща от метода getShape().

По подразбиране във входните данни не може да има допълнителни елементи:

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

$processor->process($schema, ['additional' => 1]);
// ERROR: Неочакван елемент 'additional'

Променяйте подобни елементи с otherItems(). Ще посочим схемата за всеки допълнителен елемент като параметър:

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

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ОШИБКА

Можете да създадете нова структура, като я извлечете от друга с помощта на extend():

$dog = Expect::structure([
	'name' => Expect::string(),
	'age' => Expect::int(),
]);

$dogWithBreed = $dog->extend([
	'breed' => Expect::string(),
]);

Масив

Масив с дефинирани ключове. Прилагат се същите правила като за структурите.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // default value is null
]);

Можете да дефинирате и индексиран масив, известен като кортеж:

$schema = Expect::array([
	Expect::int(),
	Expect::string(),
	Expect::bool(),
]);

$processor->process($schema, [1, 'hello', true]); // OK

Остарели елементи

Можете да обявите дадено свойство за неактуално с помощта на deprecated([string $message]). Остарелите известия се връщат с помощта на $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('Элемент %path% устарел'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["Элемент 'old' устарел"]

Обхвати: min() max()

Използвайте min() и max(), за да ограничите броя на елементите в масивите:

// масив, минимум 10 елемента, максимум 20 елемента
Expect::array()->min(10)->max(20);

За низове ограничете дължината им:

// низ, дълъг най-малко 10 символа, максимум 20 символа
Expect::string()->min(10)->max(20);

За числата ограничете тяхната стойност:

// цяло число, от 10 до 20 включително
Expect::int()->min(10)->max(20);

Разбира се, можете да споменете само min(), или само max():

// низ, максимум 20 символа
Expect::string()->max(20);

Редовни изрази: pattern()

С помощта на pattern(), можете да зададете регулярен израз, който да съвпада с всички от входния низ (т.е. все едно е обвит в ^ a $ символи):

// само 9 цифри
Expect::string()->pattern('\d{9}');

Потребителски оператори: assert()

Можете да добавите други ограничения, като използвате assert(callable $fn).

$countIsEven = fn($v) => count($v) % 2 === 0;

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // числото трябва да е четно

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 - нечетно число

Или

Expect::string()->assert('is_file'); // файлът трябва да съществува

Можете да добавите собствено описание за всяко твърдение. Това ще бъде част от съобщението за грешка.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Even elements in array');

$processor->process($schema, ['a', 'b', 'c']);
// Неуспешно изпълнение на командата "Дори елементи в масив" за елемент с масив от стойности.

Методът може да бъде извикан многократно, за да се добавят множество ограничения. Той може да се смесва с извикванията на transform() и castTo().

Трансформация: transform()

Успешно валидираните данни могат да бъдат модифицирани с помощта на потребителска функция:

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

Методът може да бъде извикан многократно, за да се добавят множество трансформации. Той може да се смесва с извиквания на assert() и castTo(). Операциите ще бъдат изпълнени в реда, в който са декларирани:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // conversion to uppercase

Методът transform() може едновременно да трансформира и валидира стойността. Това често е по-просто и по-малко излишно от верижното използване на transform() и assert(). За тази цел функцията получава обект Context с метод addError(), който може да се използва за добавяне на информация за проблеми с валидирането:

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

Заличаване: castTo()

Успешно валидираните данни могат да бъдат хвърлени:

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

В допълнение към нативните PHP типове можете да използвате и класове. Той разграничава дали става въпрос за обикновен клас без конструктор или за клас с конструктор. Ако класът няма конструктор, се създава негова инстанция и всички елементи на структурата се записват в свойствата му:

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

Ако класът има конструктор, елементите на структурата се предават като именувани параметри на конструктора:

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

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

Casting в комбинация със скаларен параметър създава обект и предава стойността като единствен параметър на конструктора:

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

Нормализация: преди()

Преди самото валидиране данните могат да бъдат нормализирани с помощта на метода before(). Като пример, нека има елемент, който трябва да бъде масив от низове (напр, ['a', 'b', 'c']), но получава вход като низ a b c:

$explode = fn($v) => explode(' ', $v);

$schema = Expect::arrayOf('string')
	->before($explode);

$normalized = $processor->process($schema, 'a b c');
// OK, возвращает ['a', 'b', 'c']

Съпоставяне с обекти: from()

Можете да генерирате структурна схема от класа. Пример:

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}

Поддържат се и анонимни класове:

$schema = Expect::from(new class {
	public string $name;
	public ?string $password;
	public bool $admin = false;
});

Тъй като информацията, получена от дефиницията на класа, може да не е достатъчна, можете да добавите потребителска схема за елементите с втория параметър:

$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);
версия: 2.0