dry-rb 1.0: upgrading validations, types and schemas

I’m enthusiastic about dry-rb gems. Actually, I’ve never worked on Ruby projects without a dry-rb gem. However, some people are sceptical, as a lot of core dry-rb gems are still in their 0.x phase, which leads to a lot of breaking changes and hours of refactoring.

I’m happy to see dry-rb mature: dry-monads entered 1.0 phase in Summer 2018, and now two more libraries hit v1.0 milestones: dry-types and dry-struct; and dry-validation is in its 1.0 RC phase.

I haven’t updated my dry-rb gems for a couple of months, so I’ve missed a lot of breaking changes. Finally, I decided to upgrade the gems and write about the process. I’ll take a swing at automating my upgrade process as much as I can.

Prerequisites

Here’s what my dry-rb gems look like:

$ bundle list | grep dry
  * dry-auto_inject (0.4.6)
  * dry-configurable (0.7.0)
  * dry-container (0.6.0)
  * dry-core (0.4.7)
  * dry-equalizer (0.2.1)
  * dry-events (0.1.0)
  * dry-inflector (0.1.2)
  * dry-initializer (2.5.0)
  * dry-logic (0.4.2)
  * dry-matcher (0.7.0)
  * dry-monads (1.2.0)
  * dry-struct (0.6.0)
  * dry-transaction (0.13.0)
  * dry-types (0.13.2)
  * dry-validation (0.12.1)

I’ve got 15 gems, but I only care about four of them: monads, types, struct and validation. Since monads are up-to-date, I’m only going to talk about types, struct and validation.

In this post, I’ll try to give a step-by-step guide that will simplify the upgrading process. It won’t give a 100% working solutions, but it will probably save you a couple of hours.

Note. I use macOS with GNU sed (gsed) instead of built-in sed command. So if you want to follow my instructions, install it via brew install gnu-sed. Since I’m using fish instead of bash / zsh, some commands might need slight modifications to work.

Note. I wrote this article while upgrading the dry-rb gems on my project. I decided to do it gradually — so you might encounter some redundant steps. If you do, please contact me via email and I’ll upgrade it.

dry-validation to dry-schema

The gem we knew as dry-validation has evolved from a complex schema validation & coercion into a high-level contract DSL with domain logic.

Meanwhile, it has become so complex they decided to break it down into two gems: dry-validation and dry-schema. The latter provides the old functionality of dry-validation — the schema validations, coercions, and they fixed all known issues. dry-validation adds domain rules and validations on top of that.

I don’t want to go around and update everything manually, so I’m going to replace dry-validation with dry-schema as much as I can, and manually refactor the rest.

Step 1. Upgrade dry-validation to 0.13. It’s the last version before the switch, so if your builds pass — you’re good to go. You’ll have to update dry-types to 0.14 too.

Step 2. Replace dry-validation with equivalent dry-schema version (0.1.0) and replace all Dry::Validation occurrences with Dry::Schema. Also replace all Dry::Validation.Schema with Dry::Validation.define.

