reedy.in / words

Topics that require more than 140 characters...

Dynamic Validation in a Rails Model

Since 2005 I have maintained a CMS developed on the Rails platform for my day job. There are a number of people who maintain the content on that page and one of the most requested features was for the content staff to create “dynamic” forms.

Over time our implementation failed for a variety of reasons, mostly due to improvements to Ruby and the Rails framework. The most recent issue hit us with Rails version 3.2.x and completely broke the code. Remedying the issue require rewriting the validation aspects of the code.

Before looking at the fix, I’ll describe how the dynamic forms work. From the administrative section a new Form can be created. A Form has many FormComponents that define the various questions and accepted answers. A FormResponse then takes the questions and answers provided and saves it as an “isolated” snapshot. This allows the form to be updated at-will but does not break the history of responses. For example, a particular option may no longer be available or a question from a form my be removed entirely.

Model Relationship
1
2
3
4
5
6
7
8
9
10
11
12
13
class Form < ActiveRecord::Base
  has_many :form_components, dependent: :destroy
  has_many :form_responses, dependent: :nullify
end

class FormComponent < ActiveRecord::Base
  belongs_to :form
end

class FormResponse < ActiveRecord::Base
  belongs_to :form, include: :form_components
  has_many :form_components, through: :form
end

A FormComponent can be any of the standard form elements, including selects, check boxes, and radio buttons. Accepted answers are stored in a comma separated list and the form is rendered using Rails standard form helpers. Additionally, a form component can be determined to be required, accepted, or match a specific regular expression.

The following code snippet shows creating a FormComponent called full_name that is a required field within the form.

Example FormComponent
1
2
3
@form = Form.first
@form_component = @form.form_components.build(kind: 'text_field',
             required: true, question: 'full_name', label: 'Your Full Name')

There are three hurdles to jump for this to work.

  1. We don’t know what the questions are going to be for a given form in advance.
  2. The answers need to presented to the visitor in a form that will be saved in a FormResponse.
  3. FormResponse needs to run the validations defined in FormComponent

The code to display the form fields is relatively straight-forward. Here are the relevant pieces.

Public Form Controller
1
2
3
4
5
def new
  # @form is defined either by a param or UUID, etc...
  @form_response = FormResponse.single_response_for(@form) # I'll discuss this soon
  respond_with(@form_response)
end
Public Form View
1
2
3
4
5
6
7
= form_for @form_response do |f|
  %fieldset
    %legend Form Fields
    = f.hidden_field :form_id, @form_response.form.id
    - @form_response.form_components.each do |component|
      = render partial: component.kind locals: { f: f, component: component }
  = f.submit

To keep things DRY I’ve created a view partial for each kind of form component. Using the example FormComponent from earlier, this form would use the _text_field.html.haml partial which looks like this.

Component Partial for Text Fields
1
2
= f.label component.question, component.label
= f.text_field component.question

The other partials are not much more complicated and I am looking forward to the Rails 4 #collection_check_boxes method, which I think can clean them up even more. That is all that’s required to display the form for submission. Clicking submit will send the following parameters to FormResponse#create.

Submitted Parameters
1
{form_response: { form_id: 1, full_name: 'Dan Reedy' } }
FormResponse#create
1
2
3
4
5
6
7
8
9
10
11
def create
  @form = Form.includes(:form_components).find(params[:form_response][:form_id])
  @form_response = FormResponse.single_response_for(@form)
  respond_with(@form_response) do |format|
    if @form_response.save
      # Send an email, redirect, or do other stuff
    else
      # Display the form again with error messages
    end
  end
end

The FormResponse#create action uses the same #single_response_for1 method that we used in new. To understand what is happening we have to look at the FormResponse class.

FormResponse.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FormResponse < ActiveRecord::Base
  def self.dup(&block)
    super.tap do |dup|
      def dup.name() FormResponse.name end
      dup.class_eval(&block)
    end
  end

  def self.single_response_for(form)
    block = Proc.new do
      form.form_components.each do |component|
        method = component.question.to_sym
        attr_accessor method
        validates_presence_of method if component.required?
        validates_acceptance_of method if component.acceptance?
        if component.format.present?
          validates_format_of method, with: Regexp.new(component.format)
        end
      end
    end
    dup(&block).new(form_id: form.id)
  end
end

It looks like a lot is happening in #single_response_for but it isn’t doing anything overly complex. It starts by creating a Proc that will be sent to dup at the end. The Proc is where all the unique methods and validations for this instance of FormResponse will be defined.

Inside the Proc each FormComponent for that Form is used in attr_accessor and, depending on the options, any of the standard Rails validations.

Once the Proc is defined it is passed to the dup method, which we override to pass our custom block into the shallow copy of FormResponse. The result is a unique instance of FormResponse with attributes and validations defined by FormComponent.

FormResponse.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
# Instantiate with #new
> @form_response = FormResponse.new
=> #<FormResponse id: nil, form_id: nil, answers: nil>
> @form_response.valid?
=> true

# Instantiate with #single_response_for
> @form_response = FormResponse.single_response_for(Form.first)
=> #<#<Class:0x007f939a770210> id: nil, form_id: 1, answers: nil>
> @form_response.valid?
=> false
> @form_response.errors?
=> #<ActiveModel::Errors:0x007f939a610b40 @base=#<#<Class:0x007f939a770210> ..., @messages={:full_name=>["can't be blank"]}> 

Solving this problem highlights many of the things I love about the Ruby language. If you’ve solved this issue another way, think this was terrible, or otherwise have a comment, please share!

  1. Thanks go to ahoward on Rails issue #5449 for a cleaner implementation than what I was originally doing.

Comments