TECH

Trailblazer tutorial: refactoring fat controller - part 3.

blogpost

Before we move our business logic to Operation class we still need to handle:

  • Setting the speaker data,
  • Updating the bio of current user based on the speaker from proposal bio.

(Iteration 6) Describe handling speaker data by tests.

context "with open event" do
  let(:current_user) { FactoryBot.create(:user) }
  let!(:event) {
    FactoryBot.create(:event, state: 'open', slug: 'Lorem')
  }
  let!(:session_format) { SessionFormat.create(name: 'FooBar', event: event)}
  it 'creates a proposal assigned to event identified by slug' do
    expect(result).to be_truthy
    expect(result[:model].title).to eq('Foo')
    expect(result[:model].persisted?).to be_truthy
    expect(result[:model].event).to eq(event)
  end

  it 'assign current user as a speaker assigned to current event and filled with passed bio' do
    expect(result[:model].speakers[0].user).to eq(current_user)
    expect(result[:model].speakers[0].bio).to eq('my bio')
    expect(result[:model].speakers[0].event).to eq(event)
  end

  it 'update speaker bio from passed params' do
    expect(result[:model].speakers.first.bio).to eq("my bio")
  end
end

Since we have the whole expected behavior described by tests, we can investigate how current code works and figure out about what we want to re-use and what we want to avoid.

As we can see in controller #create,

@proposal = @event.proposals.new(proposal_params)
speaker = @proposal.speakers[0]
speaker.user_id = current_user.id
speaker.event_id = @event.id

our current code is using nested_attributes_for to handle initializing data for speakers. So part of "initializing speakers data" is handled by rails magic, and second part like assigning user and event is handled by the controller, which (as we already know) should be responsible for that.

Since our logic is more complex than just assigning nested data from the form, let's avoid using nested_attributes and try to avoid handling this kind of logic in the controller action altogether. We shall stick to the rules of SOLID and OOP and write as much explicit code as we can.

Also, we can investigate the whole codebase to check if we use

accepts_nested_attributes_for :speakers

from proposal.rb model anywhere else except #create action in our controller. Since it is used in proposals/_form.html.haml partial which is also used in edit action, we will not remove it from model yet as we don’t want to break the edit/update actions. But it would be great to comment out this code in the model to remember we need to remove it after refactoring the edit action.

#toDo remove when refactor #edit proposal 
accepts_nested_attributes_for :speakers

Let's focus on handling that data the trailblazer way. Trailblazer contracts (which are using reform under the hood) give us a really simple way to handle that. As we can see in the documentation, to handle nested resources inside the contract we can use collection, which enables us to define collection of associated resources we will initialize as we fill the contract with data.

collection :speakers, populate_if_empty: Speaker do
  property :bio
end

The above code will search for speakers key in params we received and use them to populate all speakers, passing bio attribute to each new instance of Speaker.

After we added this code, let's run all tests and check whether they fail or pass. And as we can see, the tests within "with open event", including the ones that had previously passed, all have failed.
What is the reason for that and how to debug it? The reason is quite obvious, but also quite hard to track before we understand how each step works. Now we can slow down a bit, and discuss each TRB Macro step:

  • initialize our proposal without filling it with data
step Model(Proposal, :new) 
  • that initialized contract will be used to validate data that we want to pass to our model through Contract rules. In this step, we already filled
step Contract::Build(constant: Proposal::Contract::Create) 

we can also call it by:

step :build_contract

def build_contract(ctx, model:, **)  
  ctx["result.contract.default"] = User::Contract::Create.new(model)  
end
  • that step redirects the flow to the Present operation. After finishing the flow returns back to the outer operation (Proposal::Create in our case)
step Nested(Present)
  • IMPORTANT: First it fills the model with data from params based on contract (and members/collection if there are some) and only then it checks if data in [:proposal] hash from params have proper values and format based on initialized Contract
step Contract::Validate(key: :proposal)

we can also call it by:

step :validate
def validate(ctx, params:, **)  
  ctx["result.contract.default"].validate(params)  
end
  • this step call .save method on our object
step Contract::Persist(method: :save) 

we can also call it by:

def persist(ctx, model:, **)  
  ctx["result.contract.default"].save  
end

Since our Proposal class is AR class, and we didn’t refactor it yet, we still have one of the biggest issues with standard MVC approach there - we have all validations in the model that doesn’t care about the context of where we try to save the object. Because of that if we call ctx[:model].errors after this step, we will receive:

step Contract::Persist(method: :save)
fail ->(ctx, **) { binding.pry; true}

pry(Proposal::Operation::Create)> ctx[:model].errors
=> #<ActiveModel::Errors:0x007ff9c50cfe38
@base=
#<Proposal:0x007ff9c2799de8
id: nil,
event_id: 1,
state: "submitted",
uuid: nil,
title: "Foo",
session_format_id: 1,
track_id: nil,
abstract: "Abstract bar",
details: "About proposal",
pitch: "Elevator",
last_change: nil,
confirmation_notes: nil,
proposal_data: {},
updated_by_speaker_at: nil,
confirmed_at: nil,
created_at: nil,
updated_at: nil>,
@details=
{:"speakers.event"=>[{:error=>:blank}], :"speakers.name"=>[{:error=>:blank}], :"speakers.email"=>[{:error=>:blank}, {:error=>:invalid, :value=>nil}]},
@messages={:"speakers.event"=>["can't be blank"], :"speakers.name"=>["can't be blank"], :"speakers.email"=>["can't be blank", "is invalid"]}>

Since we know about those validations, before we focus on refactoring Speaker model, we should check if other places are using those validations.

