A form object is an informal "pattern" that many rails developers use to simplify controllers and ActiveRecord models. They can also make your code easier to understand. As an informal pattern there are many implementations. Below are a few blog posts that show different approaches.
The general idea is the same. Take input from a form, validate it, and then go on with your business. One of the motivations for this pattern is to remove business logic from ActiveRecord models and, in general, I like this idea. The catch for me is that some validations require querying a database, which is what ActiveRecord is made for. Could you leverage ActiveRecord to do its thing while keeping other responsibilities (i.e. updating other models or sending emails) in the form object?
I like to write pseudo-code as a way of sketching out an API. Tests are great for this, but for brevity I'll just show you what I wanted to do in the controller.
class RegistrationsController < ApplicationController def create @registration_form = RegistrationForm.new(params[:registration_form]) if @registration_form.save redirect_to '/' else render action: :new end end end
This is straight forward, easy to understand and doesn't do anything other than direct the flow of the applicaiton. Next is the code for RegistrationForm which will start to give you an idea of what AcitveForm provides.
class RegistrationForm < ActiveForm::Form accepts_attributes_for :user, :name, :email accepts_attributes_for :organization, :name within_save :associate_organization after_save :send_welcome_email def associate_organization organization.update_attribute(:user_id, user.id) end def send_welcome_email UserMailer.welcome(user, organization).deliver end end
The calls to .accepts_attributes_for on line 2 & 3 are what hooks us up to the ActiveRecord models User and Organization. Those give us access to instances of those models via #user and #organization which are used later in #associate_organization and #send_welcome_email. On line 5 we use .within_save to define a callback to be called when the models are being saved. It is separate from .after_save because it is wrapped in a transaction and .after_save is not.
Along with the #user and #organization methods (built with attr_accessor) there are also attr_accessors for each model and its attributes. In the example we're using we get #user_name, #user_email, and #organization_name. This allows us to keep form builder code nice and simple. This is a bare bones form (Sorry, I had to remove erb to get the highlighting to work properly).
form_for @registration_form, url: registrations_path do |form| form.text_field :user_name form.text_field :user_email form.text_field :organization_name form.submit 'Register' end
It's just like working with an ActiveRecord model but with a little extra typing. Now that we've covered how we use it let's look at how its built. At the beginning the goal was to have ActiveRecord perform validations so let's start there. The full implementation is below for line number references.
We saw that .accepts_attributes_for is where this happens. Line 84 below shows the method definition. This is where the attr_accessor calls happen. At the same time #map_model_attribute creates a Hash, attribute_map, that keeps track of which attribues belong to which model. We'll need that later when building the ActiveRecord models. That gets everything set up so now we can look at the instance methods.
In our controller we are only calling #save which in turn calls #process. Those are where the bulk of the work is done. Lines 19-23 are where our ActiveRecord models are instantiated and attributes are set. Next we call #validate_models which loops over the models, calls #valid? on each one and then copies any errors back to the form object so it can be used in the output. Now if we have any errors we bail out calling it a failure and returning false. If we don't have any errors we persist the models on lines 7-10. The transaction is used so any problems will roll everything back. This is also where we have a chance to perform other database operations via the .within_save callback. After the transaction we check for an .after_save callback and finally return true on line 12.
module ActiveForm class Form include ActiveModel::Model def save if process ActiveRecord::Base.transaction do models.map(&:save!) send(self.class.within_save_method) if self.class.within_save_method end send(self.class.after_save_method) if self.class.after_save_method return true else return false end end def process self.class.model_names.map do |model_name| model = "#{model_name}".camelize.constantize.new(attributes_for_model(model_name)) models << model send("#{model_name}=", model) end validate_models errors.messages.empty? end def attributes_for_model(model_name) attributes = {} self.class.attribute_map[model_name].each do |attribute_name| attributes[attribute_name] = send("#{model_name}_#{attribute_name}".to_sym) end attributes end private def models @models ||= [] end def validate_models models.each do |thing| name = thing.class.name.downcase unless thing.valid? thing.errors.messages.each do |k, v| v.each do |m| errors.add("#{name}_#{k}".to_sym, m) end end end end end def self.model_names @model_names ||= [] end def self.attribute_map @attribute_map ||= {} end def self.map_model_attribute(model_name, attribute_name) attribute_map[model_name] ||= [] attribute_map[model_name] << attribute_name end def self.within_save_method @within_save_method end def self.within_save(method_name) @within_save_method = method_name end def self.after_save_method @after_save_method end def self.after_save(method_name) @after_save_method = method_name end def self.accepts_attributes_for(model_name, *attributes) model_names << model_name attr_accessor model_name attributes.each do |attribute_name| map_model_attribute(model_name, attribute_name) attr_accessor "#{model_name}_#{attribute_name}".to_sym end end end end
Most of the form object examples I've seen, including this one, focus on input that is saved to a database. The majority of the time that's probably what's happening, but not always. What about a authentication? You collect credentials, but they aren't saved to a database. Because ActiveForm includes ActiveModel::Model we can validate input without an ActiveRecord model.
Assuming our User class has an authenticate method from has_secure_password we could make a LoginForm like this:
class LoginForm < ActiveForm::Form attr_accessor :email, :password, :remember_me def authenticate User.find_by(email: email).try(:authenticate, password) end end
And our controller code would use #authenticate instead of #save. Although we aren't using remember_me in LoginForm it is handy to put it there so the form builder has access to it, keeping the value if the form is rendered again. We just pass it to #start_session to be dealt with there.
class SessionsController < ApplicationController def new @login_form = LoginForm.new end def create @login_form = LoginForm.new(params[:login_form]) if user = @login_form.authenticate start_session(user, @login_form.remember_me) redirect_to '/' else flash.now[:notice] = "ACCESS DENIED" # Just kidding, be nice. render :new end end private def start_session(user, remember_me) # Start a session and use remember_me to decide how long it will last. end end
A lot of what ActiveForm provides isn't used here so it might not make sense to use it in this case.
I'd love to hear what you think about this approach. Have you implemented your own version "form objects"? How did you do it?
I'm always looking for new topics to write about. Stuck on a problem or working on something interesting? You can reach me on Twitter @bradpauly or send me an email.