Two toothbrushes and some boxes on the background

El mes pasado hablamos sobre Zod, una herramienta de validación de datos basada en TypeScript con inferencia de tipos estática. Vimos los problemas que intenta resolver y cómo lo hace. En 2023 utilizamos Zod ampliamente en la nube de Nelio, en algunos de nuestros plugins, en varios scripts… y quedamos encantados, la verdad.

Me gusta mucho Zod y no sé cómo podía vivir sin ella; se ha convertido en una de esas herramientas indispensables para la validación de datos. Por desgracia, no siempre puedes confiar en ella; en PHP, por ejemplo, no está.

Echo mucho de menos a Zod cuando uso PHP. Tanto es así que decidí crear mi propia versión. Sí, has oído bien: he creado mi propia versión. ¿Por qué? Quería una versión ligera de Zod que me ayudara a validar y sanear los datos en nuestros plugins de manera fácil y eficaz, usando una sintaxis con la que ya estoy familiarizado. Y eso es lo que quiero compartir contigo hoy: cómo implementar tu propia biblioteca de validación Zod en PHP.

Ahora bien, si no quieres hacer nada de esto y simplemente quieres Zod en PHP, tengo buenas noticias: hay un paquete que puedes instalar con Composer. Pero mejor hacemos algo divertido, ¿no?

Nuestro objetivo

En primer lugar, recapitulemos rápidamente cómo funciona Zod en TypeScript. Este es el ejemplo que utilizamos el mes pasado:

import z from 'zod';
export const editorialTaskSchema = z.object( {
  id: z.string().uuid(),
  task: z.string().min( 1 ),
  completed: z.boolean(),
  postId: z.number().positive().optional(),
} );Lenguaje del código: JavaScript (javascript)

Básicamente, importamos z del paquete zod y esto nos da acceso a un conjunto completo de funciones para definir el esquema que queremos. A continuación definimos un esquema para un objeto (object) que se supone que contiene varias propiedades. Para cada propiedad, especificamos su propio esquema: id debe ser un string con el formato uuid, task debe ser un string no vacío, etc.

Una vez creado le esquema, podemos usarlo para validar una variable desconocida:

declare const maybeTask: unknown;
// Parse with exception throwing:
try {
  const task = editorialTaskSchema.parse( maybeTask );
} catch ( e ) { ... }
// Parse safely:
const result = editorialTaskSchema.safeParse( maybeTask );
if ( result.success ) {
  const task = result.data;
} else {
  const error = result.error;
}
Lenguaje del código: PHP (php)

y convertirlo al tipo que queremos.

Por lo tanto, mi objetivo para hoy es tener algo extremadamente similar en PHP. Quiero poder definir el esquema de la siguiente manera:

use Nelio\Zod\Zod as Z;
$editorial_task_schema = Z::object( [
  'id'        => Z::string()->uuid(),
  'task'      => Z::string()->min( 1 ),
  'completed' => Z::boolean(),
  'postId'    => Z::number()->positive()->optional(),
] );Lenguaje del código: PHP (php)

y luego analizar una variable desconocida tal como lo hicimos en TypeScript:

$maybe_task = [ ... ];
// Parse with exception throwing:
try {
  const $task = $editorial_task_schema->parse( $maybe_task );
} catch ( e ) { ... }
// Parse safely:
const $result = $editorial_task_schema->safe_parse( $maybe_task );
if ( $result['success'] ) {
  const $task = $result['data'];
} else {
  const $error = $result['error'];
}Lenguaje del código: PHP (php)

Implementación básica

Empecemos con lo básico. Lo primero que notarás cuando veas el esquema de Zod es que queremos un única clase Z con algunos métodos estáticos. Esta clase es como una fábrica que nos permite crear instancias de cualquier esquema que queramos: un esquema string, un esquema object, etc. Así que vamos a crear ese esqueleto básico:

