Recientemente escribí una pequeña joya para validaciones y me gustaría compartir con ustedes su implementación.
Ideas que se persiguieron al crear la biblioteca:
- Simplicidad
- Falta de magia
- Fácil de aprender
- La posibilidad de personalización y un mínimo de restricciones.
Casi todos estos puntos están relacionados con el primero: la simplicidad. La implementación final es increíblemente pequeña, por lo que no me tomaré mucho tiempo.
El código fuente se puede encontrar aquí .
Arquitectura
En lugar de usar el DSL habitual usando métodos de clase y bloque, decidí que usaría los datos.
Por lo tanto, en lugar del habitual DSL declarativo-imperativo (jaja, bueno, ¿entiendes, sí? "Declarativo-imperativo") como, por ejemplo, en Dry, mi DSL simplemente convierte algunos conjuntos de datos en un validador. Esto también significa que esta biblioteca se puede implementar (teóricamente) en otros lenguajes dinámicos (por ejemplo, python), no necesariamente orientada a objetos.
Leí el último párrafo y entiendo que escribí algún tipo de desastre. Lo siento Primero, daré algunas definiciones y luego daré un ejemplo.
Definiciones
Toda la biblioteca se basa en tres conceptos simples: validador , modelo y transformación .
- El validador es para qué sirve la biblioteca. Un objeto que verifica si algo cumple con nuestros requisitos.
- Un esquema es simplemente datos arbitrarios que describen otros datos (el propósito de nuestra validación).
- Una transformación es una función
t(b, f)
que toma un circuito y el objeto que llama a esta función (fábrica), y devuelve otro circuito o un validador.
Por cierto, la palabra "conversión" contextualmente en matemáticas es sinónimo de la palabra "función" (en cualquier caso, en el libro que leí en la universidad).
La fábrica, formalmente, hace lo siguiente:
- Para un conjunto de transformaciones
T1, T2, ..., Tn
, Ta(Tb(Tc(...)))
una composición Ta(Tb(Tc(...)))
(el orden es arbitrario). - La composición resultante se aplica al circuito cíclicamente hasta que el resultado difiere del argumento.
Me recuerda a una máquina de Turing. En la salida, deberíamos obtener un validador (o una función anónima). Cualquier otra cosa significa que el esquema y / o las transformaciones son incorrectos.
Ejemplo
En reddit, un hombre dio un ejemplo en Dry:
user_schema = Dry::Schema.Params do required(:id).value(:integer) required(:name).value(:string) required(:age).value(:integer, included_in?: 0..150) required(:favourite_food).value(array[:string]) required(:dog).maybe do hash do required(:name).value(:string) required(:age).value(:integer) optional(:breed).maybe(:string) end end end user_schema.call(id: 123, name: "John", age: 18, ...).success?
Como puede ver, la magia se usa en la forma de required(..).value
y métodos #array
como #array
.
Compare con mi ejemplo:
is_valid_user = StValidation.build( id: Integer, name: String, age: ->(x) { x.is_a?(Integer) && (0..150).cover?(x) }, favourite_food: [String], dog: Set[NilClass, { name: String, age: Integer, breed: Set[NilClass, String] }] ) is_valid_user.call(id: 123, name: 'John', age: 18, ...)
- Un hash se usa para describir un hash. Los valores se usan para describir valores (clases, matrices, conjuntos, funciones anónimas). No hay métodos mágicos (no se considera
#build
, porque es solo una abreviatura). - El valor de validación final no es un objeto complejo, sino simplemente verdadero / falso, del cual nos preocupamos en última instancia. Esto no es una ventaja, sino una simplificación.
- En Dry, el hash externo se define ligeramente diferente del interno. En el nivel externo, se
Schema.Params
método Schema.Params
y dentro de #hash
. - (bonificación) en mi caso, el objeto validado no tiene que ser un hash, y no se requiere una sintaxis especial:
is_int = StValidation.build(Integer)
.
Cada elemento del circuito en sí es un circuito. Un hash es un ejemplo de un esquema complejo (es decir, un esquema que consta de otros esquemas).
Estructura
Toda la gema consta de un pequeño número de partes:
StValidation
nombres principal (módulo) StValidation
- La fábrica responsable de la generación de validadores es
StValidation::ValidatorFactory
. StValidation::AbstractValidator
abstracto StValidation::AbstractValidator
, que es, de hecho, una interfaz.- El conjunto de validadores básicos que
StValidation::Validators
en la "sintaxis" básica en el módulo StValidation::Validators
- Dos métodos del módulo principal para mayor comodidad y combinación de todos los demás elementos:
StValidation.build
- usando un conjunto estándar de transformacionesStValidation.with_extra_transformations
: usando un conjunto estándar de transformaciones, pero expandiéndolo.
DSL estándar
Incluí los siguientes elementos en mi propio DSL:
- Clase: comprueba el tipo de un objeto (por ejemplo, Integer).
El validador más simple en mi sintaxis, aparte de la función anónima y los descendientes de AbstractValidator, que son las primitivas del generador. - La multitud es la unión de esquemas. Ejemplo:
Set[Integer, ->(x) { x.nil? }]
Set[Integer, ->(x) { x.nil? }]
.
Comprueba que el objeto coincide con al menos uno de los esquemas . Incluso la clase misma se llama UnionValidator
.
El ejemplo más simple es un validador compuesto. - Una matriz es un ejemplo:
[Integer]
.
Comprueba que el objeto es una matriz y que todos sus elementos satisfacen un determinado esquema . - Un hash es lo mismo, pero para los hash. No se permiten llaves adicionales.
El conjunto de transformaciones se ve así:
def basic_transformations [ ->(bp, _factory) { bp.is_a?(Class) ? class_validator(bp) : bp }, ->(bp, factory) { bp.is_a?(Set) ? union_validator(bp, factory) : bp }, ->(bp, factory) { bp.is_a?(Hash) ? hash_validator(bp, factory) : bp }, ->(bp, factory) { bp.is_a?(Array) && bp.size == 1 ? array_validator(bp[0], factory) : bp } ] end def class_validator(klass) Validators::ClassValidator.new(klass) end def union_validator(blueprint, factory) Validators::UnionValidator.new(blueprint, factory) end
En ninguna parte es más fácil, ¿verdad?
Errores y #explicar
Para mí personalmente, el objetivo principal de las validaciones es verificar si un objeto es válido. Por qué no es válido es una pregunta secundaria.
Sin embargo, es útil entender por qué algo no es válido. Para hacer esto, agregué el método #explain
a la interfaz del validador.
Esencialmente, debería hacer lo mismo que la validación, pero devolver lo que es específicamente incorrecto.
En general, la validación en sí misma ( #call
) podría definirse como un caso especial de #explain
, simplemente comprobando si el resultado de la explicación está vacío.
Dicha validación, sin embargo, será más lenta (pero esto no es importante).
Porque Las funciones de predicado anónimo se envuelven en el descendiente AbstractValidator
; también tienen el método #explain
y simplemente indican dónde se define la función.
Al escribir validadores personalizados, #explain
puede ser arbitrariamente complejo e inteligente.
Personalización
Mi "sintaxis" no está integrada en el corazón de la biblioteca y, por lo tanto, no es necesaria. (Ver StValidation.build
).
Probemos un DSL más simple que solo incluya números, cadenas y matrices:
validator_factory = StValidation::ValidatorFactory.new( [ -> (blueprint, _) { blueprint == :int ? ->(x) { x.is_a?(Integer) } : blueprint }, -> (blueprint, _) { blueprint == :str ? ->(x) { x.is_a?(String) } : blueprint }, lambda do |blueprint, factory| return blueprint unless blueprint.is_a?(Array) inner_validators = blueprint.map { |b| factory.build(b) } ->(x) { x.is_a?(Array) && inner_validators.zip(x).all? { |v, e| v.call(e) } } end ] ) is_int = validator_factory.build(:int) is_int.call('123')
Perdón por el código un poco confuso. En esencia, la matriz en este caso verifica el cumplimiento por índice.
Resumen
Pero no a él. Estoy orgulloso de esta solución técnica y quería demostrarlo :)