Rails 5.2 - Command Objects and PORO Attributes

Adding Attribute Validations to specific actions

Overview

Sometimes within an work flow different validations and business logic is needed. It can be very complex if each modification uses the same controller and model logic! I’ve tried and ist crazy difficult. The best solution I have found is to use Commands and separate Controllers. It became practical to treat a command object as a Rails Model as of Rails 5.2 when the Attribute API allows Plain Old Ruby Objects (POROs) to use Attributes.

Scenario

We have a workflow (at a school) where a young student submits a request.

  • Student creates a request
  • This is reviewed by the ‘assistant to the dean’ (approved, conditional, declined)
  • When approved it is forwarded to the student’s guardian for guardian_approval (approved, conditional, declined)
  • When approved it is forwarded to the ‘Dean of Students’ for final_approval (approved, conditional, declined)

This example will omit much of the real-world complexities especially the relationships between users and the related security and scoping. But the focus here is to build validations and logic outside the controllers and base model. (the base model will only contains logic that is always true)

create a project

We will ensure we are using Rails 5.2 (but it can be later and there are be more features in later versions of Rails)

rbenv install 2.7.5
rbenv local 2.7.5

gem install rails -v 5.2.7
rails _5.2.7_ new commands_n_attributes --skip-spring

cd commands_n_attributes

bin/rails db:drop
bin/rails db:create
bin/rails db:migrate

Let’s create a landing page using:

bin/rails g controller landing index

And now update routes:

# config/routes.rb
Rails.application.routes.draw do
  get 'landing/index'
  root 'landing#index'
end

If you start rails with bin/rails s ‘/’ should be the new landing-page.

We will need some users and their roles (but we will keep this simple!) using:

rails g scaffold User name role email:uniq

And we will update the migration to require our new fields using:

class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :role, default: 'student', null: false

      t.timestamps
    end
    add_index :users, :email, unique: true
  end
end

Since all fields are required all the time we will put the user validations in the model - so we can now update the model using:

class User < ApplicationRecord
  validates :name, presence: true
  validates :email, presence: true, uniqueness: true
  validates :role, presence: true,
                   inclusion: { in: %w[student assistant parent dean],
                                message: '%{value} is not a valid role' }
end

Let’s migrate bin/rails db:migrate and make some users with bin/rails db:migrate or using /users

# db/seeds.rb
student = User.create(name: 'student', email: '[email protected]', role: 'student')
reviewer = User.create(name: 'reviewer', email: '[email protected]', role: 'reviewer')
bill = User.create(name: 'bill', email: '[email protected]', role: 'guardian')
dean = User.create(name: 'dean', email: '[email protected]', role: 'dean')

Student Request

allow the student to make a request (this stuff will always be required so we will use all rails standards) – in real-life this would usually be scoped and the only area students can access, but that is beyond the scope of this article.

rails g scaffold Request category description:text student:references

Now lets open this migration and require all fields using:

class CreateRequests < ActiveRecord::Migration[5.2]
  def change
    create_table :travel_requests do |t|
      t.string :category, null: false
      t.text :description, null: false
      t.references :student, foreign_key: {to_table: :users}, null: false

      t.timestamps
    end
  end
end

Note we are using the student as an alias for the User class – so we can eventually allow all the various users to interact and ‘student’ is much clearer than user the model name. And we can enforce these required fields in the model too:

class Request < ApplicationRecord
  belongs_to :student, class_name: 'User'

  validates :student, presence: true
  validates :description, presence: true
  validates :category, presence: true,
                       inclusion: { in: %w(travel money),
                                    message: "%{value} is not a valid category" }
end

Let’s migrate again and create some requests:

# db/seeds.rb
student = User.create(name: 'student', email: '[email protected]', role: 'student')
reviewer = User.create(name: 'reviewer', email: '[email protected]', role: 'reviewer')
bill = User.create(name: 'bill', email: '[email protected]', role: 'guardian')
dean = User.create(name: 'dean', email: '[email protected]', role: 'dean')

request_good = Request.create(category: 'travel', description: 'reasonable', student: student)
request_hmmm = Request.create(category: 'travel', description: 'questionable', student: student)
request_nope = Request.create(category: 'travel', description: 'unreasonable', student: student)

Review Command

Let’s add the more complex logic outside the rails-standard way.

First let’s add the new fields needed by the reviewer:

bin/rails g migration AddReviewFieldsToRequests review_decision review_notes:texr \
                      review_decision_at:timestamp reviewer:references

Update the migration:

class AddReviewFieldsToRequests < ActiveRecord::Migration[5.2]
  def change
    add_column :requests, :review_notes, :string
    add_column :requests, :review_decision, :string
    add_column :requests, :review_decision_at, :timestamp
    add_reference :requests, :reviewer, foreign_key: {to_table: :users}
  end
end

Now we need to update the Review Model too to add the relationship - its critical to add optional: true in rails 5.2 - otherwise rails thinks it should always be present! Otherwise, we are not changing the model nor its validations - since these fields are only required by the reviewer while reviewing a request.

class Request < ApplicationRecord
  belongs_to :student, class_name: 'User', foreign_key: 'student_id'
  belongs_to :reviewer, class_name: 'User', foreign_key: 'reviewer_id', optional: true

  validates :student, presence: true
  validates :description, presence: true
  validates :category, presence: true,
                       inclusion: { in: %w(travel money),
                                    message: "%{value} is not a valid category" }
end

bin/rails db:migrate

Let’s create a new controller to reviews the requests using (note - will will only accept the fields: review_decision review_notes - the others we will set in code) - so we can use the command:

bin/rails g scaffold_controller ReviewRequests review_decision review_notes

We don’t actually want the assistant to create or delete requests so the routes will look like:

Rails.application.routes.draw do
  resources :review_requests, only: [:index, :show, :edit, :update]
  resources :requests
  resources :users
  get 'landing/index'
  root 'landing#index'
end

Now let’s create out command (since we will have many commands we will make a folder called commands):

mkdir -p app/commands
touch app/commands/review_request_command.rb

class ReviewRequestCommand
  # Model and Attributes are BOTH needed to user Rails attributes in a Plain Ruby class
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::AttributeAssignment # allows direct assignment `.assign_attributes`
  include ActiveModel::AttributeMethods # allows attribute prefixing, etc
  include ActiveModel::Conversion # provides: #to_model, #to_key, #to_param, and to_partial_path
  include ActiveModel::Dirty # needed to track changes
  include ActiveModel::Validations

  # allow at least request to be seen outside this model (to display above the form)
  attr_reader :params, :request

  # unless all attributes are in the accessor you will get
  # `undefined method `write_from_user' for nil:NilClass` with normal attribute assignment
  # although you can use @id = params[:id] instead
  attr_accessor :id, :reviewer_id, :review_decision, :review_notes,
                :review_decision_at, :reviewer

  # our attributes (these are the only things the form can access/submit)
  attribute :id, :integer
  attribute :reviewer, User
  attribute :reviewer_id, :integer
  attribute :review_notes, :string
  attribute :review_decision, :string
  attribute :review_decision_at, :datetime

  # our rewiewer validations
  validates :id, presence: true
  validates :reviewer_id, presence: true
  validates :review_decision, presence: true,
                              inclusion: { in: %w[approved conditional declined],
                                           message: '%{value} is not a valid decision' }
  # a complex validation with logic
  validate :validate_review_notes

  # not needed but convenient
  def self.call(params = {})
    new(params).run
  end

  def initialize(params = {})
    @params = params # helps with debugging

    # get the request and pre-populate our attributes from there
    @request = Request.find(params[:id])
    self.review_notes = @request.review_notes
    self.review_decision = @request.review_decision

    # set the attributes (incoming attributes overwrite those from the request)
    assign_attributes(params)
  end

  # what does the action
  def run
    request.reviewer_id = reviewer_id
    request.review_notes = review_notes
    request.review_decision = review_decision
    request.review_decision_at = review_decision_at

    # return errors if not valid
    return self unless valid? && request.valid? && request.save

    # if success updating the original request - then do our additional logic
    case request.review_decision
    when 'approved'
      puts 'notify parent of request'
      puts 'notify student of approval'
    else
      puts "notify student that the request is #{review_decision} because #{review_notes}"
    end

    self
  end

  # without this method path(object) doesn't work or you can just use path(id: object.id) instead
  def to_param
    id
  end

  # this must answer true for the form to use patch instead of post!
  def persisted?
    true
  end

  # rails 5.2 requires this to work with forms and dirty tracking
  # unfortunately, rails 5.2 can't self-discover its own attributes
  # without this you get: `undefined method `to_hash' for nil:NilClass` in the form
  def attributes
    {
      id: id,
      reviewer_id: reviewer_id,
      review_notes: review_notes,
      review_decision: review_decision,
      review_decision_at: review_decision_at,
      request: request
    }
  end
  alias_method :to_hash, :attributes

  private

  def validate_review_notes
    # don't report an error when the decision is 'approved' or decision is invalid
    return unless %w[conditional declined].include? review_decision
    return unless review_notes.blank?

    errors.add(:review_notes, 'is required when request is not approved')
  end
end

Now you can test this Command in console:

student = User.create(name: 'student', email: '[email protected]', role: 'student')
reviewer = User.create(name: 'reviewer', email: '[email protected]', role: 'reviewer')
bill = User.create(name: 'bill', email: '[email protected]', role: 'guardian')
dean = User.create(name: 'dean', email: '[email protected]', role: 'dean')

request_good = Request.create(category: 'travel', description: 'reasonable', student: student)
request_hmmm = Request.create(category: 'travel', description: 'questionable', student: student)
request_nope = Request.create(category: 'travel', description: 'unreasonable', student: student)

review = ReviewRequestCommand.new(id: request_good.id, reviewer_id: reviewer.id)
review.valid?
review.errors.messages
request_good.reload # should be unchanged

review = ReviewRequestCommand.call(id: request_good.id, reviewer_id: reviewer.id)
review.valid?
review.errors.messages
request_good.reload # should be unchanged

review = ReviewRequestCommand.call(id: request_good.id, reviewer_id: reviewer.id, review_decision: 'accepted')
review.valid?
review.errors.messages
request_good.reload # SHOULD BE CHANGED

Adapt the Review Controller

Basically we need to treat the command as the model and send in the reviewer and the proper params.

class ReviewRequestsController < ApplicationController
  def index
    @review_requests = Request.all
  end

  def edit
    @review_request = ReviewRequestCommand.new(command_params)
  end

  def update
    @review_request = ReviewRequestCommand.call review_request_params.merge(command_params)
    respond_to do |format|
      if @review_request.valid?
        format.html { redirect_to review_requests_url, notice: 'Review request was successfully updated.' }
        format.json { render :show, status: :ok, location: @review_request }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @review_request.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  def command_params
    # {id: params[:id].to_i, reviewer_id: current_user.id}
    {id: params[:id].to_i, reviewer_id: current_user.id, reviewer: current_user}
  end

  # Only allow a list of trusted parameters through.
  def review_request_params
    params.require(:review_request_command) # must be the model name
          .permit(:review_decision, :review_notes)
  end

  def current_user
    User.where(role: %w[reviewer admin]).first
  end
end

Adapting the View Form

