Nette Schema
Una biblioteca práctica para validar y normalizar estructuras de datos contra un esquema dado, con una API inteligente y fácil de entender.
Instalación:
composer require nette/schema
Uso básico
En la variable $schema tenemos un esquema de validación (en un momento explicaremos qué significa eso y cómo se
crea), y en la variable $data tenemos la estructura de datos que queremos validar y normalizar. Pueden ser, por
ejemplo, datos enviados por un usuario a través de una API, un archivo de configuración, etc.
De la tarea se encarga la clase Nette\Schema\Processor, que procesa la entrada y devuelve los datos normalizados, o lanza una excepción Nette\Schema\ValidationException si ocurre un error.
$processor = new Nette\Schema\Processor;
try {
$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
echo 'Los datos no son válidos: ' . $e->getMessage();
}
El método $e->getMessages() devuelve un array con todos los mensajes como cadenas, y
$e->getMessageObjects() devuelve todos los mensajes como objetos Nette\Schema\Message.
Definir el esquema
Y ahora creemos el esquema. Para definirlo se usa la clase Nette\Schema\Expect; en esencia definimos las expectativas
de cómo deben ser los datos. Digamos que los datos de entrada tienen que ser una estructura (p. ej. un array) que contenga los
elementos processRefund de tipo bool y refundAmount de tipo int.
use Nette\Schema\Expect;
$schema = Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
]);
Creemos que la definición del esquema resulta comprensible aunque la vea por primera vez.
Enviemos los siguientes datos a validar:
$data = [
'processRefund' => true,
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, pasa la validación
La salida, es decir, el valor $normalized, es un objeto stdClass. Si quisiéramos que la salida fuera
un array, añadiríamos al esquema la conversión ->castTo('array').
Todos los elementos de la estructura son opcionales y tienen el valor predeterminado null. Un ejemplo:
$data = [
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, pasa la validación
// $normalized = {'processRefund' => null, 'refundAmount' => 17}
Que el valor predeterminado sea null no significa que aceptara 'processRefund' => null en los
datos de entrada. No, la entrada tiene que ser un booleano, es decir, solo true o false. Tendríamos que
permitir null explícitamente con Expect::bool()->nullable().
Un elemento se puede hacer obligatorio con Expect::bool()->required(). El valor predeterminado lo podemos
cambiar, por ejemplo, a false con Expect::bool()->default(false) o de forma abreviada con
Expect::bool(false).
¿Y si quisiéramos aceptar, además de los booleanos, 1 y 0? Entonces listamos los valores que
queremos normalizar también a booleano:
$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
Ya conoce los fundamentos de la definición de un esquema y cómo se comportan los elementos de la estructura. Ahora le mostraremos qué otros elementos puede usar al definir un esquema.
Tipos de datos: type()
En el esquema se pueden indicar todos los tipos de datos estándar de 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 = [])
Y también todos los tipos que soporta la clase
Validators, por ejemplo Expect::type('scalar') o de forma abreviada Expect::scalar(). También los
nombres de clases o interfaces, p. ej. Expect::type('AddressEntity').
También se puede usar la sintaxis de unión:
Expect::type('bool|string|array')
El valor predeterminado es siempre null, salvo en array y list, donde es un array
vacío. (Una list es un array indexado por una secuencia de claves numéricas que empieza en cero, es decir, un array no
asociativo).
Array de valores: arrayOf() listOf()
Un array representa una estructura demasiado general; es más útil indicar con precisión qué elementos puede contener. Por ejemplo, un array cuyos elementos solo pueden ser cadenas:
$schema = Expect::arrayOf('string');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 no es una cadena
El segundo parámetro puede indicar las claves (desde la versión 1.2):
$schema = Expect::arrayOf('string', 'int');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' no es un int
Una list es un array indexado:
$schema = Expect::listOf('string');
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 no es una cadena
$processor->process($schema, ['key' => 'a']); // ERROR: no es una list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: tampoco es una list
El parámetro también puede ser un esquema, así que podemos escribir:
Expect::arrayOf(Expect::bool())
El valor predeterminado es un array vacío. Si indica un valor predeterminado, se fusionará con los datos pasados. Eso se
puede desactivar con mergeDefaults(false) (desde la versión 1.1).
Enumeración: anyOf()
anyOf() representa un conjunto de valores o esquemas que un valor puede tomar. Así se escribe un array de
elementos que pueden ser 'a', true o null:
$schema = Expect::listOf(
Expect::anyOf('a', true, null),
);
$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false no pertenece ahí
Los elementos de la enumeración también pueden ser esquemas:
$schema = Expect::listOf(
Expect::anyOf(Expect::string(), true, null),
);
$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR
El método anyOf() acepta las variantes como parámetros separados, no como array. Para pasarle un array de
valores, use el operador de desempaquetado anyOf(...$variants).
El valor predeterminado es null. Use el método firstIsDefault() para que el primer elemento sea el
predeterminado:
// el valor predeterminado es 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();
Estructuras
Las estructuras son objetos con claves definidas. Cada par clave-valor se denomina “propiedad”.
Las estructuras aceptan arrays y objetos, y devuelven objetos stdClass.
De forma predeterminada, todas las propiedades son opcionales y tienen el valor predeterminado null. Puede definir
propiedades obligatorias con required():
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // el valor predeterminado es null
]);
$processor->process($schema, ['optional' => '']);
// ERROR: falta la opción 'required'
$processor->process($schema, ['required' => 'foo']);
// OK, devuelve {'required' => 'foo', 'optional' => null}
Una estructura es obligatoria en sí misma. Por eso, si está anidada dentro de otra estructura y la entrada no la contiene, se
crea igualmente, y notifica un error cuando contiene una propiedad obligatoria. Use required(false) para que toda la
estructura anidada sea opcional. Si falta en la entrada, en la salida aparece null, pero si está presente, se exigen
sus propiedades obligatorias:
$schema = Expect::structure([
'db' => Expect::structure([
'dsn' => Expect::string()->required(),
])->required(false),
]);
$processor->process($schema, []);
// OK, devuelve {'db' => null}
$processor->process($schema, ['db' => []]);
// ERROR: falta 'db › dsn'
Si no quiere que en la salida aparezcan las propiedades con el valor predeterminado, use skipDefaults():
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(),
])->skipDefaults();
$processor->process($schema, ['required' => 'foo']);
// OK, devuelve {'required' => 'foo'}
Aunque null es el valor predeterminado de la propiedad optional, no se permite en los datos de
entrada (el valor tiene que ser una cadena). Las propiedades que aceptan null se definen con
nullable():
$schema = Expect::structure([
'optional' => Expect::string(),
'nullable' => Expect::string()->nullable(),
]);
$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.
$processor->process($schema, ['nullable' => null]);
// OK, devuelve {'optional' => null, 'nullable' => null}
El array con todas las propiedades de la estructura lo devuelve el método getShape().
De forma predeterminada, en los datos de entrada no puede haber elementos adicionales:
$schema = Expect::structure([
'key' => Expect::string(),
]);
$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'
Eso se puede cambiar con otherItems(). Como parámetro, pase el esquema con el que se validará cada elemento
adicional:
$schema = Expect::structure([
'key' => Expect::string(),
])->otherItems(Expect::int());
$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR
Puede crear una estructura nueva ampliando otra con extend():
$dog = Expect::structure([
'name' => Expect::string(),
'age' => Expect::int(),
]);
$dogWithBreed = $dog->extend([
'breed' => Expect::string(),
]);
Array
Un array con claves definidas. Le vale todo lo que vale para las estructuras.
$schema = Expect::array([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // el valor predeterminado es null
]);
También puede definir un array indexado, conocido como tupla:
$schema = Expect::array([
Expect::int(),
Expect::string(),
Expect::bool(),
]);
$processor->process($schema, [1, 'hello', true]); // OK
Propiedades obsoletas
Puede marcar una propiedad como obsoleta con el método deprecated([string $message]). La información sobre la
obsolescencia se obtiene con $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"]
Rangos: min() max()
Use min() y max() para limitar el número de elementos de los arrays:
// array, al menos 10 elementos, como máximo 20
Expect::array()->min(10)->max(20);
En las cadenas, limite su longitud:
// cadena, de al menos 10 caracteres y como máximo 20
Expect::string()->min(10)->max(20);
En los números, limite su valor:
// entero, entre 10 y 20 inclusive
Expect::int()->min(10)->max(20);
Por supuesto, es posible indicar solo min() o solo max():
// cadena, como máximo 20 caracteres
Expect::string()->max(20);
Expresiones regulares: pattern()
Con pattern() puede indicar una expresión regular con la que tiene que encajar toda la cadena de entrada
(es decir, como si estuviera envuelta entre los caracteres ^ y $):
// exactamente 9 dígitos
Expect::string()->pattern('\d{9}');
Aserciones propias: assert()
Puede añadir cualquier otra restricción con assert(callable $fn).
$countIsEven = fn($v) => count($v) % 2 === 0;
$schema = Expect::arrayOf('string')
->assert($countIsEven); // el número tiene que ser par
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 no es un número par
O bien
Expect::string()->assert('is_file'); // el archivo tiene que existir
A cada aserción le puede añadir una descripción propia. Formará parte del mensaje de error.
$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.
El método se puede llamar repetidamente para añadir varias restricciones. Se puede intercalar con las llamadas a
transform() y castTo().
Transformación: transform()
Los datos validados correctamente se pueden modificar con una función propia:
// convierte a mayúsculas:
Expect::string()->transform(fn(string $s) => strtoupper($s));
El método se puede llamar repetidamente para añadir varias transformaciones. Se puede intercalar con las llamadas a
assert() y castTo(). Las operaciones se realizan en el orden en que se declaran:
Expect::type('string|int')
->castTo('string')
->assert('ctype_lower', 'All characters must be lowercased')
->transform(fn(string $s) => strtoupper($s)); // convierte a mayúsculas
El método transform() puede transformar y validar el valor a la vez. Eso suele ser más simple y duplica menos
código que encadenar transform() y assert(). Para ello, la función recibe un objeto Context con el método addError(), que sirve
para añadir información sobre los problemas de validación:
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);
});
Conversión: castTo()
Los datos validados correctamente se pueden convertir:
Expect::scalar()->castTo('string');
Además de a los tipos nativos de PHP, también puede convertir a clases. Distingue entre una clase simple sin constructor y una clase con constructor. Si la clase no tiene constructor, se crea una instancia y todos los elementos de la estructura se escriben en las propiedades:
class Info
{
public bool $processRefund;
public int $refundAmount;
}
Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
])->castTo(Info::class);
// crea '$obj = new Info' y escribe en $obj->processRefund y $obj->refundAmount
Si la clase tiene constructor, los elementos de la estructura se pasan al constructor como argumentos con nombre:
class Info
{
public function __construct(
public bool $processRefund,
public int $refundAmount,
) {
}
}
// crea $obj = new Info(processRefund: ..., refundAmount: ...)
La conversión combinada con un parámetro escalar crea un objeto y pasa el valor como único argumento al constructor:
Expect::string()->castTo(DateTime::class);
// crea new DateTime(...)
Normalización: before()
Antes de la validación en sí, los datos se pueden normalizar con el método before(). Como ejemplo, tomemos un
elemento que tiene que ser un array de cadenas (p. ej. ['a', 'b', 'c']), pero que acepta la entrada en forma de la
cadena a b c:
$explode = fn($v) => explode(' ', $v);
$schema = Expect::arrayOf('string')
->before($explode);
$normalized = $processor->process($schema, 'a b c');
// OK y devuelve ['a', 'b', 'c']
Mapeo a objetos: from()
Puede hacer que el esquema de la estructura se genere a partir de una clase. Un ejemplo:
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}
También se soportan las clases anónimas:
$schema = Expect::from(new class {
public string $name;
public ?string $password = null;
public bool $admin = false;
});
Como la información obtenida de la definición de la clase puede no bastar, puede complementar los elementos con su propio esquema mediante el segundo parámetro:
$schema = Expect::from(new Config, [
'name' => Expect::string()->pattern('\w:.*'),
]);
Fusionar varias configuraciones
Las aplicaciones suelen componer su configuración por capas: hay valores predeterminados integrados y, encima de ellos, el
usuario aporta sus propios ajustes, que deberían sobrescribir solo los elementos que realmente indica. Eso es exactamente lo que
hace processMultiple(): toma varios conjuntos de datos, los fusiona en orden de modo que los posteriores tienen
prioridad, y valida el resultado final en conjunto:
$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}
El elemento host conserva su valor predeterminado porque el usuario no lo estableció, mientras que
port y logging los sobrescribe el conjunto de datos posterior. Los valores guardados bajo claves de tipo
cadena se fusionan de esta manera; los elementos indexados numéricamente (las lists) se añaden uno tras otro en lugar de
sobrescribirse.
Por debajo: normalize, merge, complete
Cada elemento del esquema, ya sea integrado o escrito por usted, implementa cuatro métodos que juntos definen cómo trata los datos. Tres de ellos forman la cadena de procesamiento:
- normalize(): prepara la entrada en bruto. Aquí se ejecutan los hooks de
before()y aquí, por ejemplo, un objeto se convierte en array. Se ejecuta primero, por separado en cada conjunto de datos. - merge(): combina dos conjuntos de datos ya normalizados, con prioridad para el posterior. Este paso lo usa solo
processMultiple();process()se lo salta, porque tiene un único conjunto de datos. - complete(): realiza la validación en sí, rellena los valores predeterminados de los elementos que faltan y aplica
assert(),transform()ycastTo(). Se ejecuta al final, sobre el resultado fusionado.
El cuarto método, completeDefault(), lo llama el elemento padre para un elemento que falta por completo en la entrada:
o bien proporciona el valor predeterminado, o bien informa de que falta un elemento required().
Así que process() ejecuta normalize → complete, mientras que processMultiple() ejecuta
normalize (cada conjunto de datos) → merge → complete. Este orden es la razón por la que before() ve la
entrada en bruto, mientras que transform() ve el valor ya validado.
Elementos de esquema propios
Con assert(), transform() y before() se llega muy lejos, así que rara vez hace falta
construir algo desde cero. Pero cuando quiera un elemento reutilizable y autónomo con su propia lógica de validación y de
fusión, puede crearlo implementando la interfaz Nette\Schema\Schema. Tiene exactamente los cuatro métodos
descritos arriba:
interface Schema
{
function normalize(mixed $value, Context $context);
function merge(mixed $value, mixed $base);
function complete(mixed $value, Context $context);
function completeDefault(Context $context);
}
Los errores no se lanzan; en su lugar los notifica a través del objeto Context con $context->addError() y
devuelve null. El Processor reúne todos los errores y los lanza juntos al final.
Como ejemplo, construyamos un elemento reutilizable que acepte el valor de respaldo de un enum (p. ej. la cadena
'hearts') y devuelva la instancia del 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; // no hace falta preprocesar nada
}
public function merge(mixed $value, mixed $base): mixed
{
return $value ?? $base; // gana el valor posterior
}
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; // valor que se usa cuando el elemento falta en la entrada
}
}
Lo puede usar en cualquier sitio donde se espere un elemento integrado, por sí solo o como parte de una estructura mayor:
enum Suit: string
{
case Hearts = 'hearts';
case Spades = 'spades';
}
$schema = Expect::structure([
'suit' => new EnumSchema(Suit::class),
]);
$processor->process($schema, ['suit' => 'hearts']);
// OK, devuelve {'suit' => Suit::Hearts}
Como el elemento implementa toda la interfaz, también funciona automáticamente dentro de processMultiple(): el
Processor llama a su método merge() igual que con cualquier otro elemento.