namespace Nelio\Zod;
class Zod {
  public static function boolean() {
    return null;
  }
  public static function number() {
    return null;
  }
  public static function string() {
    return null;
  }
  // ...
}Lenguaje del código: PHP (php)

¡Muy bien! El problema es que todas estas clases devuelven null. Pero eso no es lo que queremos; siempre que creamos un esquema, queremos que sea una instancia de «algo» que nos permita parse y/o safe_parse una variable, así que está claro que también necesitamos una clase Schema:

namespace Nelio\Zod;
abstract class Schema {
  public function parse( $value ) {
    return $this->parse_value( $value );
  }
  public function safe_parse( $value ) {
    try {
      $result = $this->parse( $value );
      return array(
        'success' => true,
        'data'    => $result,
      );
    } catch ( \Exception $e ) {
      return array(
        'success' => false,
        'error'   => $e->getMessage(),
      );
    }
  }
  abstract protected function parse_value( $value );
}Lenguaje del código: PHP (php)

¿Por qué abstract?, te preguntarás. Bueno, está claro que analizar un string es distinto que analizar, por ejemplo, un number. Esto significa que necesitamos diferentes Schemas, cada uno con su propia implementación.

La clase abstracta Schema tiene una interfaz clara con los dos métodos que utilizarán nuestros consumidores (parse y safe_parse). También requiere que todas sus subclases concretas implementen un método que, dado un determinado $value, valide si coincide o no con el esquema; si es así, devolverá el valor analizado; si no, lanzará una excepción (parse) o un resultado con error (safe_parse).

Tipos de esquemas básicos

Hay tres tipos básicos que necesitan su propia subclase Schema: boolean, number y string. Veamos cómo implementar el primero.

Un valor boolean sólo tiene dos opciones posibles: true o false, así que eso es todo lo que tenemos que comprobar:

namespace Nelio\Zod;
class BooleanSchema extends Schema {
  protected function parse_value( $value ) {
    if ( ! in_array( $value, [ true, false ], true ) ) {
      throw new \Exception( 'Not a boolean value' );
    }
    return $value;
  }
}Lenguaje del código: PHP (php)

¡Eso es todo! ¿Ves qué fácil? Si ahora reescribimos nuestra clase Zod para usar este esquema:

class Zod {
  public static function boolean(): BooleanSchema {
    return new BooleanSchema();
  }
}Lenguaje del código: PHP (php)

podemos probarlo rápidamente:

use Nelio\Zod\Zod as Z;
$s = Z::boolean();
var_dump( [
  $s->safe_parse( true ),
  $s->safe_parse( false ),
  $s->safe_parse( 1 ),
  $s->safe_parse( 'hello' ),
] );Lenguaje del código: PHP (php)

y obtener los siguientes resultados:

Array
(
  [0] => Array
    (
      [success] => 1
      [data] => true
    )
  [1] => Array
    (
      [success] => 1
      [data] => false
    )
  [2] => Array
    (
      [success] =>
      [error] => Not a boolean value
    )
  [3] => Array
    (
      [success] =>
      [error] => Not a boolean value
    )
)Lenguaje del código: PHP (php)

¡Mola!

¿Qué pasa con los otros dos esquemas? Bueno, son tan sencillos como este. La única diferencia es que puedes agregar algunos controles más. Por ejemplo, consideremos el NumberSchema. Todo lo que tienes que hacer es verificar si $value es numérico y, en tal caso, que no sea un número representado como string :

