Schema: Validación de datos

Una biblioteca práctica para la validación y normalización de estructuras de datos con respecto a 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 (qué significa esto exactamente y cómo crearlo lo diremos más adelante) y en la variable $data tenemos una estructura de datos que queremos validar y normalizar. Pueden ser, por ejemplo, datos enviados por el usuario a través de una API, fichero de configuración, etc.

De esta 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 en caso de error.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Data is invalid: ' . $e->getMessage();
}

El método $e->getMessages() devuelve un array con todas las cadenas de mensajes y $e->getMessageObjects() devuelve todos los mensajes como objetos Nette\Schema\Message.

Definición del esquema

Y ahora vamos a crear un esquema. La clase Nette\Schema\Expect se utiliza para definirlo, en realidad definimos las expectativas de cómo deben ser los datos. Digamos que los datos de entrada deben ser una estructura (por ejemplo un array) que contenga 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 parece clara, incluso si la ves por primera vez.

Enviemos los siguientes datos para su validación:

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

$normalized = $processor->process($schema, $data); // OK, pasa

La salida, es decir, el valor $normalized, es el objeto stdClass. Si queremos que la salida sea un array, añadimos un cast al esquema Expect::structure([...])->castTo('array').

Todos los elementos de la estructura son opcionales y tienen un valor por defecto null. Ejemplo:

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

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

El hecho de que el valor por defecto sea null no significa que se aceptaría en los datos de entrada 'processRefund' => null. No, la entrada debe ser booleana, es decir, sólo true o false. Tendríamos que permitir explícitamente null a través de Expect::bool()->nullable().

Un elemento puede hacerse obligatorio mediante Expect::bool()->required(). Cambiamos el valor por defecto a false utilizando Expect::bool()->default(false) o en breve utilizando Expect::bool(false).

¿Y si quisiéramos aceptar 1 and 0 además de booleanos? Entonces enumeramos los valores permitidos, que también normalizaremos a booleanos:

$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

Ahora ya sabes lo básico de cómo se define el esquema y cómo se comportan los elementos individuales de la estructura. Ahora mostraremos qué otros elementos se pueden utilizar para definir un esquema.

Tipos de datos: type()

Todos los tipos de datos estándar de PHP pueden ser listados en el esquema:

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

Y luego todos los tipos soportados por los Validadores a través de Expect::type('scalar') o abreviado Expect::scalar(). También se aceptan nombres de clases o interfaces, por ejemplo Expect::type('AddressEntity').

También se puede utilizar la notación de unión:

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

El valor por defecto es siempre null excepto para array y list, donde es un array vacío. (Una lista es una matriz indexada en orden ascendente de claves numéricas a partir de cero, es decir, una matriz no asociativa).

Matriz de valores: arrayOf() listOf()

El array es una estructura demasiado general, es más útil especificar exactamente qué elementos puede contener. Por ejemplo, un array cuyos elementos sólo 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 utilizarse para especificar 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 int

La lista es una matriz indexada:

$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 lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: no es una lista

El parámetro también puede ser un esquema, por lo que podemos escribir:

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

El valor por defecto es un array vacío. Si especifica el valor por defecto, se fusionará con los datos pasados. Esto puede desactivarse utilizando mergeDefaults(false) (desde la versión 1.1).

Enumeración: anyOf()

anyOf() es un conjunto de valores o esquemas que un valor puede ser. A continuación se muestra cómo escribir una matriz 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 debe estar 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 variantes como parámetros individuales, no como array. Para pasarle un array de valores, utilice el operador de desempaquetado anyOf(...$variants).

El valor por defecto es null. Utilice el método firstIsDefault() para que el primer elemento sea el valor predeterminado:

// default is 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Estructuras

Las estructuras son objetos con claves definidas. Cada uno de estos pares clave ⇒ valor se denomina “propiedad”:

Las estructuras aceptan arrays y objetos y devuelven objetos stdClass (a menos que lo cambies con castTo('array'), etc.).