If we comment on all AR validation and run specs one will fail:

rspec ./spec/features/staff/speakers_spec.rb:101 
# Organizers can manage speakers for Program Sessions 
# An organizer Can't update a speaker with a bad email .

In this case lets comment validation, add ToDo comment that will indicate that we need to handle that validation for other actions & contexts in our application and go back to our current code.

class Speaker < ApplicationRecord
...
#ToDo: move all validations to proper contracts, that will distinguish which validation is necessary when 
# validates :event, presence: true
# validates :bio, length: {maximum: 500}
# validates :name, :email, presence: true, unless: :skip_name_email_validation
# validates_format_of :email, with: Devise.email_regexp

So let's move validation to contract, and handle validation errors from the contract in operation (like a gentleman, not a barbarian which uses one set of AR validations definition disregarding the context).

module Proposal::Contract
  class Create < Reform::Form
    property :event_id
    ...
    collection :speakers, populate_if_empty: Speaker do
      property :bio  
      property :event_id  
      property :user_id  

      validates :event_id, presence: true  
      validates :user_id, presence: true  
      validates :bio, length: {maximum: 500}  
    end
  end
end

Then let's update our Operation to handle validation errors,

  step  Contract::Validate(key: :proposal)
  fail :validation_failed, fail_fast: true

and run our "with open event" tests which will give us:

Failure/Error: expect(result).to be_success

expected `#<Trailblazer::Operation::Trace::Result(<Result:false #<Trailblazer::Context:0x007fcb32946498 @wrappe..., 
:errors=>{:"speakers.event_id"=>["can't be blank"],
:"speakers.user_id"=>["can't be blank"]}}> >)>.success?`
to return true, got false

Since we don’t pass event_id or user_id we shouldn’t be surprised. Starting with doing that, user_id will be taken from current_user, so we need to pass it to contract (http://trailblazer.to/gems/operation/2.0/contract.html#manual-build).

module Proposal::Contract
  class Create < Reform::Form
    property :title
    property :abstract
    property :details
    property :pitch
    property :session_format_id
    property :event_id
    property :current_user, virtual: true
    validates :current_user, presence: true

    collection :speakers, populate_if_empty: Speaker do
      property :bio
      property :event_id
      property :user_id

      validates :event_id, presence: true
      validates :user_id, presence: true
      validates :bio, length: {maximum: 500}
    end
  end
end

That is how we prepared our contract to receive current_user which is not a field of Proposal.

We shall focus on how to pass event_id (which we have in Contract object), and how to pass user_id ( taken from current_user) to initialized speaker. Since the contract is filled by data during building it, we are supposed to have an event initialized to have it in the contract. So we have to change the operation steps order*(in the sake of visibility, for now, we skip using Nested Present class)*.

module Proposal::Operation
  class Create < Trailblazer::Operation      
    step :event
    fail :event_not_found, fail_fast: true
    step Model(Proposal, :new)
    step :assign_event
    step Contract::Build(constant: Proposal::Contract::Create,
      builder: -> (ctx, constant:, model:, **){
        constant.new(model, current_user: ctx[:current_user])
      }
    )

    step :event_open?
    fail :event_not_open_error, fail_fast: true
    step Contract::Validate(key: :proposal)
    fail :validation_failed, fail_fast: true
    step Contract::Persist(method: :save)
    #our definitions ...    
  end  
end

As we can see we moved the event step to the beginning of the operation since event object will be useful during the next steps like building contract and eventually validating it. Since we have our event, the second issue was to pass current_user. We did by using builder which is an indication of how we will initialize Proposal::Contract::Create. “constant” there is just Proposal::Contract::Create (we could replace constant with Proposal::Contract::Create, but it will be longer and less readable). Thanks to using builder, we can not only pass model but also additional arguments, like current_user.

Operation initializes the contract with all the necessary data, so let's focus on how to initialize speakers based on passed data in our contract:

collection :speakers, populator: ->(model:, index:, **args) {    
  model.insert(index,
    Speaker.find_or_initialize_by(user_id: current_user.id, event_id: self.event_id) )
  } do
      property :bio  
      property :event_id  
      property :user_id  

      validates :event_id, presence: true  
      validates :user_id, presence: true  
      validates :bio, length: {maximum: 500}  
    end

We can check our tests:

1) Proposal::Operation::Create with valid params with open event
 update speaker user bio from passed params
Failure/Error: expect(result[:model].speakers[0].user.bio).to eq("my bio")

expected: "my bio"
got: "A great Bio"

(compared using ==)
#./spec/concepts/proposal/operation/create_spec.rb:48:in `block (4 levels) in <top (required)>'


Finished in 1.37 seconds (files took 7.3 seconds to load)
6 examples, 1 failure

This is not surprising since we didn’t implement updating user bio-based on a new speaker bio. All other tests are green, so that is cool, and we can focus on making the last one to pass. Updating user bio-based on bio from a saved speaker is business logic of the whole operation, so we just add another step:

  step Contract::Persist(method: :save)  
  step :update_user_bio  

And a step definition:

def update_user_bio(ctx, **)    
  ctx[:current_user].update(bio: ctx[:model].speakers[0][:bio])  
end

And that's it. The test is passing, everyone is happy. Of course, we should also test and handle all invalid scenarios, like validations in the user model, or errors during saving our contract - and I suggest you to try to implement it on your own.

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

Over the years I had to deal with applications and system that have a long history of already being "legacy".
On top of that I met with clients/product owners that never want you to spend time ref...

bloglist_item
Tech

How many times have you searched for that one specific library that meets your needs? How much time have you spent customizing it to fit your project's requirements? I must admit, waaay too much. T...