Building Partial Objects Step by Step
Clone this wiki locally
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
category and to have a valid product all these fields must be present.
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
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
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 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
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 up add_column :products, :status, :string end def down remove_column :products, :status end end
Now we want to add an
active state to our
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
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
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.
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.
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!