Skip to content

Building Partial Objects Step by Step

Akshat Kedia edited this page Feb 13, 2019 · 10 revisions

This question comes up a lot, people want to have an object, lets call it a Product that they want to create in several different steps. Let's say our product has a few fields name, price, and category and to have a valid product all these fields must be present.

The Problem

We want to build an object in several different steps but we can't because that object needs validations. Lets take a look at our Product model.

class Product < ActiveRecord::Base
  validates :name, :price, :category, presence: true
end

So we have a product that relies on name, price, and category to all be there. Lets take a look at a simple Wizard controller we'll make a Products::BuildController. It is located at app/controllers/products/build_controller.rb

class Products::BuildController < ApplicationController
  include Wicked::Wizard

  steps :add_name, :add_price, :add_category

  def show
    @product = Product.find(params[:product_id])
    render_wizard
  end


  def update
    @product = Product.find(params[:product_id])
    @product.update_attributes(params[:product])
    render_wizard @product
  end


  def create
    @product = Product.create
    redirect_to wizard_path(steps.first, product_id: @product.id)
  end
end

Since Wicked uses our :id parameter we will need to have a route that also includes :product_id for instance /products/:product_id/build/:id. This is one way to generate that route:

  resources :products do
    resources :build, controller: 'products/build'
  end

This also means to get to the create action we don't have a product_id yet so we can either create this object in another controller and redirect to the wizard, or we can use a route with a placeholder product_id such as [POST] /products/building/build in order to hit this create action.

We also have another problem, if we've added validations to our product requiring fields that are set later in the wizard, it will fail to store to the database. How can we keep all of our validations, but let this invalid object save to the database?

The Solution

The best way to build an object incrementally with validations is to save the state of our product in the database and use conditional validation. To do this we're going to add a status field to our Product class.

Notice: Another method for partial validations, which might be considered more flexible by some users (allowing for easy validation testing inside model tests), was described by Josh McArthur here.

class ProductStatus < ActiveRecord::Migration
  def change
    add_column :products, :status, :string
  end
end

Now we want to add an active state to our Product model.

  def active?
    status == 'active'
  end

And we can add a conditional validation to our model.

class Product < ActiveRecord::Base
  validates :name, :price, :category, presence: true, if: :active?

  def active?
    status == 'active'
  end
end

Now we can create our Product and we won't have any validation errors, when the time comes that we want to release the product into the wild you'll want to remember to change the status of our Product on the last step.

class Products::BuildController < ApplicationController
  include Wicked::Wizard

  steps :add_name, :add_price, :add_category

  def update
    @product = Product.find(params[:product_id])
    params[:product][:status] = 'active' if step == steps.last
    @product.update_attributes(params[:product])
    render_wizard @product
  end
end

Great, but...

So that works well, but what if we want to disallow a user to go to the next step unless they've properly set the value before it. We'll need to split up our validations to support multiple conditional validations.

class Product < ActiveRecord::Base
  validates :name,      presence: true, if: :active_or_name?
  validates :price,     presence: true, if: :active_or_price?
  validates :category,  presence: true, if: :active_or_category?

  def active?
    status == 'active'
  end

  def active_or_name?
    status.include?('name') || active?
  end

  def active_or_price?
    status.include?('price') || active?
  end

  def active_or_category?
    status.include?('category') || active?
  end
end

Then in our Products::BuildController Wizard we can set the status to the current step name in in our update.

  def update
    @product = Product.find(params[:product_id])
    params[:product][:status] = step.to_s
    params[:product][:status] = 'active' if step == steps.last
    @product.update_attributes(params[:product])
    render_wizard @product
  end

So on the :add_name step status.include?('name') will be true and our product will not save if it isn't present. So in the update action of our controller if @product.save returns false then the render_wizard @product will direct the user back to the same step :add_name. We still set our status to active on the last step since we want all of our validations to run.

Wow that's cool, but seems like a bunch of work

What you're trying to do is fairly complicated, we're essentially turning our Product model into a state machine, and we're building it inside of our wizard which is a state machine. Yo dawg, i heard you like state machines... This is a very manual process which gives you, the programmer, as much control as you like.

Cleaning up

If you have conditional validation it can be easy to have incomplete Products laying around in your database, you should set up a sweeper task using something like Cron, or Heroku's scheduler to clean up Products that are not complete.

lib/tasks/cleanup.rake

namespace :cleanup do
  desc "removes stale and inactive products from the database"
  task :products => :environment do
    # Find all the products older than yesterday, that are not active yet
    stale_products = Product.where("DATE(created_at) < DATE(?)", Date.yesterday).where("status is not 'active'")

    # delete them
    stale_products.map(&:destroy)
  end
end

When cleaning up stale data, be very very sure that your query is correct before running the code. You should also be backing up your whole database periodically using a tool such as Heroku's PGBackups incase you accidentally delete incorrect data.

Wrap it up

Hope this helps, I'll try to do a screencast on this pattern. It will really help if you've had problems implementing this, to let me know what they were. Also if you have another method of doing partial model validation with a wizard, I'm interested in that too. As always you can find me on the internet @schneems. Thanks for using Wicked!