Валидация кастомных типов данных для dry-schema
Tags: ruby, dryschema, drytypes
Published: 2023-06-09 08:52:30

---
title: Валидация кастомных типов данных для dry-schema
published: true
description: 
tags: #ruby #dryschema #drytypes
---

При задании схемы есть возможность указать [кастомный тип данных](https://dry-rb.org/gems/dry-schema/main/advanced/custom-types/) на основе конструктора одного из базовых типов. Для примера возьмем конвертер римских чисел в Integer. При вводе невалидной комбинации должна возращаться человеко-читаемая ошибка.

```ruby
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
```

Реализация функции `roman_to_int` не важна. Возьмем, например, [такую](https://stackoverflow.com/a/53034721):

```ruby
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
```

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

```ruby
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=[]>
```

Первый результат ожидаем и нагляден, а вот второй - просто не корректен. Вместо ошибки о невалидности мы получили 0. Нужно добавить проверку римской нотации. Например, [такую](https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch06s09.html):

```ruby
RomanNumber =
  Dry.Types::Integer.constructor do |input|
    if roman_valid?(input)
      roman_to_int(input)
    end
  end
```

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

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

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

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

```ruby
schema.call(roman: 'invalid')
# RuntimeError: invalid roman number
```

Придется покопаться в исходниках... Библиотека `dry-types` специальным образом [обрабатывает](https://github.com/dry-rb/dry-types/blob/main/lib/dry/types/constructor/function.rb) 3 класса ошибок: `NoMethodError`, `TypeError`, `ArgumentError`. Поэтому, чтобы увидеть желаемую ошибку валидации, нужно использовать один из перечисленных:

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

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

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

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

```ruby
roman_to_int(nil)
# => NoMethodError: undefined method `reverse' for nil:NilClass
```

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

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

```ruby
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')
```