Nette Schema

Une bibliothèque pratique pour valider et normaliser des structures de données par rapport à un schéma donné, avec une API intelligente et facile à comprendre.

Installation :

composer require nette/schema

Usage de base

Dans la variable $schema, nous avons un schéma de validation (nous expliquerons dans un instant ce que cela signifie et comment en créer un) et, dans la variable $data, la structure de données que nous voulons valider et normaliser. Ce peut être, par exemple, des données envoyées par un utilisateur via une API, un fichier de configuration, etc.

La tâche est prise en charge par la classe Nette\Schema\Processor, qui traite l'entrée et renvoie soit les données normalisées, soit lève une exception Nette\Schema\ValidationException en cas d'erreur.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Les données sont invalides : ' . $e->getMessage();
}

La méthode $e->getMessages() renvoie un tableau de tous les messages sous forme de chaînes, et $e->getMessageObjects() renvoie tous les messages sous forme d'objets Nette\Schema\Message.

Définir le schéma

Créons maintenant le schéma. La classe Nette\Schema\Expect sert à le définir ; nous définissons pour ainsi dire nos attentes quant à l'aspect des données. Disons que les données d'entrée doivent être une structure (par exemple un tableau) contenant les éléments processRefund de type bool et refundAmount de type int.

use Nette\Schema\Expect;

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

Nous pensons que la définition du schéma paraît compréhensible, même si vous la voyez pour la première fois.

Envoyons les données suivantes à la validation :

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

$normalized = $processor->process($schema, $data); // OK, passe la validation

La sortie, c'est-à-dire la valeur $normalized, est un objet stdClass. Si nous voulions un tableau en sortie, nous ajouterions au schéma la conversion ->castTo('array').

Tous les éléments de la structure sont facultatifs et ont pour valeur par défaut null. Exemple :

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

$normalized = $processor->process($schema, $data); // OK, passe la validation
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Le fait que la valeur par défaut soit null ne signifie pas qu'il accepterait 'processRefund' => null dans les données d'entrée. Non, l'entrée doit être un booléen, c'est-à-dire uniquement true ou false. Il faudrait autoriser explicitement null à l'aide d'Expect::bool()->nullable().

Un élément peut être rendu obligatoire avec Expect::bool()->required(). Nous pouvons changer la valeur par défaut, par exemple en false, avec Expect::bool()->default(false) ou la forme courte Expect::bool(false).

Et si nous voulions accepter 1 et 0 en plus des booléens ? Nous énumérons alors les valeurs que nous voulons aussi normaliser en booléen :

$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

Vous connaissez maintenant les bases de la définition d'un schéma et le comportement des éléments d'une structure. Nous allons montrer quels autres éléments vous pouvez utiliser en définissant un schéma.

Types de données : type()

Tous les types de données standards de PHP peuvent être indiqués dans le schéma :

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

Ainsi que tous les types pris en charge par la classe Validators, par exemple Expect::type('scalar') ou la forme courte Expect::scalar(). Et aussi les noms de classes ou d'interfaces, par exemple Expect::type('AddressEntity').

La syntaxe union peut également être utilisée :

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

La valeur par défaut est toujours null, à l'exception d'array et de list, où c'est un tableau vide. (Une list est un tableau indexé par une suite de clés numériques à partir de zéro, autrement dit un tableau non associatif.)

Tableau de valeurs : arrayOf() listOf()

Un tableau représente une structure trop générale ; il est plus utile de préciser exactement quels éléments il peut contenir. Par exemple un tableau dont les éléments ne peuvent être que des chaînes :

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERREUR : 123 n'est pas une chaîne

Le deuxième paramètre peut préciser les clés (depuis la version 1.2) :

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERREUR : 'a' n'est pas un int

Une list est un tableau indexé :

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERREUR : 123 n'est pas une chaîne
$processor->process($schema, ['key' => 'a']); // ERREUR : ce n'est pas une list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERREUR : ce n'est pas une list non plus

Le paramètre peut aussi être un schéma, nous pouvons donc écrire :

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

La valeur par défaut est un tableau vide. Si vous indiquez une valeur par défaut, elle sera fusionnée avec les données transmises. Cela peut être désactivé avec mergeDefaults(false) (depuis la version 1.1).

Énumération : anyOf()

anyOf() représente un ensemble de valeurs ou de schémas qu'une valeur peut prendre. Voici comment écrire un tableau d'éléments pouvant être soit 'a', soit true, soit null :

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERREUR : false n'y a pas sa place

Les éléments de l'énumération peuvent eux aussi être des schémas :

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

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERREUR

La méthode anyOf() accepte les variantes comme paramètres distincts, pas comme un tableau. Pour lui passer un tableau de valeurs, utilisez l'opérateur de décomposition anyOf(...$variants).

La valeur par défaut est null. Utilisez la méthode firstIsDefault() pour faire du premier élément la valeur par défaut :

// la valeur par défaut est 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Structures

Les structures sont des objets à clés définies. Chaque paire clé-valeur est appelée une “propriété”.

