Partial Validation of Active Record Objects in Wicked
17 Apr 2012This 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.
This is a re-post of a wiki I wrote for Wicked. While it was written to be used with a wizard, the pattern can be used without it. Enjoy!
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 for ProductController.
class ProductController < 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
Here the create action won’t work because our product didn’t save. OhNo!
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.
class ProductStatus < ActiveRecord::Migration
def up
add_column :products, :status, :string
end
def down
remove_column :product, :status
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 ProductController < 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
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 or 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 ProductController 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
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 Product’s laying around in your database, you should set up a sweeper task using something like Cron, or Heroku’s scheduler to clean up Product’s 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!