Outro DSL para validações

Recentemente, escrevi uma pequena jóia para validações e gostaria de compartilhar com você sua implementação.


Idéias que foram buscadas ao criar a biblioteca:


  • Simplicidade
  • Falta de magia
  • Fácil de aprender
  • A possibilidade de personalização e um mínimo de restrições.

Quase todos esses pontos estão ligados à primeira - simplicidade. A implementação final é incrivelmente pequena, então não vou demorar muito do seu tempo.


O código fonte pode ser encontrado aqui .


Arquitetura


Em vez de usar o DSL usual usando métodos de classe e bloco, decidi que usaria os dados.
Portanto, em vez do DSL declarativo-imperativo habitual (haha, bem, você entende, sim? "Declarativo-imperativo") como, por exemplo, no Dry, meu DSL simplesmente converte alguns dados em um validador. Isso também significa que essa biblioteca pode ser implementada (teoricamente) em outras linguagens dinâmicas (por exemplo, python), nem necessariamente orientada a objetos.


Li o último parágrafo e entendi que escrevi algum tipo de bagunça. Me desculpe Primeiro, darei algumas definições e depois darei um exemplo.


Definições


Toda a biblioteca é construída em três conceitos simples: validador , blueprint e transformação .


  • O validador é para que serve a biblioteca. Um objeto que verifica se algo atende aos nossos requisitos.
  • Um esquema é simplesmente dados arbitrários que descrevem outros dados (o objetivo de nossa validação).
  • Uma transformação é uma função t(b, f) que recebe um circuito e o objeto que chama essa função (de fábrica) e retorna outro circuito ou um validador.
    A propósito, a palavra "conversão" contextualmente em matemática é sinônimo da palavra "função" (em qualquer caso, no livro que li na universidade).

A fábrica, formalmente, faz o seguinte:


  • Para um conjunto de transformações T1, T2, ..., Tn , Ta(Tb(Tc(...))) uma composição Ta(Tb(Tc(...))) (a ordem é arbitrária).
  • A composição resultante é aplicada ao circuito ciclicamente até que o resultado seja diferente do argumento.

Isso me lembra uma máquina de Turing. Na saída, devemos obter um validador (ou uma função anônima). Qualquer outra coisa significa que o esquema e / ou transformações estão incorretas.


Exemplo


No reddit, um homem deu um exemplo no 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 você pode ver, a magia é usada na forma de required(..).value #array e métodos como #array .


Compare com o meu exemplo:


 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, ...) 

  1. Um hash é usado para descrever um hash. Os valores são usados ​​para descrever valores (classes, matrizes, conjuntos, funções anônimas). Nenhum método mágico ( #build não #build considerado, porque é apenas uma abreviação).
  2. O valor final da validação não é um objeto complexo, mas simplesmente verdadeiro / falso, com o qual nos preocupamos. Isso não é uma vantagem, mas uma simplificação.
  3. No Dry, o hash externo é definido ligeiramente diferente do interno. No nível externo, o método Schema.Params é Schema.Params e dentro de #hash .
  4. (bônus) no meu caso, o objeto validado não precisa ser um hash e nenhuma sintaxe especial é necessária: is_int = StValidation.build(Integer) .
    Cada elemento do circuito em si é um circuito. Um hash é um exemplo de um esquema complexo (isto é, um esquema que consiste em outros esquemas).

Estrutura


A gema inteira consiste em um pequeno número de peças:


  • Namespace principal (módulo) StValidation
  • A fábrica responsável pela geração de validadores é StValidation::ValidatorFactory .
  • Validador abstrato StValidation::AbstractValidator , que é, de fato, uma interface.
  • O conjunto de validadores básicos que incluí na "sintaxe" básica no módulo StValidation::Validators
  • Dois métodos do módulo principal para conveniência e combinação de todos os outros elementos:
    • StValidation.build - usando um conjunto padrão de transformações
    • StValidation.with_extra_transformations - usando um conjunto padrão de transformações, mas expandindo-o.

DSL padrão


Incluí os seguintes elementos em minha própria DSL:


  • Classe - verifica o tipo de um objeto (por exemplo, Inteiro).
    O validador mais simples da minha sintaxe, além da função anônima e dos descendentes do AbstractValidator, que são os primitivos do gerador.
  • A multidão é a união de esquemas. Exemplo: Set[Integer, ->(x) { x.nil? }] Set[Integer, ->(x) { x.nil? }]
    Verifica se o objeto corresponde a pelo menos um dos esquemas . Até a própria classe é chamada UnionValidator .
    O exemplo mais simples é um validador composto.
  • Uma matriz é um exemplo: [Integer] .
    Verifica se o objeto é uma matriz e se todos os seus elementos atendem a um determinado esquema .
  • Um hash é o mesmo, mas para hashes. Chaves extras não são permitidas.

O conjunto de transformações é assim:


 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 # ... 

Nenhum lugar é mais fácil, é?


Erros e #explicar


Para mim, pessoalmente, o principal objetivo das validações é verificar se um objeto é válido. Por que não é válido é uma questão paralela.
No entanto, é útil entender por que algo não é válido. Para fazer isso, adicionei o método #explain à interface do validador.


Essencialmente, ele deve fazer o mesmo que validação, mas retornar o que está especificamente errado.
Em geral, a validação em si ( #call ) pode ser definida como um caso especial de #explain , apenas verificando se o resultado da explicação está vazio.


Essa validação, no entanto, será mais lenta (mas isso não é importante).


Porque As funções de predicado anônimo envolvem-se no descendente AbstractValidator ; elas também têm o método #explain e simplesmente indicam onde a função está definida.


Ao escrever validadores personalizados, #explain pode ser arbitrariamente complexo e inteligente.


Personalização


Minha "sintaxe" não está embutida no coração da biblioteca e, portanto, não é necessária. (consulte StValidation.build ).


Vamos tentar uma DSL mais simples que inclua apenas números, seqüências de caracteres e matrizes:


 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') # ==> false is_int_pair = validator_factory.build([:int, :int]) is_int_pair.call([1, 2]) # ==> true is_int_pair.call([1, '2']) # ==> false 

Desculpe pelo código um pouco confuso. Essencialmente, a matriz nesse caso verifica a conformidade pelo índice.


Sumário


Mas ele não. Estou orgulhoso desta solução técnica e queria demonstrá-la :)

Source: https://habr.com/ru/post/pt479600/


All Articles