Les structures acceptent des tableaux et des objets et renvoient des objets stdClass.

Par défaut, toutes les propriétés sont facultatives et ont pour valeur par défaut null. Vous pouvez définir des propriétés obligatoires à l'aide de required() :

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // la valeur par défaut est null
]);

$processor->process($schema, ['optional' => '']);
// ERREUR : l'option 'required' manque

$processor->process($schema, ['required' => 'foo']);
// OK, renvoie {'required' => 'foo', 'optional' => null}

Une structure est elle-même obligatoire. Si elle est donc imbriquée dans une autre structure et que l'entrée ne la contient pas, elle est tout de même créée – et signale une erreur si elle contient une propriété obligatoire. Utilisez required(false) pour rendre facultative toute la structure imbriquée. Si elle manque dans l'entrée, null apparaît en sortie, mais si elle est présente, ses propriétés obligatoires sont exigées :

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

$processor->process($schema, []);
// OK, renvoie {'db' => null}

$processor->process($schema, ['db' => []]);
// ERREUR : 'db › dsn' manque

Si vous ne voulez pas des propriétés à valeur par défaut dans la sortie, utilisez skipDefaults() :

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

$processor->process($schema, ['required' => 'foo']);
// OK, renvoie {'required' => 'foo'}

Bien que null soit la valeur par défaut de la propriété optional, elle n'est pas autorisée dans les données d'entrée (la valeur doit être une chaîne). Les propriétés acceptant null se définissent à l'aide de nullable() :

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

$processor->process($schema, ['optional' => null]);
// ERREUR : 'optional' attend une chaîne, null donné.

$processor->process($schema, ['nullable' => null]);
// OK, renvoie {'optional' => null, 'nullable' => null}

Le tableau de toutes les propriétés d'une structure est renvoyé par la méthode getShape().

Par défaut, aucun élément supplémentaire ne peut figurer dans les données d'entrée :

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

$processor->process($schema, ['additional' => 1]);
// ERREUR : élément inattendu 'additional'

Cela peut être changé à l'aide d'otherItems(). Passez en paramètre le schéma servant à valider chaque élément supplémentaire :

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

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

Vous pouvez créer une nouvelle structure en en étendant une autre à l'aide d'extend() :

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

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

Array

Un tableau à clés définies. Tout ce qui vaut pour les Structures vaut pour lui.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // la valeur par défaut est null
]);

Vous pouvez aussi définir un tableau indexé, appelé tuple :

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

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

Propriétés obsolètes

Vous pouvez marquer une propriété comme obsolète à l'aide de la méthode deprecated([string $message]). L'information sur l'obsolescence est renvoyée par $processor->getWarnings() :

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('L\'élément %path% est obsolète'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["L'élément 'old' est obsolète"]

Plages : min() max()

Utilisez min() et max() pour limiter le nombre d'éléments des tableaux :

// tableau, au moins 10 éléments, 20 au maximum
Expect::array()->min(10)->max(20);

Pour les chaînes, cela limite leur longueur :

// chaîne, au moins 10 caractères, 20 au maximum
Expect::string()->min(10)->max(20);

Pour les nombres, cela limite leur valeur :

// entier, entre 10 et 20 inclus
Expect::int()->min(10)->max(20);

Il est bien sûr possible de n'indiquer que min() ou que max() :

// chaîne, 20 caractères au maximum
Expect::string()->max(20);

Expressions régulières : pattern()

À l'aide de pattern(), vous pouvez indiquer une expression régulière à laquelle toute la chaîne d'entrée doit correspondre (comme si elle était encadrée par les caractères ^ et $) :

// exactement 9 chiffres
Expect::string()->pattern('\d{9}');

Assertions personnalisées : assert()

Vous pouvez ajouter n'importe quelles autres contraintes à l'aide d'assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // le nombre doit être pair

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERREUR : 3 n'est pas un nombre pair

Ou

Expect::string()->assert('is_file'); // le fichier doit exister

Vous pouvez ajouter à chaque assertion une description personnalisée. Elle fera partie du message d'erreur.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Nombre pair d\'éléments dans le tableau');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Nombre pair d'éléments dans le tableau" for item with value array.

La méthode peut être appelée plusieurs fois pour ajouter plusieurs contraintes. Elle peut être entrelacée avec des appels à transform() et castTo().

Transformation : transform()

Les données validées avec succès peuvent être modifiées par une fonction personnalisée :

// convertit en majuscules :
Expect::string()->transform(fn(string $s) => strtoupper($s));

La méthode peut être appelée plusieurs fois pour ajouter plusieurs transformations. Elle peut être entrelacée avec des appels à assert() et castTo(). Les opérations sont effectuées dans l'ordre où elles sont déclarées :

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

La méthode transform() peut à la fois transformer et valider la valeur. C'est souvent plus simple et moins redondant que d'enchaîner transform() et assert(). À cette fin, la fonction reçoit un objet Context doté d'une méthode addError(), qui permet d'ajouter des informations sur les problèmes de validation :

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

Conversion : castTo()

Les données validées avec succès peuvent être converties :

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