Por defecto, todas las propiedades son opcionales y tienen un valor por defecto de null. Puede definir propiedades obligatorias utilizando required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // el valor por defecto es null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: falta la opción 'required

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

Si no desea mostrar propiedades con un valor predeterminado, utilice 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 por defecto de la propiedad optional, no está permitido en los datos de entrada (el valor debe ser una cadena). Las propiedades que aceptan null se definen utilizando nullable():

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

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' espera ser string, dado null.

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

Por defecto, no puede haber elementos adicionales en los datos de entrada:

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

$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'

Lo cual podemos cambiar con otherItems(). Como parámetro, especificaremos el esquema para cada elemento extra:

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

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

Depreciaciones

Puedes eliminar una propiedad utilizando el método deprecated([string $message]) método. Los avisos de desaprobación son devueltos por $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('El elemento %ruta% está obsoleto'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["El elemento 'old' es obsoleto"]

Rangos: min() max()

Utilice min() y max() para limitar el número de elementos de las matrices:

// matriz, al menos 10 elementos, máximo 20 elementos
Expect::array()->min(10)->max(20);

Para cadenas, limita su longitud:

// cadena, de al menos 10 caracteres, máximo 20 caracteres
Expect::string()->min(10)->max(20);

Para los números, limita su valor:

// número entero, entre 10 y 20 inclusive
Expect::int()->min(10)->max(20);

Por supuesto, es posible mencionar sólo min(), o sólo max():

// cadena, máximo 20 caracteres
Expect::string()->max(20);

Expresiones regulares: pattern()

Utilizando pattern(), puede especificar una expresión regular con la que debe coincidir toda la cadena de entrada (es decir, como si estuviera envuelta en caracteres ^ a $):

// sólo 9 dígitos
Expect::string()->pattern('\d{9}');

Aserciones personalizadas: assert()

Puede añadir cualquier otra restricción utilizando assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // la cuenta debe ser par

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 no es par

O:

Expect::string()->assert('is_file'); // el archivo debe existir

Puede añadir su propia descripción para cada aserción. Formará parte del mensaje de error.

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

$processor->process($schema, ['a', 'b', 'c']);
// Fallo en la aserción "Even items in array" para el elemento con valor array.

El método puede llamarse repetidamente para añadir múltiples restricciones. Puede entremezclarse con llamadas a transform() y castTo().

Transformación: transform()

Los datos validados correctamente pueden modificarse mediante una función personalizada:

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

El método puede llamarse repetidamente para añadir múltiples transformaciones. Puede entremezclarse con llamadas a assert() y castTo(). Las operaciones se ejecutarán en el orden en que se declaren:

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

El método transform() puede transformar y validar el valor simultáneamente. Esto suele ser más sencillo y menos redundante que encadenar transform() y assert(). Para ello, la función recibe un objeto Context con un método addError(), que puede utilizarse para añadir información sobre 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);
	});

Casting: castTo()

Los datos validados correctamente pueden ser emitidos:

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

Además de los tipos nativos de PHP, también se puede hacer cast a clases. Distingue si se trata de una clase simple sin constructor o de una clase con constructor. Si la clase no tiene constructor, se crea una instancia de la misma y todos los elementos de la estructura se escriben en sus propiedades:

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

Si la clase tiene un constructor, los elementos de la estructura se pasan como parámetros con nombre al constructor:

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

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

El moldeado combinado con un parámetro escalar crea un objeto y pasa el valor como único parámetro al constructor:

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

Normalización: before()

Antes de la validación propiamente dicha, los datos pueden normalizarse utilizando el método before(). Como ejemplo, tengamos un elemento que debe ser un array de cadenas (ej. ['a', 'b', 'c']), pero que recibe la entrada en forma de cadena a b c:

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

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

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

Asignación a objetos: from()

Se puede generar esquema de estructura a partir de la clase. Ejemplo:

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}

También se admiten clases anónimas:

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

Dado que la información obtenida de la definición de la clase puede no ser suficiente, puede añadir un esquema personalizado para los elementos con el segundo parámetro:

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