Skip to content

Instantly share code, notes, and snippets.

@mtortonesi
Created December 10, 2019 14:32
Show Gist options
  • Save mtortonesi/e9ea2639e0089004de3aa27a555352d9 to your computer and use it in GitHub Desktop.
Save mtortonesi/e9ea2639e0089004de3aa27a555352d9 to your computer and use it in GitHub Desktop.
Example of dry-validation and dry-transaction adoption within Rails 6
require_relative '../operations/create_actor'
require_relative '../operations/update_actor'
class ActorsController < ApplicationController
# GET /actors
# GET /actors.json
def index
# @actors = Actor.all
@actors = Actor.order(:name).page(params[:page])
end
# GET /actors/1
# GET /actors/1.json
def show
@actor = Actor.find(params[:id])
end
# GET /actors/new
def new
@actor = Actor.new
end
# GET /actors/1/edit
def edit
@actor = Actor.find(params[:id])
end
# POST /actors
# POST /actors.json
def create
# need to call .to_unsafe_h because dry-validation won't accept Rails's wacky params format
CreateActor.new.call(params.to_unsafe_h[:actor]) do |m|
m.success do |actor|
respond_to do |format|
format.html { redirect_to actor, notice: 'Actor was successfully created.' }
format.json { render :show, status: :created, location: actor }
end
end
m.failure do |errors|
@actor = Actor.new; @errors = errors
respond_to do |format|
format.html { render :new }
format.json { render json: @errors, status: :unprocessable_entity }
end
end
end
end
# PATCH/PUT /actors/1
# PATCH/PUT /actors/1.json
def update
# need to call .to_unsafe_h because dry-validation won't accept Rails's wacky params format
UpdateActor.new.call(params[:id], params.to_unsafe_h[:actor]) do |m|
m.success do |actor|
respond_to do |format|
format.html { redirect_to actor, notice: 'Actor was successfully created.' }
format.json { render :show, status: :ok, location: actor }
end
end
m.failure do |errors|
@actor = Actor.find(params[:id]);
@errors = errors
respond_to do |format|
format.html { render :new }
format.json { render json: @errors, status: :unprocessable_entity }
end
end
end
end
# DELETE /actors/1
# DELETE /actors/1.json
def destroy
# this action is very simple: no need to define a dedicated operation for it
Actor.destroy(params[:id])
respond_to do |format|
format.html { redirect_to actors_url, notice: 'Actor was successfully destroyed.' }
format.json { head :no_content }
end
end
end
require 'dry-transaction'
require_relative '../validators/actor_contract'
class CreateActor
include Dry::Transaction
step :validate
step :persist
private
def validate(input)
result = ActorContract.new.call(input)
if result.success?
Success(result.to_h)
else
Failure(result.errors(full: true))
end
end
def persist(input)
actor = Actor.new(input)
if actor.save
Success(actor)
else
Failure(OpenStruct.new(messages: [ "cannot persist actor" ]))
end
end
end
require 'dry-transaction'
require_relative '../validators/actor_contract'
class UpdateActor
include Dry::Transaction
step :validate
step :persist
private
def validate(input)
result = ActorContract.new.call(input[:actor])
if result.success?
Success(input)
else
Failure(result.errors(full: true))
end
end
def persist(input)
actor = Actor.find(input[:id])
if actor.update(input[:actor])
Success(actor)
else
Failure(OpenStruct.new(messages: [ "cannot persist actor" ]))
end
end
end
require 'dry-validation'
class ActorContract < Dry::Validation::Contract
params do
required(:name).filled(:string)
required(:dob).value(:date)
end
end
require 'test_helper'
class CreateActorTest < ActiveSupport::TestCase
def setup
@valid_input = { name: "Terence Hill", dob: "1939-03-29" }
@invalid_input = @valid_input.except(@valid_input.keys.sample)
end
test "it creates an actor in case of valid input" do
result = CreateActor.new.call(@valid_input)
assert result.success?
end
test "it should not create an actor given invalid input" do
result = CreateActor.new.call(@invalid_input)
assert result.failure?
end
test "it should return error messages in case of failure" do
result = CreateActor.new.call(@invalid_input)
assert result.failure.messages
end
end
require 'test_helper'
class UpdateActorTest < ActiveSupport::TestCase
def setup
@actor = actors(:one)
@valid_input = { name: "Terence Hill", dob: "1939-03-29" }
@invalid_input = @valid_input.except(@valid_input.keys.sample)
end
test "it updates an actor in case of valid input" do
result = UpdateActor.new.call(id: @actor.id, actor: @valid_input)
assert result.success?
end
test "it should not update an actor given invalid input" do
result = UpdateActor.new.call(id: @actor.id, actor: @invalid_input)
refute result.success?
end
test "it should return error messages in case of failure" do
result = UpdateActor.new.call(@invalid_input)
assert result.failure.messages
end
end
@solnic
Copy link