Outre les types natifs de PHP, vous pouvez aussi convertir vers des classes. Une distinction est faite entre une classe simple sans constructeur et une classe avec constructeur. Si la classe n'a pas de constructeur, une instance est créée et tous les éléments de la structure sont écrits dans ses propriétés :

class Info
{
	public bool $processRefund;
	public int $refundAmount;
}

Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
])->castTo(Info::class);

// crée '$obj = new Info' et écrit dans $obj->processRefund et $obj->refundAmount

Si la classe a un constructeur, les éléments de la structure lui sont passés comme arguments nommés :

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

// crée $obj = new Info(processRefund: ..., refundAmount: ...)

Une conversion combinée à un paramètre scalaire crée un objet et passe la valeur comme unique argument au constructeur :

Expect::string()->castTo(DateTime::class);
// crée new DateTime(...)

Normalisation : before()

Avant la validation elle-même, les données peuvent être normalisées à l'aide de la méthode before(). Prenons comme exemple un élément qui doit être un tableau de chaînes (par exemple ['a', 'b', 'c']), mais accepte une entrée sous forme de chaîne a b c :

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

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

$normalized = $processor->process($schema, 'a b c');
// OK et renvoie ['a', 'b', 'c']

Mapping vers des objets : from()

Vous pouvez faire générer le schéma d'une structure à partir d'une classe. Exemple :

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}

Les classes anonymes sont aussi prises en charge :

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

Comme les informations tirées de la définition de la classe peuvent ne pas suffire, vous pouvez compléter les éléments par votre propre schéma à l'aide du deuxième paramètre :

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

Fusionner plusieurs configurations

Les applications assemblent souvent leur configuration en couches : il y a des valeurs par défaut intégrées et, par-dessus, l'utilisateur fournit ses propres réglages, qui ne devraient remplacer que les éléments qu'il indique réellement. C'est exactement ce que fait processMultiple() : elle prend plusieurs jeux de données, les fusionne dans l'ordre de sorte que les derniers l'emportent, et valide le résultat final dans son ensemble :

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

L'élément host garde sa valeur par défaut, car l'utilisateur ne l'a pas définie, tandis que port et logging sont écrasés par le jeu de données suivant. Les valeurs stockées sous des clés textuelles sont fusionnées de cette façon ; les éléments indexés numériquement (les lists) sont ajoutés les uns à la suite des autres au lieu d'être écrasés.

Sous le capot : normalize, merge, complete

Chaque élément de schéma – qu'il soit intégré ou écrit par vos soins – implémente quatre méthodes qui définissent ensemble sa façon de traiter les données. Trois d'entre elles forment le pipeline de traitement :

  1. normalize() – prépare l'entrée brute. C'est là que s'exécutent les hooks before() et que, par exemple, un objet est transformé en tableau. Elle s'exécute en premier, séparément sur chaque jeu de données.
  2. merge() – combine deux jeux de données déjà normalisés, le dernier ayant la priorité. Cette étape n'est utilisée que par processMultiple() ; process() la saute, car elle n'a qu'un seul jeu de données.
  3. complete() – effectue la validation proprement dite, remplit les valeurs par défaut des éléments manquants et applique assert(), transform() et castTo(). Elle s'exécute en dernier, sur le résultat fusionné.

La quatrième méthode, completeDefault(), est appelée par l'élément parent pour un élément totalement absent de l'entrée : elle fournit soit la valeur par défaut, soit signale qu'un élément required() manque.

Ainsi, process() exécute normalize → complete, tandis que processMultiple() exécute normalize (chaque jeu de données) → merge → complete. C'est à cause de cet ordre que before() voit l'entrée brute, alors que transform() voit la valeur déjà validée.

Éléments de schéma personnalisés

On va loin avec assert(), transform() et before(), vous avez donc rarement besoin de construire quoi que ce soit de zéro. Mais lorsque vous voulez un élément réutilisable et autonome, doté de sa propre logique de validation et de fusion, vous pouvez en créer un en implémentant l'interface Nette\Schema\Schema. Elle comporte exactement les quatre méthodes décrites ci-dessus :

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

Les erreurs ne sont pas levées ; vous les signalez par l'objet Context à l'aide de $context->addError() et renvoyez null. Le Processor rassemble toutes les erreurs et les lève ensemble à la fin.

Construisons en exemple un élément réutilisable qui accepte la valeur sous-jacente d'un enum (par exemple la chaîne 'hearts') et renvoie l'instance de l'enum :

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; // aucun pré-traitement nécessaire
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // la valeur la plus récente l'emporte
	}

	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; // valeur utilisée quand l'élément manque dans l'entrée
	}
}

Vous pouvez l'utiliser partout où un élément intégré est attendu, seul ou au sein d'une structure plus vaste :

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

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

$processor->process($schema, ['suit' => 'hearts']);
// OK, renvoie {'suit' => Suit::Hearts}

Comme l'élément implémente toute l'interface, il fonctionne aussi automatiquement dans processMultiple() : le Processor appelle sa méthode merge() exactement comme pour n'importe quel autre élément.

version: 2.x