DEV Community

Sergei Malykh
Sergei Malykh

Posted on

Валидация кастомных типов данных для dry-schema

При задании схемы есть возможность указать кастомный тип данных на основе конструктора одного из базовых типов. Для примера возьмем конвертер римских чисел в Integer. При вводе невалидной комбинации должна возращаться человеко-читаемая ошибка.

RomanNumber =
  Dry.Types::Integer.constructor do |input|
    roman_to_int(input)
  end

schema =
  Dry::Schema.Params do
    optional(:int).maybe(:integer)
    optional(:roman).maybe(RomanNumber)
  end
Enter fullscreen mode Exit fullscreen mode

Реализация функции roman_to_int не важна. Возьмем, например, такую:

H = {"VI"=>4, "XI"=>9, "LX"=>40, "CX"=>90, "DC"=>400, "MC"=>900, "I"=>1, "V"=>5, "X"=>10, "L"=>50, "C"=>100, "D"=>500, "M"=>1000}.transform_values { " #{_1}" }
def roman_to_int(roman_string)
  roman_string.reverse.gsub(Regexp.union(H.keys), H).split.sum(&:to_i)
end
Enter fullscreen mode Exit fullscreen mode

Проверим, что будет получаться при валидации схемы:

schema.call(int: 'invalid')
# => #<Dry::Schema::Result{:int=>"invalid"} errors={:int=>["must be an integer"]} path=[]>

schema.call(roman: 'invalid')
# => #<Dry::Schema::Result{:roman=>0} errors={} path=[]>
Enter fullscreen mode Exit fullscreen mode

Первый результат ожидаем и нагляден, а вот второй - просто не корректен. Вместо ошибки о невалидности мы получили 0. Нужно добавить проверку римской нотации. Например, такую:

RomanNumber =
  Dry.Types::Integer.constructor do |input|
    if roman_valid?(input)
      roman_to_int(input)
    end
  end
Enter fullscreen mode Exit fullscreen mode
INVALID_ROMAN = /[MDCLXVI]/i
def roman_valid?(s)
  !s.match?(INVALID_ROMAN)
end
Enter fullscreen mode Exit fullscreen mode

Возникает вопрос, а что делать, если нотация не валидна? Никаких подсказок в документации нет, попробуем вызвать exception:

RomanNumber =
  Dry.Types::Integer.constructor do |input|
    if roman_valid?(input)
      roman_to_int(input)
    else
      raise 'invalid roman number'
    end
  end
Enter fullscreen mode Exit fullscreen mode

Метод тыка не сработал, в результате вызова получаем RuntimeError, а не красивую ошибку:

schema.call(roman: 'invalid')
# RuntimeError: invalid roman number
Enter fullscreen mode Exit fullscreen mode

Придется покопаться в исходниках... Библиотека dry-types специальным образом обрабатывает 3 класса ошибок: NoMethodError, TypeError, ArgumentError. Поэтому, чтобы увидеть желаемую ошибку валидации, нужно использовать один из перечисленных:

RomanNumber =
  Dry.Types::Integer.constructor do |input|
    if roman_valid?(input)
      roman_to_int(input)
    else
      raise TypeError, 'invalid roman number'
    end
  end
Enter fullscreen mode Exit fullscreen mode
schema.call(roman: 'invalid')
# => #<Dry::Schema::Result{:roman=>"invalid"} errors={:roman=>["must be an integer"]} path=[]>
Enter fullscreen mode Exit fullscreen mode

Вот теперь результат нам подходит.

Стоит отметить, что решение с перехватом ошибок конструктора не однозначно. NoMethodError довольно частая ошибка в коде. Все наверняка встречали ситуацию, когда вместо ожидаемого значения, в переменной оказывается nil. В этом случае вызов большинства методов вызовет ту самую NoMethodError:

roman_to_int(nil)
# => NoMethodError: undefined method `reverse' for nil:NilClass
Enter fullscreen mode Exit fullscreen mode

Ошибка может возникнуть как вследствие невалидных данных, так и вследствие ошибок в самом коде. Следует об этом помнить, когда пишете кастомный конструктор.

Итоговый код:

H = {"VI"=>4, "XI"=>9, "LX"=>40, "CX"=>90, "DC"=>400, "MC"=>900, "I"=>1, "V"=>5, "X"=>10, "L"=>50, "C"=>100, "D"=>500, "M"=>1000}.transform_values { " #{_1}" }
def roman_to_int(roman_string)
  roman_string.reverse.gsub(Regexp.union(H.keys), H).split.sum(&:to_i)
end

INVALID_ROMAN = /[MDCLXVI]/i
def roman_valid?(s)
  !s.match?(INVALID_ROMAN)
end

RomanNumber =
  Dry.Types::Integer.constructor do |input|
    if roman_valid?(input)
      roman_to_int(input)
    else
      raise TypeError, 'invalid roman number'
    end
  end

schema =
  Dry::Schema.Params do
    optional(:int).maybe(:integer)
    optional(:roman).maybe(RomanNumber)
  end

schema.call(roman: 'invalid')
Enter fullscreen mode Exit fullscreen mode

Top comments (0)