Une autre DSL pour les validations

J'ai récemment écrit un petit bijou pour les validations et je voudrais partager avec vous sa mise en œuvre.


Idées poursuivies lors de la création de la bibliothèque:


  • Simplicité
  • Manque de magie
  • Facile à apprendre
  • La possibilité de personnalisation et un minimum de restrictions.

Presque tous ces points sont liés à la première - la simplicité. L'implémentation finale est incroyablement petite, donc je ne prendrai pas beaucoup de votre temps.


Le code source peut être trouvé ici .


L'architecture


Au lieu d'utiliser la DSL habituelle en utilisant des méthodes de classe et de bloc, j'ai décidé d'utiliser les données.
Ainsi, au lieu de l'habituel déclaratif-impératif (haha, eh bien, vous comprenez, oui? "Déclaratif-impératif") DSL comme, par exemple, dans Dry, mon DSL convertit simplement un ensemble de données en un validateur. Cela signifie également que cette bibliothèque peut être implémentée (théoriquement) dans d'autres langages dynamiques (par exemple, python), pas nécessairement même orientés objet.


J'ai lu le dernier paragraphe et je comprends que j'ai écrit une sorte de gâchis. Je suis désolé. Je vais d'abord donner quelques définitions, puis donner un exemple.


Définitions


L'ensemble de la bibliothèque est construit sur trois concepts simples: validateur , plan directeur et transformation .


  • Le validateur est à quoi sert la bibliothèque. Un objet qui vérifie si quelque chose répond à nos exigences.
  • Un schéma est simplement des données arbitraires décrivant d'autres données (le but de notre validation).
  • Une transformation est une fonction t(b, f) qui prend un circuit et l'objet qui appelle cette fonction (usine), et elle renvoie soit un autre circuit, soit un validateur.
    Soit dit en passant, le mot «conversion» contextuellement en mathématiques est synonyme du mot «fonction» (en tout cas, dans le livre que j'ai lu à l'université).

L'usine, officiellement, fait ce qui suit:


  • Pour un ensemble de transformations T1, T2, ..., Tn , une composition Ta(Tb(Tc(...))) (l'ordre est arbitraire).
  • La composition résultante est appliquée au circuit de manière cyclique jusqu'à ce que le résultat diffère de l'argument.

Cela me rappelle une machine Turing. À la sortie, nous devrions obtenir un validateur (ou une fonction anonyme). Tout autre élément signifie que le schéma et / ou les transformations sont incorrects.


Exemple


Sur reddit, un homme a donné un exemple dans 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? 

Comme vous pouvez le voir, la magie est utilisée sous la forme required(..).value et méthodes comme #array .


Comparez avec mon exemple:


 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. Un hachage est utilisé pour décrire un hachage. Les valeurs sont utilisées pour décrire les valeurs (classes, tableaux, ensembles, fonctions anonymes). Pas de méthodes magiques ( #build pas considéré, car ce n'est qu'une abréviation).
  2. La valeur de validation finale n'est pas un objet complexe, mais simplement vrai / faux, ce qui nous inquiète finalement. Ce n'est pas un avantage, mais une simplification.
  3. Dans Dry, le hachage externe est défini légèrement différent de l'intérieur. Au niveau externe, la méthode Schema.Params est Schema.Params , et à l'intérieur de #hash .
  4. (bonus) dans mon cas, l'objet validé ne doit pas être un hachage, et aucune syntaxe spéciale n'est requise: is_int = StValidation.build(Integer) .
    Chaque élément du circuit lui-même est un circuit. Un hachage est un exemple de schéma complexe (c'est-à-dire un schéma qui se compose d'autres schémas).

La structure


La gemme entière se compose d'un petit nombre de parties:


  • StValidation noms principal (module) StValidation
  • L'usine responsable de la génération des validateurs est StValidation::ValidatorFactory .
  • StValidation::AbstractValidator abstrait StValidation::AbstractValidator , qui est en fait une interface.
  • L'ensemble des validateurs de base que j'ai inclus dans la "syntaxe" de base du module StValidation::Validators
  • Deux méthodes du module principal pour plus de commodité et combinant tous les autres éléments:
    • StValidation.build - en utilisant un ensemble standard de transformations
    • StValidation.with_extra_transformations - en utilisant un ensemble standard de transformations, mais en l'étendant.

DSL standard


J'ai inclus les éléments suivants dans ma propre DSL:


  • Classe - vérifie le type d'un objet (par exemple, Entier).
    Le validateur le plus simple de ma syntaxe, à part la fonction anonyme et les descendants de AbstractValidator, qui sont les primitives du générateur.
  • La multitude est l'union des schémas. Exemple: Set[Integer, ->(x) { x.nil? }] Set[Integer, ->(x) { x.nil? }] .
    Vérifie que l'objet correspond à au moins l'un des schémas . Même la classe elle-même s'appelle UnionValidator .
    L'exemple le plus simple est un validateur composite.
  • Un tableau est un exemple: [Integer] .
    Vérifie que l'objet est un tableau et que tous ses éléments satisfont un certain schéma .
  • Un hachage est le même, mais pour les hachages. Les clés supplémentaires ne sont pas autorisées.

L'ensemble des transformations ressemble à ceci:


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

Nulle part est plus facile, n'est-ce pas?


Erreurs et #explain


Pour moi personnellement, le but principal des validations est de vérifier si un objet est valide. Pourquoi n'est-il pas valide est une question secondaire.
Cependant, il est utile de comprendre pourquoi quelque chose n'est pas valide. Pour ce faire, j'ai ajouté la méthode #explain à l'interface du validateur.


Essentiellement, il devrait faire la même chose que la validation, mais renvoyer ce qui est spécifiquement faux.
En général, la validation elle-même ( #call ) peut être définie comme un cas spécial de #explain , simplement en vérifiant si le résultat d'explication est vide.


Cette validation sera cependant plus lente (mais ce n'est pas important).


Parce que Les fonctions de prédicat anonymes s'enroulent dans le descendant de AbstractValidator ; elles ont également la méthode #explain et indiquent simplement où la fonction est définie.


Lors de l'écriture de validateurs personnalisés, #explain peut être arbitrairement complexe et intelligent.


Personnalisation


Ma "syntaxe" n'est pas intégrée au cœur de la bibliothèque et, par conséquent, n'est pas requise. (voir StValidation.build ).


Essayons une DSL plus simple qui n'inclura que des nombres, des chaînes et des tableaux:


 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 

Désolé pour le code légèrement déroutant. En substance, le tableau dans ce cas vérifie la conformité par index.


Résumé


Mais pas lui. Je suis juste fier de cette solution technique et je voulais la démontrer :)

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


All Articles