$ bundle remove dry-validation && bundle add dry-schema --version 0.1.0`
$ grep -rl 'Dry::Validation' ./**/*.rb | xargs gsed -i 's/Dry::Validation/Dry::Schema/g'
$ grep -rl 'Dry::Schema.Schema' ./**/*.rb | xargs gsed -i 's/Dry::Schema.Schema/Dry::Schema.define/g'

If you’ve used struct extension, don’t forget to search for Dry::Schema.load_extensions and remove :struct from the list.

Step 3. Replace .each(&:type?) predicates with .each(:type?). The same goes for maybe, filled and value. You might get ArgumentError: no receiver given if you don’t.

$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\.\(filled\|value\|each\|maybe\)(&/.\1(/g'

Step 4. Refactor schemas that use arrays as input.

The feature has been removed and it’s not coming back until dry-schema 1.0. Here’s an issue with the feature.

The refactoring will look like this:

# Before

ItemSchema = Dry::Schema.Params do
  each do
    schema do
      required(item_id).filled(:int?)
      required(option_ids).each(:int?)
    end
  end
end

ItemSchema.call(input)

# After

ItemSchema = Dry::Schema.Params do
  required(:input).each do
    schema do
      required(:item_id).filled(:int?)
      required(:option_ids).each(:int?)
    end
  end
end


ItemSchema.call(input: input)

Step 5. Check you’ve ever inherited from Dry::Validation schemas. If you did, do the following transformations:

  1. Rename classes
  • Dry::Validation::Schema::ParamsDry::Schema::Params
  • Dry::Validation::Schema::JSONDry::Schema::JSON
  • Dry::Validation::SchemaDry::Schema
  1. Replace define! block with define
  2. Move configure block under define

# Before:

class ApplicationSchema < Dry::Validation::Schema::Params
  configure do
    config.messages = :i18n
  end
end

# After:

class ApplicationSchema < Dry::Schema::Params
  define do
    config.messages = :i18n
  end
end

And update its subclasses:

# Before

class MySchema < ApplicationSchema
  configure do
    config.messages = :yaml
  end

  define! do
    ...
    # your params go here
  end
end

# After

class MySchema < ApplicationSchema
  define do
    config.messages = :yaml

    ...
    # your params go here
  end
end

Step 6. Update DSL inheritance.

Replace Dry::Schema.Params(BaseClass) with Dry::Schema.Params(parent: BaseClass).

$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/Dry::Schema\(\(::\)\|\.\)\(Params\|JSON\|Schema\)(\([[:alnum:]]*\))/Dry::Schema\1\3(parent: \4)/g'

Before you proceed Skip steps 7 and 8 if you’ve never used type specs API.

Step 7. Remove config.type_specs from your schemas

$ grep -rl 'config.type_specs' ./**/*.rb | xargs gsed -i '/config\.type_specs/d'

Step 8. Remove type spec usages from required and optional.

$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\(required\|optional\)(\(:[[:alnum:]_]*\), [[:print:]]*)\(\.\|$\)/\1(\2)\3/g'

Updating dry-schema to 0.3

Step 9. Update your gemfile to specify gem 'dry-schema', '~> 0.3.0' and run bundle install

Step 10. If you’re using I18n, move errors under dry_schema namespace. This way,

en:
  errors:
    array?: must be an array

will turn into

en:
  dry_schema:
    errors:
      array?: must be an array

Step 11. Find any schema macro usages and replace them with hash, as schema no longer prepends value(:hash?) check.

# Before

required(:foo).schema do
end

# After

required(:foo).hash do
end
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/schema \(do\|{\)/hash \1/g'

Step 12. Find any each macro usages and replace them with array to add type check. Since Ruby has a Enumerable#each function, we can’t automate it, but we can still find possible occurrences:

$ grep -rl 'Schema' ./**/*.rb | xargs grep -n 'each \(do\|{\)'

Feel free to skip if you feel like you don’t need type checks.

Step 13. Load hints extension if you use monads or .messages.

Dry::Schema.load_extensions(:hints)

The leap towards 1.0.0

Step 14. Update dry-struct, dry-types and dry-schema and run bundle install.

gem 'dry-schema', '~> 1.1.0'
gem 'dry-struct', '~> 1.0.0'
gem 'dry-types', '~> 1.0.0'

Step 15. Replace Dry::Types.module with Dry.Types(default: :nominal)

If you’ve never used nominal types (i.e. Types::Hash, Types::Integer), feel free to use Dry.Types instead.

$ gsed -i 's/Dry::Types\.module/Dry.Types(default: :nominal)/g' ./**/*.rb

Step 16. Replace legacy hash schemas with new ones. See https://dry-rb.org/gems/dry-types/0.15/hash-schemas/

Step 17. Update error message config

  1. Replace config.messages with config.messages.backend
  2. Replace config.messages_file = '/path/to/my/errors.yml' with config.messages.load_paths << '/path/to/my/errors.yml'
  3. Replace config.namespace = :user with config.messages.namespace = :user
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/config\.messages =/config.messages.backend =/g'
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/config\.messages_file =/config.messages.load_paths <</g'
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/config\.namespace =/config.messages.namespace =/g'

Step 18. Symbolize all string keys

$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\(required\|optional\)(\(\'\|"\)\([[:alnum:]_]*\)\(\'\|"\)/\1(:\3/g'

Step 19. Replace Types.Definition with Types.Nominal

$ gsed -i 's/Types\.Definition/Types.Nominal/' ./**/*.rb

Step 20. If you rely on Types::Params and Types::JSON not to raise an exception on invalid input, decorate the definitions with .lax

$ gsed -i 's/Types::JSON::\([[:alnum:]]*\)/Types::JSON::\1.lax/g' ./**/*.rb
$ gsed -i 's/Types::Params::\([[:alnum:]]*\)/Types::Params::\1.lax/g' ./**/*.rb

Step 21. Replace :type? predicates with type checks wherever you need this

$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\(filled\|maybe\|value\)(:str?/\1(:string/g'
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\(filled\|maybe\|value\)(:int?/\1(:integer/g'
$ grep -rl 'Schema' ./**/*.rb | xargs gsed -i 's/\(filled\|maybe\|value\)(:date?/\1(:date/g'

Step 22. Result#{messages, errors, hints} now return MessageSet, which can be converted to Hash. So we need to go and update the usages everywhere. Also Result#to_monad now wraps entire Result object, so we have to update our code.

# Before

render errors: Schema.call(params).errors
render errors: Schema.call(params).to_monad.failure

# After

render errors: Schema.call(params).errors.to_h
render errors: Schema.call(params).to_monad.failure.errors.to_h

I’ve used the scripts to help me look and trace those values:

$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.messages'
$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.errors'
$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.to_monad'
$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.failure'
$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.value_or'
$ grep -rl 'Schema' ./**/*.rb | xargs grep -n '\.value!'

Refactoring to dry-validation

The steps above should be good enough to update most of the features, but if you ‘ve ever used high-level rules, validation blocks, you have two options: either remove those features from your schemas, or use dry-validation 1.0. I decided to refactor most of my schemas, that’s what came out of it.

There are things to keep in mind during the update:

  • dry-validation is a library to validate domain logic and rules. The core concept is a Contract.
  • All contracts must be instantiated — no more Schema.call. We need to use Contract.new.call now
  • The idiomatic way to define a contract is to use standard Ruby syntax: class Contract < Dry::Validation::Contract as opposed to dry-schema’s Dry::Schema.Params { }

Step 23. Update dependency injection. The new version uses dry-initializer under the hood, so it works like this:

  • use option for keyword arguments
  • use param for positional arguments

You’ll have to pass the arguments when you instantiate the method

# Before

Schema = Dry::Validation.Schema do
  configure do
    option :repo
  end

  ...
end

Schema.with(repo: my_repo).call(params)

# After

class Contract < Dry::Validation::Contract
  option :repo
end

contract = Contract.new(repo: repo)
contract.call(params)

I used the script to find the files I need to refactor:

$ grep -rl 'Schema' ./**/*.rb | xargs grep -n 'option :[[:alnum:]_]*$'

Step 24. Rewrite rules and validations. I can’t provide a comprehensive migration guide because I’ve just refactored everything and tried to make my specs pass without giving it much thought.

# Before


class CreditCardSchema < Dry::Validation::Schema::Params
  configure do
    config.type_specs = true
  end

  define! do
    required(:number, :string).filled(format?: /\A\d{13,19}\z/)
    required(:month, :string).filled(format?: /\A(0?[1-9]|1[012])\z/)

    validate(expired: %i[year month]) do |year, month|
      Date.new("20#{year}".to_i, month.to_i).end_of_month >= Date.current
    end
  end
end

# After
class CreditCardSchema < Dry::Validation::Contract
  params do
    required(:month).filled(:string, format?: /\A(0?[1-9]|1[012])\z/)
    required(:year).filled(:string, format?: /\A\d{2}\z/)
  end

  rule(:year, :month) do
    year = values[:year]
    month = values[:month]

    if Date.new("20#{year}".to_i, month.to_i).end_of_month < Date.now
      key(:expired).failure(:expired)
      # ^ a little duplication here to produce the expected error message
      # without refactoring anything else
    end
  end
end

I used this script to search for all schemas that need rewriting:

$ grep -rl 'Schema' ./**/*.rb | xargs grep -n 'rule\|validate('

Step 25 (optional). If you’re using Reform, you’re in for a disappointment, especially if you’ve been using its dry-validation DSL.

We have Reform 2.2.4 with ActiveModel validations, so we forked it and removed all the dry-validation stuff. Feel free to fork and use!

Step 26. Fix the rest of failing specs. All done!

Recap

The upgrade process took me about 3 work days of refactoring, and I was glad I learned basic sed to help me — it’s annoying to do so much manual work.

However, I think the improvements are worth it. The ones I like the most:

  • dry-types is stricter and less verbose now — if you’re not including nominal types, then Types::String is the same as Types::Strict::String
  • The known dry-validation bugs were fixed
  • Decreased complexity of schema validations
  • New library to design domain validations and contracts

I urge you to try the new dry-rb gems — and write about your experience. If you’ve upgraded your gems and wrote a post about your journey and update process — please send me an email and I’ll add a link to your page. And of course, it would be great to see new contributions to official docs.

References

Update (01.06.2019). flash-gordon pointed out that you don’t need to wrap config into the configure block. So I’ve replaced

define do
  configure do
    config.xxx = yyy
  end
end

with a less nested version:


define do
  config.xxx = yyy
end

Update (07.06.2019). solnic pointed out that I made a typo in Step 10: it used to say dry_struct instead of dry_schema. I’ve updated the step accordingly.

Related reading

  • Do Notation in Ruby: Railway Oriented Programming

    This post showcases one of the then-newest dry-monads' feature: the Do notation. Akin to Haskell's Do notation, it allows us to write imperative code instead of chaining binds and ors. The article illustrates the idea of Railway-Oriented programming and the practical application of Do in Ruby.

  • Monad laws in Ruby

    A little article taking a look into monad laws through the prism of Ruby and dry-monads. Those laws might be complex from the first glance, but they actually describe some pretty useful behaviors in those objects. The article reads into the definitions, explains those, and illustrates them from a raw programming perspective.