Dry::Transaction::Extra

Dry::Transaction comes with a limited set of steps. This gem defines a few more steps that are useful for getting the most out of Transactions.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add dry-transaction-extra

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install dry-transaction-extra

Usage

By requiring the gem, you get a few additional Step adapters registered with dry-transaction, and can begin using them immediately.

require "dry-transaction-extra"

Additional Steps

Dry::Transaction::Extra defines a few extra steps you can use:

  • merge -- Merges the output of the step with the input args. Best used with keyword arguments.
  • tap -- Similar to Ruby Kernel#tap, discards the return value of the step and returns the original input. If the step fails, then returns the Failure instead.
  • valid -- Runs a Dry::Schema or Dry::Validation::Contract on the input, and transforms the validation Result to a Result monad.
  • use -- Invokes another transaction (or any other callable), and merges the result.
  • maybe -- Optionally invokes another transaction by first attempting to invoke the validator. If the validation fails, it continues to the next step without failing.

merge

If you're using keyword args as the arguments to your steps, you often want a step to add its output to those args, while keeping the original kwargs intact.

  • If the output of the step is a Hash, then that hash is merged into the input.
  • If the output of the step is not a Hash, then a key is inferred from the step name. The name of the key can be overridden with the as: option.
Merging Hash output
merge :add_context

# Input: { user: #<User id:42>, account: #<Account id:1> }
def add_context(user:, **)
  {
    email: user.email,
    token: UserToken.lookup(user)
  }
end
# Output: { user: #<User id:42>, account: #<Account id:1>, email: "[email protected]", token: "1234" }
Merging non-Hash output, inferring the key from the step name
merge :user

# Input: { id: 42 }
def user(id:, **)
  User.find(id)
end
# Output: { id: 42, user: #<User id:42> }
Merging non-Hash output, specifying the key explicitly
merge :find_user, as: :current_user

# Input: { id: 42 }
def find_user(id:, **)
  User.find(id)
end
# Output: { id: 42, current_user: #<User id:42> }

tap

A step that mimics Ruby's builtin Kernel#tap method. If the step succeeds, the step output is ignored and the original input is returned. However, if the step fails, then that Failure is returned instead.

tap :track_user
map :next_step

def track_user(user)
  response = Tracker.track(user_id: user.email)
  return Failure(response.body) if response.status >= 400
end

def next_step(user)
  # Normally, the return value if the previous step would be passed
  # as the input to this step. In this case, we don't care, we want
  # to keep going with the original input `user`.
end

use

Invokes another Transaction (or anything else #call-able), and merges the result. It can also lookup the item to invoke in a container, which allows it to be changed at runtime, or for tests.

The output of the invoked item is merged with the input, following the same rules as the [merge][#merge] step.

This also works well in conjunction with the [Class Callable][#class-callable] extension.

use FindUser
use AppContainer, :find_user
use ->(id:, **) { User.find(id) }, as: "user"

Note: The Container-lookup form of this is functionally equivalent to the built-in Dry Container Dependency Inject that is a part of Dry-Transaction (but lacking the merge semantics. However, you may find this method to be more readable, particularly when combined with other step adapters with a similar structure.

class CreateUser
  step :validate, with: "validate"
  step :create, with: "create"

  # vs

  use UserContainer, "validate"
  use UserContainer, "create"
end

valid

Runs a Dry::Schema or Dry::Validation::Contract, either passed to the step directly, or returned from the step method. It runs the validator on the input arguments, and returns Success on the validator output, or the Failure with errors returned from the validator.

valid :validate_params

def validate_params(params)
  Dry::Schema.Params do
    required(:name).filled(:string)
    required(:email).filled(:string)
  end
end

This is essentially equivalent to:

step :validate_params

def validate_params(params)
  Dry::Schema.Params do
    required(:name).filled(:string)
    required(:email).filled(:string)
  end.call(params).to_monad
end

You can also define the Schema/Contract elsewhere if you want to reuse it, and invoke it:

valid ParamsValidator

maybe

Maybe combines the use step with the Validation extension. Before attempting to run the provided transaction, it first runs its defined validator. If that validation passes, then it invokes the transaction. If the validation fails, however, then the transaction continues on, silently ignoring the failure. This is useful in several scenarios, like not running if there's insufficient data, or they've already been run.

For example, when creating a user, if they provided an email address, we want to send a verification email. If they didn't provide an email, or we've already verified it, then we can skip that part. Given a VerifyEmail transaction with a validation block that requires an email address, and does something to check if we've already verified it, we can use a maybe to invoke it. If that validator fails, then we can ignore it and create the user anyway.

class VerifyEmail
  include Dry::Transaction
  include Dry::Transaction::Extra
  load_extensions :validation, :class_callable

  validate do
    params do 
      required(:email).filled(type?: EmailAddress)
    end

    rule(:email) do
      !EmailValidation.exists?(value)
    end
  end

  step :send_verification_email
end

class CreateUser
  include Dry::Transaction
  include Dry::Transaction::Extra
  load_extensions :validation

  validate do
    params do 
      optional(:email).filled(:string)
    end
  end

  maybe VerifyEmail
  step :create_user

Extensions

Validation

In addition to the valid step adapter, Dry::Transaction::Extra has support for an explicit "pre-flight" validation that runs as the first step.

class CreateUser
  include Dry::Transaction
  include Dry::Transaction::Extra
  load_extensions :validation

  validate do
    params do
      required(:name).filled(:string)
      optional(:email).maybe(:string)
    end
  end

  step :create_user
end

This is useful if you want to, for example, run the transaction as an async background job, but want to first verify the arguments to the job before enqueueing it. If the job is going to fail anyway, why bother creating it in the first place?

result = CreateUser.validator.new.call(params)
CreateUserJob.perform_async(params) unless result.failure?

Class Callable

This is a nice shorthand to initialize and call a Transaction in a single method. If you don't need to pass any arguments to the initializer, then you can #call it directly on the class:

MyTransaction.new.call(args)
MyTransaction.call(args)

This is particularly useful when invoking transactions via the use and maybe steps:

use   MyTransaction
maybe MyOptionalTransaction

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 the created tag, and push the .gem file to rubygems.org.

Contributing

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

License

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

Code of Conduct

Everyone interacting in the Dry::Transaction::Extra project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.