namespace Nelio\Zod;
class NumberSchema extends Schema {
  protected function parse_value( $value ) {
    if ( ! is_numeric( $value ) {
      throw new \Exception( 'Not a numeric value' );
    }
    if ( is_string( $value ) ) {
      throw new \Exception( 'Numeric value is a string' );
    }
    return $value;
  }
}Lenguaje del código: PHP (php)

Como te decía, sabemos que Zod ofrece más controles para los números. Por ejemplo, permite establecer un límite inferior y/o superior, ya sea con funciones min y max u otras helper para explicitar que el número sea positivo, negativo, etc. ¿Cómo hacemos todo eso? Bueno, simplemente necesitamos añadir esas funciones aquí, establecer algunas propiedades internas y usarlas en nuestro método parse_value :

namespace Nelio\Zod;
class NumberSchema extends Schema {
  private $min;
  private $max;
  public function min( $min ) {
    $this->min = $min;
    return $this;
  }
  public function max( $max ) {
    $this->max = $max;
    return $this;
  }
  public function positive() {
    return $this->min( 1 );
  }
  // Add more helper functions...
  protected function parse_value( $value ) {
    if ( ! is_numeric( $value ) {
      throw new \Exception( 'Not a numeric value' );
    }
    if ( is_string( $value ) ) {
      throw new \Exception( 'Numeric value is a string' );
    }
    if ( ! is_null( $this->min ) && $value < $this->min ) {
      throw new \Exception( 'Value is too small' );
    }
    if ( ! is_null( $this->max) && $$this->max < $value ) {
      throw new \Exception( 'Value is too big' );
    }
    return $value;
  }
}Lenguaje del código: PHP (php)

¡Y eso es todo! Fíjate en algo importante: todas nuestras funciones auxiliares devuelven $this. Hay dos razones para esto. Por un lado, es más cómodo si luego terminamos con una instancia de Schema. Por otro lado, acabar con una instancia de este tipo nos permite encadenar varias funciones:

$schema = Z::number()->min( 3 )->max( 10 );Lenguaje del código: PHP (php)

¿Te gustaría intentar implementar ahora el StringSchema por tu cuenta? ¡Seguro que puedes! Dime cómo te ha ido en los comentarios.

Nelio A/B Testing

Pruebas A/B nativas en WordPress

Usa tu editor de páginas favorito en WordPress para crear variaciones y lanza pruebas A/B con solo un par de clics. No se necesita saber nada de programación para que funcione.

Aspectos avanzados

¿Y otros aspectos más complicados? Bueno, una vez entendido el patrón básico, en realidad es bastante fácil. Cada nuevo Schema que implementes solo es responsable de sí mismo y puede apoyarse en las otras piezas que hayas implementado. Veamos un par de ejemplos más.

Arrays

Digamos que desea construir un nuevo esquema que valide (a) que una variable determinada es un array y (b), si lo es, que todos los elementos de ese array satisfacen un determinado esquema (como, por ejemplo, ser un string con al menos 3 caracteres). Es decir, quieres lo siguiente:

$schema = Z::array( Z::string()->min( 3 ) );Lenguaje del código: PHP (php)

¿Cómo lo harías? Bueno, sigamos la misma receta de antes:

namespace Nelio\Zod;
class ArraySchema extends Schema {
  protected function parse_value( $value ) {
    // TODO
  }
}Lenguaje del código: PHP (php)

Ahora presta atención. Este esquema es un poco más complejo porque toma un esquema como parámetro de entrada, que es el que usaremos para analizar todos los elementos del array:

namespace Nelio\Zod;
class ArraySchema extends Schema {
  private $schema;
  public function __construct( $schema ) {
    $this->schema = $schema;
  }
  protected function parse_value( $value ) {
    // TODO
  }
}Lenguaje del código: PHP (php)

y ahora que tenemos el esquema para los elementos del array, es hora de implementar parse_value:

protected function parse_value( $value ) {
  if ( ! is_array( $value ) ) {
    throw new \Exception( 'Not an array' );
  }
  $result = array();
  foreach ( $value as $v ) {
    $result[] = $this->schema->parse( $v );
  }
  return $result;
}Lenguaje del código: PHP (php)

¿Ves? Todo lo que tenemos que hacer es asegurarnos de que $value es efectivamente un array y, si lo es, parse (analizar) cada elemento usando nuestro $this->schema. Si todos los elementos del array satisfacen este esquema interno, no hay nada de qué preocuparse: simplemente vamos construyendo, elemento a elemento, el array parseado $result. Pero si uno de los elementos no lo hace, $this->schema->parse lanzará una excepción; excepción que relanzará parse_value de ArraySchema, lo que dará lugar a un análisis no válido.

Simple. Elegante. Genial.

Objetos

El esquema para objetos es casi una copia del de arrays. Casi. La única diferencia es que en los objetos tenemos varias claves, cada una con su propio esquema. Veamos un primer intento de implementación, basado en lo que hemos hecho en la sección anterior:

namespace Nelio\Zod;
class ObjectSchema extends Schema {
  private $schemas;
  public function __construct( $schemas ) {
    $this->schemas = $schemas;
  }
  protected function parse_value( $value ) {
    if ( is_object( $value ) ) {
      $value = get_object_vars( $value );
    }
    if ( ! is_array( $value ) ) {
      throw new \Exception( 'Not an object' );
    }
    $result = array();
    foreach ( $this->schemas as $prop => $schema ) {
      $result[ $prop ] = $schema->parse(
        isset( $value[ $prop ] ) ? $value[ $prop ] : null
      );
    }
    return array_filter( $result, fn( $p ) => ! is_null( $p ) );
  }
}Lenguaje del código: PHP (php)

En lugar de entrar un único esquema, ahora entran varios $schemas, cada uno asociada a una $prop diferente. Así, en nuestro parse_value podemos iterar sobre todas las props que esperamos que nuestro objeto tenga e ir parseando la prop equivalente en $value con $schema->parse. De nuevo, si uno de los subesquemas falla, lanzaremos una excepción. Pero si todos lo superan con éxito, tendremos un objeto validado.

Ahora bien, falta un detalle importante. ¿Recuerdas nuestro objetivo inicial? Queríamos poder definir un objeto con propiedades optional, como postId aquí:

use Nelio\Zod\Zod as Z;
$editorial_task_schema = Z::object( [
  'id'        => Z::string()->uuid(),
  'task'      => Z::string()->min( 1 ),
  'completed' => Z::boolean(),
  'postId'    => Z::number()->positive()->optional(),
] );Lenguaje del código: PHP (php)

Por suerte, esto es fácil. Todos los esquemas que utilicemos en un objeto pueden ser opcionales, así que añadiremos el método optional en nuestro Schema abstracto y podremos utilizarlo cuando lo necesitemos:

namespace Nelio\Zod;
abstract class Schema {
  private $is_optional = false;
  public function optional() {
    $this->is_optional = true;
  }
  public function parse( $value ) {
    if ( $this->is_optional && is_null( $value ) ) {
      return null;
    }
    return $this->parse_value( $value );
  }
  public function safe_parse( $value ) {
    // ...
  }
  abstract protected function parse_value( $value );
}Lenguaje del código: PHP (php)

¡Ya está! Modificamos nuestro método parse para que, si el esquema es optional y no hay ningún $value de entrada, simplemente devuelva null. De esta forma, nuestro esquema de objetos puede filtrar valores null en su return.

Y ahora, ¿qué?

Hay algunas cosas más que puedes hacer e implementar. Valores por defeco, transformaciones, tipos de unión… ¡lo que quieras! Pero eso ya depende de ti.

Espero que hayas aprendido algo interesante y que, si alguna vez necesitas validar tus datos, implementes tu propio Zod o, bueno, utilices una de las bibliotecas existentes. Explícanos en la sección de comentarios de abajo qué tal te ha ido 😁.

Imagen destacada de Toa Heftiba en Unsplash.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

He leído y acepto la política de privacidad de Nelio Software

Tus datos personales se almacenarán en SiteGround y serán usados por Nelio Software con el único objetivo de publicar tu comentario aquí. Con el envío de este comentario, nos das el consentimiento expreso para ello. Escríbenos para acceder, rectificar, limitar o eliminar tus datos personales.