OPERATIONS

Liubov Derevianko, MobiDev

Trailblazer Stack Diagram

How that looks without Trailblazer

def create
  @project = current_user.projects.new(project_params)
  if @project.save
    UserMailer.after_create_project_email(@project, current_user).deliver_later
    redirect_to @project
  else
    render 'new'
  end
end
def create
  run Project::Create, params: params['project'].merge(current_user) do |op|
      redirect_to @project
  end
  render 'new'
end

How that looks with Trailblazer

Example Operation

class Project < ApplicationRecord
  class Create < Trailblazer::Operation
    include Model
    model Project, :create

    contract do
      property   :user, virtual: true
      property   :title
      property   :budget
      property   :status
      property   :measurement_unit

      validates :title, presence:  true
    end

    def process(params)
      validate(params[:project]) do |f|
        f.save
      end
    end
  end
end

How to call operations?

Run Style

res, operation = Comment::Create.run(
    comment: { 
                  body: "MVC is so 90s."     
              })

Returns a result set [result, operation] for both valid and invalid invocation.

How to call operations?

Run Style

operation = Comment::Create.run(comment: {}) do |op|

  return "Hey, #{op.model} was created!"  # not run.

end

puts "That's wrong: #{operation.errors}"

It also can accepts a block that’s run in case of a successful validation.

Only the operation instance is returned as the block represents a valid state.

Shorthand methods to run and present operations.

Use #present if you’re only interested in presenting the operation’s model.

 

Use #form to render the form object. This will not run the operation.

 

Use #run to process incoming data using the operation (and present it afterwards).

 

Use #respond to process incoming data and present it using Rails’ respond_to.

RUN

Invoke the operation

class CommentsController < ApplicationController
  def create
    run Comment::Create, params: params['comment']
  end
end

- runs the operation with params

- sets @operation, @model and @form

- returns the operation instance.

 

RUN

An optional block  is invoked only when the operation was valid.

class CommentsController < ApplicationController
  def create
    run Comment::Create do |op|
      flash[:notice] = "Success!"
    end
  end
end

FORM

Render the form object. This will not run the operation.

class CommentsController < ApplicationController
  def new
    form Comment::Create
  end
end

- instantiates the operation with params

- sets @operation, @model and @form

- does not run #process.

- returns the form instance.

Custom Params

If you want to manually hand in parameters to #run, #respond, #form or #present, use the params:option.

def create
  run Comment::Create, params: {
        comment: {
            body: "Always the same! Boring!"
                }
    }
end

def create
  run Comment::Create, params: params['comment'].merge(user: current_user) do |op|
    .....
  end
end

Operations Callstack

Here’s the default call stack of methods involved when running an Operation.

::call
├── ::build_operation

│   ├── #initialize

│   │   ├── #setup!

│   │   │   ├── #assign_params!

│   │   │   │   │   ├── #params! #Override if you want to use a different params hash.

│   │   │   ├── #setup_params! #Override to normalize the incoming params.

│   │   │   ├── #build_model!

│   │   │   │   ├── #assign_model!

│   │   │   │   │   ├── #model! #Override to compute the operation’s model.

│   │   │   │   ├── #setup_model! #Override for adding nested models to your model.

│   ├── #run

│   │   ├── #process  # Implement for your business logic.

Contract

An operations lets you define a form object, or contract.

Internally, this simply creates a Reform form class.

Contract is like a schema of the data structure.

class Create < Trailblazer::Operation
    contract do
      property   :user, virtual: true
      property   :title
      property   :budget
      property   :status
      property   :measurement_unit

      validates :title, presence:  true
    end

    def process(params)
        # ..
    end
end

Contract

Check out the #process method now.

class Create < Trailblazer::Operation

    contract do
	# ..
    end

    def process(params)
        @model = Comment.new
        validate(params[:comment]) do |f|
	    # process the data here
	end
    end
end

You don't need strong_parameters using operations and forms. The #validate method knows which parameters of the hash go into the form and what is to be ignored.

Contract Property

class Create < Trailblazer::Operation

    contract do
	property :password
        property :password_confirmation, virtual: true
        property :country, writeable: false
        property :credit_card_number, readable: false
        property :current_status, default: 'unknown'
    end

end

 Validation

class Create < Trailblazer::Operation

    contract do
	property :host
        property :port
        property :time_interval
        property :slack_for_notifications
        property :emails_for_notifications

        validates :host, :port, :time_interval, presence: true
        validates :time_interval,
  	      numericality: {
                  only_integer: true,
                  greater_than_or_equal_to: MIN_PING_INTERVAL,
                  less_than_or_equal_to: MAX_PING_INTERVAL
              }
        
    end

end

Likewise, since Reform uses ActiveModel::Validations , you can use all kinds of standard validations like length or inclusion.

 Validation

class Create < Trailblazer::Operation

    contract do
        property :slack_url

        validates :slack_url,
              url: true,
              format: {
                  with: /^https:\/\/hooks\.slack\.com\/services/,
                  multiline: true,
                  message: '- Webhook url is invalid'
              },
              allow_blank: true
        
    end

end

You can specify custom message for error in format block or allow this field to be blank

 Validation

class Create < Trailblazer::Operation

    contract do
        property :emails_for_notifications
        validates :emails_for_notifications, email: true
    end
end

Custom validators are standart

ProjectName/app/validators/email_validator.rb

class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    value.each do |current_value|
      unless current_value =~ /\A([\w+\-]\.?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\Z/i
        record.errors[attribute] << (options[:message] || 'wrong email address')
      end
    end
  end
end

Inheritance Over Configuration

We have a contract in Create operation

class Project < ApplicationRecord
  class Create < Trailblazer::Operation
    include Model
    model Project, :create

    contract do
      property   :user, virtual: true
      property   :title
      property   :measurement_unit
      collection :real_costs, default: []
   end
  end
end

Inheritance Over Configuration

Subclassed operations will inherit behavior, the contract and representer.

class Project < ApplicationRecord
  class Update < Create
    model Project, :update

    contract do
      property :measurement_unit, writeable: false
    end
end

After inheriting you’re free to change, override or rewrite contracts, business logic and representers.

Processing Data

In case of a successful validation the logic in the block gets executed.

def process(params)
    @model = Comment.new
    validate(params[:comment]) do |f|
        f.save
        send_email
    end
end

- The #validate method yields the contract instance to the block.

- Push changed values from the form to the Comment model

- Afterwards it calls #save on the model itself

Processing Data

If we have only CRUD operation there is no need to assign model in process action

class Project < ApplicationRecord
  class Create < Trailblazer::Operation
    include Model
    model Project, :find
  end
end

By including Model and setting the :find action, the operation will take care of retrieving the correct model.

:create calls Project.new

:update or :find will execute Project.find(params[:id])

Processing Data

If we have only NON-CRUD operation we can assign model before the process action

class ProjectUser < ApplicationRecord
  class AddUserToProject < Trailblazer::Operation
      contract do
          property :user, virtual: true
          property :project, virtual: true
      end

      def model!(params)
         params[:project]
      end

      def process(params)
         .....
      end
   end
end

Processing Data

Marking Operation as Invalid

class ProjectUser < ApplicationRecord
  class AddUserToProject < Trailblazer::Operation
      contract do
          property :user, virtual: true
          property :project, virtual: true
      end

      def model!(params)
         params[:project]
      end

      def process(params)
          invalid! if model.users.include?(params[:user])
      end
   end
end

Wrapping Operations

class ProjectUser < ApplicationRecord
  class AddUserToProject < Trailblazer::Operation
      contract do
          property :user, virtual: true
          property :project, virtual: true
      end

      def model!(params)
         params[:project]
      end

      def process(params)
          invalid! if model.users.include?(params[:user])
      end
   end
end

Wrapping Operations

class ProjectsUser < ApplicationRecord
  class AddUserToManyProject < Trailblazer::Operation

   contract do
     property :user_email, virtual: true
     property :projects_ids, virtual: true
   end
   def process(params)
     user = User.find_by_email(params[:user_email])
     if user.present?
       params[:projects_ids].each do |project_id|
         project = Project.find(project_id)
         res, op = ProjectsUser::AddUserToProject.run(project: project, user: user)
         notify! if op.valid?
       end
     else
       errors.add(:base,'Email was not found in our database.')
       invalid!
     end
   end
 end
end

 Keep in touch
 

http://slides.com/liubovderevianko/trailblazer_operations

https://www.linkedin.com/in/liubov-derevianko

http://meetup.mobidev.com.ua/

Made with Slides.com