Dry::Mutations

Build Status Code Climate


A link between dry-validation and mutations gems. This gem enables support for dry-validation schemas to be used within legacy mutations-based syntax.

Installation

Add this line to your application's Gemfile:

gem 'dry-mutations'

And then execute:

$ bundle

Or install it yourself as:

$ gem install dry-mutations

Was ⇒ Is

Was

class ComposedMutation < Mutations::Command
  ...
  def validate
    additional_validate(input1, input2)
    @nested = NestedMutation.new(inputs, input1: input1, input2: input2)
    unless @nested.validation_outcome.success?
      @nested.validation_outcome.errors.each do |key, error|
        add_error(key.to_sym, error.symbolic, error.message)
      end
    end
  end

  def execute
    @nested.run!
  end
end

Is

class ComposedValidation < Mutations::Command
  prepend ::Dry::Mutations::Extensions::Command
  prepend ::Dry::Mutations::Extensions::Sieve

  ...
  def validate
    additional_validate(input1, input2)
  end
end

class ComposedTransform < Mutations::Command
  prepend ::Dry::Mutations::Extensions::Command

  ...
  def execute
    inputs.merge(input1: input1, input2: input2)
  end
end

class ComposedMutation
  extend ::Dry::Mutations::Transactions::DSL
  chain do
    validate ComposedValidation
    transform ComposedTransform
    mutate NestedMutation
  end
end

Call syntax

Basically, any call syntax is supported:

# preferred
ComposedMutation.(input)          # returns (Either ∨ Outcome) object

# legacy
ComposedMutation.run(input)       # returns (Either ∨ Outcome) object
ComposedMutation.new(input).run   # returns (Either ∨ Outcome) object
ComposedMutation.run!(input)      # throws Mutation::ValidationException
ComposedMutation.new(input).run!  # throws Mutation::ValidationException

Usage

Enable extensions for the specific mutation’s command

Prepend a ::Dry::Mutations::Extensions::Command module to your Mutation::Command instance:

class MyMutation < Mutations::Command
  prepend ::Dry::Mutations::Extensions::Command

  required do
    model :company, class: 'Profile'
    model :user
    hash  :maturity_set do
      string :maturity_choice, in: %w(spot forward_days fixed_date)
      optional do
        hash :maturity_days_set do
          integer :days, default: 3 # For spot or forward_days options
        end
        hash :maturity_date_set do
          date :date # When passing a fixed date
        end
      end
    end
    ...

dry-validation syntax

It is possible to mix standard mutations’ syntax with dry-rb schemas:

class MyMutation < Mutations::Command
  prepend ::Dry::Mutations::Extensions::Command

  required do
    model :company, class: 'Profile'
  end

  schema do
    required(:maturity_choice).filled(:str?, included_in?: %w(spot forward_days fixed_date))
  end

Reusing schema

Basically, everything written here is applicable. Syntax to include the nested schema is as simple as:

UserSchema = Dry::Validation.Schema do
  required(:email).filled(:str?)
  required(:name).filled(:str?)
  required(:address).schema(AddressSchema)
end

or, in legacy mutations syntax (NB! Starting with 0.99.9!):

required do
  string :name
  schema :address, AddressSchema
  string :email
end

ActiveRecord::Relation support

schema(Dry::Mutations.Schema do
  required(:slaves).filled(relation?: Slave)
end)

Combining dry schemas with mutation-like syntax

Since version 0.99.9, one might pass the Dry::Validation::Schema directly to legacy mutations syntax:

required do
  model :user
  schema :address, AddressSchema # AddressSchema = ::Dry::Validation.Schema {}
  date: Date.today
end

Since version 0.11.1, one might pass the instance of Dry::Validation::Schema and/or Dry::Validation::Form instance to schema mutation DSL.

Such a block might be only one, and it must be the first DSL in the mutation. NB this is not a preferred way to do things, but it might be useful to share schemas (unlikely the above, this will embed the schema, rather than nest it.)

Correct

Class.new(::Mutations::Command) do
  prepend ::Dry::Mutations::Extensions::Command
  prepend ::Dry::Mutations::Extensions::Sieve

  schema(::Dry::Validation.Form do
    required(:integer_value).filled(:int?, gt?: 0)
    required(:date_value).filled(:date?)
    required(:bool_value).filled(:bool?)
  end)

  required do
    integer :forty_two
    string :hello
  end
end

Incorrect

Class.new(::Mutations::Command) do
  prepend ::Dry::Mutations::Extensions::Command
  prepend ::Dry::Mutations::Extensions::Sieve

  required do
    integer :forty_two
    string :hello
  end

  schema(::Dry::Validation.Form do
    required(:integer_value).filled(:int?, gt?: 0)
    required(:date_value).filled(:date?)
    required(:bool_value).filled(:bool?)
  end)
end

Declare the resulting type of a schema

Using the approach above, one might start with a schema type declaration:

# this line must be a first declaration
schema(::Dry::Validation.Form {})

# now continue with generic `schema {}` blocks to append features:
schema do
  required(:integer_value).filled(:int?, gt?: 0)
  required(:date_value).filled(:date?)
  required(:bool_value).filled(:bool?)
end

see schema_spec.rb for an inspiration.

Subschema’s type

Startign with 0.99.100 we accept type: parameter in call to schema:

schema type: :form do
  ...
end

Dealing with outcome

Command

let!(:command) do
  Class.new(::Mutations::Command) do
    prepend ::Dry::Mutations::Extensions::Command

    required { string :name, max_length: 5 }
    schema { required(:amount).filled(:int?, gt?: 0) }

    def execute
      @inputs
    end
  end
end

Using Either monad

outcome = command.new(name: 'John', amount: 42).run
outcome.right?
#⇒ true
outcome.either.value
#⇒ { 'name' => 'John', 'amount' => 42 }

outcome = command.new(name: 'John Donne', amount: -500).run
outcome.right?
#⇒ false
outcome.left?
#⇒ true
outcome.either
#⇒ Left({
#   "name"=>#<Dry::Mutations::Errors::ErrorAtom:0x00000003b4e7b0
#               @key="name",
#               @symbol=:max_length,
#               @message="size cannot be greater than 5",
#               @index=0,
#               @dry_message=#<Dry::Validation::Message
#                               predicate=:max_size?
#                               path=[:name]
#                               text="size cannot be greater than 5"
#                               options={:args=>[5], :rule=>:name, :each=>false}>>,
#   "amount"=>#<Dry::Mutations::Errors::ErrorAtom:0x00000003b4e508
#               @key="amount",
#               @symbol=:gt?,
#               @message="must be greater than 0",
#               @index=1,
#               @dry_message=#<Dry::Validation::Message
#                               predicate=:gt?
#                               path=[:amount]
#                               text="must be greater than 0"
#                               options={:args=>[0], :rule=>:amount, :each=>false}>>
# })
outcome.either.value
#⇒ the hash ⇑ above

Using Matcher

expect(outcome.match { |m| m.success(&:keys) }).to match_array(%w(amount name))
expect(outcome.match { |m| m.failure(&:keys) }).to be_nil

Turn On Globally (use with caution!)

ENV['GLOBAL_DRY_MUTATIONS'] = 'true' && rake

That way all mutations all over the system will be patched/injected with new functionality. This is untested in all possible environments.

Bug reports are very welcome!

Changelog

0.99.1

Support for direct input parameters invocation. 100%-compatibility with mutations:

def validate # input ≡ { date: nil }
  date < Date.now
end

1.1.0

More handy chains, better dry-rb integration, improvements.

0.99.0

Support for default: guard. 99%-compatibility with mutations

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dry-mutations. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.