solnic commented Dec 10, 2019

hey, this looks good but you may want to tweak it to use dry-monad's Do notation instead of dry-transaction, because we're discontinuing its development. See more here dry-rb/dry-transaction#127

One small suggestion I'd have as well is to remove OpenStruct usage. Its behavior is broken because it responds to anything and returns nil even when there's no corresponding value. I believe simply returning a hash would be OK.

@mtortonesi
Copy link
Author

Dear @solnic,

thank you so very much for your kind suggestions. I refactored the code as follows.

However, note that by doing so I hit Issue 115, which was kind of hard to debug. (Rails is a complicated beast which, to use a kind euphemism, doesn't really go the extra mile to avoid breaking the principle of least surprise.) Anyway, I was able to get the code working by changing the single occurrence of EMPTY_ARRAY in dry-monads' do.rb to [].

app/operations/create_actor.rb:

require 'dry/monads'
require 'dry/monads/do'
require_relative '../validators/actor_contract'

class CreateActor
  include Dry::Monads[:result]
  include Dry::Monads::Do.for(:call)

  def call(params)
    valid_input = yield validate(params)
    create_actor(valid_input)
  end

  def validate(input)
    result = ActorContract.new.call(input)
    if result.success? 
      Success(result.to_h)
    else
      Failure(result.errors(full: true).messages)
    end
  end

  def create_actor(input)
    actor = Actor.new(input)
    if actor.save
      Success(actor)
    else
      Failure(actor.errors.full_messages)
    end
  end
end

app/operations/update_actor.rb:

require 'dry/monads'
require 'dry/monads/do'
require_relative '../validators/actor_contract'

class UpdateActor
  include Dry::Monads[:result]
  include Dry::Monads::Do.for(:call)

  def call(params)
    valid_input = yield validate(params)
    update_actor(valid_input)
  end

  def validate(input)
    result = ActorContract.new.call(input[:actor])
    if result.success? 
      Success(input)
    else
      Failure(result.errors(full: true).messages)
    end
  end

  def update_actor(input)
    actor = Actor.find(input[:id])
    if actor.update(input[:actor])
      Success(actor)
    else
      Failure(actor.errors.full_messages)
    end
  end
end

app/controllers/actors_controller.rb:

require_relative '../operations/create_actor'
require_relative '../operations/update_actor'


class ActorsController < ApplicationController
  # GET /actors
  # GET /actors.json
  def index
    # @actors = Actor.all
    @actors = Actor.order(:name).page(params[:page])
  end

  # GET /actors/1
  # GET /actors/1.json
  def show
    @actor = Actor.find(params[:id])
  end

  # GET /actors/new
  def new
    @actor = Actor.new
  end

  # GET /actors/1/edit
  def edit
    @actor = Actor.find(params[:id])
  end

  # POST /actors
  # POST /actors.json
  def create
    # need to call .to_unsafe_h because dry-validation won't accept Rails's wacky params format
    result = CreateActor.new.(params.to_unsafe_h[:actor])
    if result.success?
      actor = result.value!
      respond_to do |format|
        format.html { redirect_to actor, notice: 'Actor was successfully created.' }
        format.json { render :show, status: :created, location: actor }
      end
    else
      @actor = Actor.new
      @errors = result.failure
      respond_to do |format|
        format.html { render :new }
        format.json { render json: @errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /actors/1
  # PATCH/PUT /actors/1.json
  def update
    result = UpdateActor.new.(params.to_unsafe_h.symbolize_keys)
    if result.success?
      actor = result.value!
      respond_to do |format|
        format.html { redirect_to actor, notice: 'Actor was successfully updated.' }
        format.json { render :show, status: :ok, location: actor }
      end
    else
      @actor = Actor.find(params[:id]);
      @errors = result.failure
      respond_to do |format|
        format.html { render :new }
        format.json { render json: @errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /actors/1
  # DELETE /actors/1.json
  def destroy
    # questa è una action molto semplice: non è necessario definire un'operation per gestirla
    Actor.destroy(params[:id])

    respond_to do |format|
      format.html { redirect_to actors_url, notice: 'Actor was successfully destroyed.' }
      format.json { head :no_content }
    end
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment