Еще один DSL для валидаций

от автора

Недавно я написал небольшой гем для валидаций и хотел бы поделиться с вами его реализацией.

Идеи, которые преследовались при создании библиотеки:

  • Простота
  • Отсутствие магии
  • Легкость в освоении
  • Возможность кастомизации и минимум ограничений.

Почти все эти пункты завязаны на первом — простоте. Итоговая реализация невероятно маленькая, поэтому я не отниму у вас много времени.

С исходным кодом можно ознакомиться здесь.

Архитектура

Вместо использования привычного DSL с помощью методов класса и блоков я решил, что буду использовать данные.
Таким образом, вместо привычного декларативно-императивного (хаха, ну вы поняли, да? «декларативно-императивный») DSL как, например, в Dry, мой DSL просто преобразует некоторый набор данных в валидатор. Так же это означает, что данная библиотека может быть реализована (теоретически) и на других динамических языках (например, питоне), не обязательно даже объектно-ориентированных.

Читаю я последний параграф и понимаю, что написал какую-то кашу. Прошу прощения. Для начала я дам несколько определений и потом приведу пример.

Определения

Вся библиотека построена на трех простых концептах: валидатор, схема (blueprint) и преобразование (transformation).

  • Валидатор — то, ради чего библиотека и нужна. Объект, который проверяет, удовлетворяет ли нечто нашим требованиям.
  • Схема — это просто произвольные данные, описывающие другие данные (цель нашей валидации).
  • Преобразование — функция t(b, f), принимающая схему и объект, вызывающий эту функцию (фабрика), и возвращает она либо другую схему, либо валидатор.
    Кстати, слово «преобразование» контекстуально в математике является синонимом слова «функция» (во всяком случае, в книжке, которую я читал в универе).

Фабрика, формально, делает следующее:

  • Для набора преобразований T1, T2, ..., Tn создается композиция Ta(Tb(Tc(...))) (порядок произвольный).
  • Полученная композиция применяется к схеме циклично, пока результат отличается от аргумента.

Мне это чем-то напоминает машину Тьюринга. На выходе мы должны получить валидатор (или анонимную функцию). Что-либо иное означает, что схема и(ли) трансформации неверны.

Пример

На реддите человек привел пример в 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?

Как видите, используется магия в виде required(..).value и методы вроде #array.

Сравните с моим примером:

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. Для описания хеша используется… хеш. Для описания значений используются значения (классы, массивы, множества, анонимные функции). Никаких магических методов (#build не считается, т.к. является просто сокращением).
  2. Итоговым значением валидации является не сложный объект, а просто true/false, о чем мы в конечном итоге и волнуемся. Это не является преимуществом, но упрощением.
  3. В Dry внешний хеш определен немного отлично от внутреннего. На внешнем уровне используется метод Schema.Params, а внутри #hash.
  4. (бонус) в моем случае валидируемый объект не обязан быть хешем и при этом не требуется никакого особенного синтаксиса: is_int = StValidation.build(Integer).
    Каждый элемент схемы сам является схемой. Хеш — пример сложной схемы (т.е. схемой, которая состоит из других схем).

Структура

Весь гем состоит из небольшого количества частей:

  • Главное пространство имен (модуль) StValidation
  • Фабрика, которая и отвечает за генерацию валидаторов, StValidation::ValidatorFactory.
  • Абстрактный валидатор StValidation::AbstractValidator, являющийся, по сути, интерфейсом.
  • Набор базовых валидаторов, которые я включил в базовый «синтаксис» в модуле StValidation::Validators
  • Два метода главного модуля для удобства и объединения всех остальных элементов:
    • StValidation.build — использующий стандартный набор трансформаций
    • StValidation.with_extra_transformations — использующий стандартный набор трансформаций, но расширяющий его.

Стандартный DSL

В свой собственный DSL я включил следующие элементы:

  • Класс — проверяет тип объекта (например, Integer).
    Простейший валидатор в моем синтаксисе, не считая анонимной функции и наследников AbstractValidator, которые являются примитивами генератора.
  • Множество — объединение схем. Пример: Set[Integer, ->(x) { x.nil? }].
    Проверяет, что объект соответствует хотя бы одной из схем. Даже сам класс называется UnionValidator.
    Простейший пример композитного валидатора.
  • Массив — пример: [Integer].
    Проверяет, что объект является массивом и все его элементы удовлетворяют определенной схеме.
  • Хеш — то же самое, но для хешей. Дополнительные ключи не позволяются.

Набор трансормаций выглядит так:

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

Проще некуда, не правда ли?

Ошибки и #explain

Лично для меня основной целью валидаций является проверка, валиден ли объект. Почему он не валиден — уже побочный вопрос.
Однако полезно понимать, почему что-то не валидно. Для этого я добавил в интерфейс валидатора метод #explain.

По сути, он должен делать то же самое, что и валидация, но возвращать, что конкретно не так.
В целом, саму валидацию (#call) можно было бы определить как частный случай #explain, просто проверив, пустой ли результат explain.

Такая валидация, однако, будет медленнее (но это не важно).

Т.к. анонимные функции-предикаты оборачиваются в наследника AbstractValidator, они тоже имеют метод #explain и просто указывают, где функция определена.

При написании собственных валидаторов, #explain может быть сколь угодно сложным и умным.

Кастомизация

Мой «синтаксис» не встроен в сердце библиотеки и, соответственно, не обязателен к использованию. (см. StValidation.build).

Давайте попробуем более простой DSL, который будет включать только числа, строки и массивы:

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

Простите за немного запутанный код. По сути, массив в данном случае проверяет соответствие по индексу.

Итог

А нет его. Просто я горжусь данным техническим решением и хотел его продемонстрировать 🙂


ссылка на оригинал статьи https://habr.com/ru/post/479600/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *