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

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

Heroku

This site is built on Heroku

Join the ranks of developers at Salesforce, Airbase, DEV, and more who deploy their mission critical applications on Heroku. Sign up today and launch your first app!

Get Started

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay