Еще один 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

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


    Итог


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

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.