Nette 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. Если бы мы хотели получить на выходе массив, мы добавили бы в схему приведение ->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::list($default = [])

А также все типы, поддерживаемые классом Validators, например 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']);
// OK, вернёт {'required' => 'foo', 'optional' => null}

Сама структура обязательна. Поэтому если она вложена в другую структуру и во вводе её нет, она всё равно создаётся, и она сообщает об ошибке, когда содержит обязательное свойство. Используйте required(false), чтобы сделать всю вложенную структуру необязательной. Если во вводе её нет, на выходе появляется null, но если она есть, её обязательные свойства требуются:

$schema = Expect::structure([
	'db' => Expect::structure([
		'dsn' => Expect::string()->required(),
	])->required(false),
]);

$processor->process($schema, []);
// OK, вернёт {'db' => null}

$processor->process($schema, ['db' => []]);
// ОШИБКА: отсутствует 'db › dsn'

Если вы не хотите видеть на выходе свойства со значением по умолчанию, используйте 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' ожидает string, передан null.

$processor->process($schema, ['nullable' => null]);
// OK, вернёт {'optional' => null, 'nullable' => null}

Массив всех свойств структуры возвращает метод getShape().

По умолчанию во входных данных не может быть дополнительных пунктов:

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

$processor->process($schema, ['additional' => 1]);
// ОШИБКА: Неожиданный пункт '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(), // значение по умолчанию 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('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]

Диапазоны: 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() можно задать регулярное выражение, которому должна соответствовать вся входная строка (то есть как если бы оно было обёрнуто в символы ^ и $):

// ровно 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']); // ОШИБКА: 3 - нечётное количество

Или

Expect::string()->assert('is_file'); // файл должен существовать

К каждому утверждению можно добавить собственное описание. Оно станет частью сообщения об ошибке.

$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.

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

Преобразование: transform()

Успешно проверенные данные можно изменить собственной функцией:

// преобразуем в верхний регистр:
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)); // преобразуем в верхний регистр

Метод 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);

// создаёт '$obj = new Info' и записывает в $obj->processRefund и $obj->refundAmount

Если у класса есть конструктор, элементы структуры передаются в конструктор как именованные аргументы:

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

// создаёт $obj = new Info(processRefund: ..., refundAmount: ...)

Приведение в сочетании со скалярным параметром создаёт объект и передаёт значение в конструктор единственным аргументом:

Expect::string()->castTo(DateTime::class);
// создаёт new DateTime(...)

Нормализация: before()

Перед самой проверкой данные можно нормализовать методом 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 = null;
	public bool $admin = false;
}

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

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

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

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

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

Поскольку сведений, полученных из определения класса, может не хватать, элементы можно дополнить собственной схемой через второй параметр:

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

Объединение нескольких конфигураций

Приложения часто собирают свою конфигурацию слоями: есть встроенные значения по умолчанию, а поверх них пользователь задаёт собственные настройки, которые должны переопределять только те пункты, которые он действительно указал. Именно этим и занимается processMultiple(): он берёт несколько наборов данных, объединяет их по порядку так, что более поздние имеют приоритет, и проверяет итоговый результат целиком:

$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}

Пункт host сохраняет значение по умолчанию, потому что пользователь его не задал, а port и logging перезаписаны более поздним набором данных. Значения, хранящиеся под строковыми ключами, объединяются именно так; элементы с числовыми индексами (списки) вместо перезаписи дописываются друг за другом.

Под капотом: normalize, merge, complete

Каждый элемент схемы, будь то встроенный или написанный вами, реализует четыре метода, которые вместе определяют, как он обращается с данными. Три из них образуют конвейер обработки:

  1. normalize() – подготавливает сырой ввод. Здесь выполняются хуки before() и здесь, например, объект превращается в массив. Он выполняется первым, отдельно для каждого набора данных.
  2. merge() – объединяет два уже нормализованных набора данных, причём приоритет у более позднего. Этот шаг использует только processMultiple(); process() его пропускает, потому что у него всего один набор данных.
  3. complete() – выполняет собственно проверку, подставляет значения по умолчанию для отсутствующих пунктов и применяет assert(), transform() и castTo(). Он выполняется последним, над объединённым результатом.

Четвёртый метод, completeDefault(), вызывается родительским элементом для пункта, полностью отсутствующего во вводе: он либо выдаёт значение по умолчанию, либо сообщает, что отсутствует пункт required().

Так что process() выполняет normalize → complete, а processMultiple() выполняет normalize (каждый набор данных) → merge → complete. Именно из-за этого порядка before() видит сырой ввод, а transform() – уже проверенное значение.

Собственные элементы схемы

С assert(), transform() и before() можно уйти далеко, так что писать что-то с нуля нужно редко. Но когда вам нужен многократно используемый самостоятельный элемент с собственной логикой проверки и объединения, вы можете создать его, реализовав интерфейс Nette\Schema\Schema. У него ровно те четыре метода, что описаны выше:

interface Schema
{
	function normalize(mixed $value, Context $context);
	function merge(mixed $value, mixed $base);
	function complete(mixed $value, Context $context);
	function completeDefault(Context $context);
}

Ошибки не выбрасываются; вместо этого вы сообщаете о них через объект Context методом $context->addError() и возвращаете null. Processor собирает все ошибки и выбрасывает их вместе в конце.

В качестве примера построим многократно используемый элемент, который принимает базовое значение перечисления (например, строку 'hearts') и возвращает экземпляр перечисления:

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; // предварительная обработка не нужна
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // побеждает более позднее значение
	}

	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; // значение, используемое, когда пункта во вводе нет
	}
}

Использовать его можно везде, где ожидается встроенный элемент, – сам по себе или как часть более крупной структуры:

enum Suit: string
{
	case Hearts = 'hearts';
	case Spades = 'spades';
}

$schema = Expect::structure([
	'suit' => new EnumSchema(Suit::class),
]);

$processor->process($schema, ['suit' => 'hearts']);
// OK, вернёт {'suit' => Suit::Hearts}

Поскольку элемент реализует весь интерфейс, он автоматически работает и внутри processMultiple(): Processor вызывает его метод merge() точно так же, как у любого другого элемента.

версия: 2.x