This next part is always tricky (rails likes working with Default models) – the important thing is the form command needs the URL since the model is called review_request_command, but the route is called review_request_path

  • if you get a url error that review_request_command_path doesn’t exist - check the form url
  • if this form sends a POST be sure review_request.persisted? returns true!
  • if the form returns the error: undefined method to_hash’ for nil:NilClassin the form - be sure your attribute is returned inreview_request.attributes`
  • if you get the error review_request wasn’t included, be sure you check the controller’s review_request_params method requires the review_request_command not review_request
<!-- app/views/review_requests/_form.html.erb -->
<!-- add the url to the form and all should work :) -->
<%= form_with(model: review_request, local: true,
              url: review_request_path(id: review_request.id)) do |form| %>

  <% if review_request.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(review_request.errors.count, "error") %> prohibited this review_request from being saved:</h2>

      <ul>
      <% review_request.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :review_decision %>
    <%= form.text_field :review_decision %>
  </div>

  <div class="field">
    <%= form.label :review_notes %>
    <%= form.text_field :review_notes %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

Now you can have complex process logic isolated in your commands and all your models, controllers and views can be focused on their simple tasks.

Resources

attributes allowed in non-ar models

Attribute Assignment methods

Rails 5.2 Docs

default attribute types:

book using PORO with attributes

helpful blogs

custom Attribute types

Form_with

Custom Attributes (when inherited from AcitiveModel)

This is good with a ’normal’ active record model you can add ‘virtual’ attributes - just for the form pages

# app/attribute_types/squished_string.rb
class SquishedString < ActiveRecord::Type::Value
  include ActiveModel::Type::Helpers::Mutable
  def type
    :squished_string
  end

  def cast(value)
    value.to_s.squish.strip
  end

  # def cast_value(value)
  # end

  # def deserialize(value)
  # end

  # def serialize(value)
  # end
end
# config/initializers/attribute_types.rb
# Not needed unless truely a new Datbase type
# ActiveRecord::Type.register(:squished_string, SquishedString)
# critical to define ActiveModel to use within the model
ActiveModel::Type.register(:squished_string, SquishedString)

Custom Attributes (in PORO - not yet in Rails 5.2)

Without access to attribute_type it seems this only works with Manual casting - without explict casting.

@review_notes = TrimmedText.new.cast(’ lots to say ‘) “lots to say”

ActiveModels have: #attribute_types & #attribute_names & #columns & #reflections (associations) - when a model has attribute types all works well as documented.

[26] pry(Request):2> attribute_types
=> {"id"=>
  #<ActiveModel::Type::Integer:0x00007f8382cf5b18 @limit=8, @precision=nil, @range=-9223372036854775808...9223372036854775808, @scale=nil>,
 "category"=>#<ActiveModel::Type::String:0x00007f8383c36be0 @limit=nil, @precision=nil, @scale=nil>,
 "description"=>#<ActiveRecord::Type::Text:0x00007f8382cf5708 @limit=nil, @precision=nil, @scale=nil>,
 "student_id"=>
  #<ActiveModel::Type::Integer:0x00007f8382cf5b18 @limit=8, @precision=nil, @range=-9223372036854775808...9223372036854775808, @scale=nil>,
 "created_at"=>#<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::DateTime:0x00007f8383c35c68 @limit=nil, @precision=nil, @scale=nil>,
 "updated_at"=>#<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::DateTime:0x00007f8383c35c68 @limit=nil, @precision=nil, @scale=nil>,
 "review_notes"=>#<ActiveRecord::Type::Text:0x00007f8382cf5708 @limit=nil, @precision=nil, @scale=nil>,
 "review_decision"=>#<ActiveModel::Type::String:0x00007f8383c36be0 @limit=nil, @precision=nil, @scale=nil>,
 "review_decision_at"=>
  #<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::DateTime:0x00007f8383c35c68 @limit=nil, @precision=nil, @scale=nil>,
 "reviewer_id"=>
  #<ActiveModel::Type::Integer:0x00007f8382cf5b18 @limit=8, @precision=nil, @range=-9223372036854775808...9223372036854775808, @scale=nil>}
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature