#416 Form Objects pro
- Download:
- source codeProject Files in Zip (69.4 KB)
- mp4Full Size H.264 Video (45 MB)
- m4vSmaller H.264 Video (25.6 MB)
- webmFull Size VP8 Video (29.6 MB)
- ogvFull Size Theora Video (59.7 MB)
Models in a Rails application can easily become very complicated as more logic gets added to them. Fortunately there are several ways to refactor them that can help to clean up them up. An excellent blog post by Bryan Helmkamp covers seven refactorings, one of which, Service Objects, has already been covered in episode 398. In this episode we’ll take a look at another of the refactorings in the list: extracting out form objects.
Refactoring Our User Model
Most of the behaviour in the User model of our example application is to do with forms. It has some custom virtual accessors, a number of validations, some callbacks and an association that accepts nested attributes for another model. It also has some virtual attributes and custom validations.
class User < ActiveRecord::Base has_secure_password attr_accessor :changing_password, :original_password, :new_password validates_presence_of :username validates_uniqueness_of :username validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/ validates_length_of :password, minimum: 6, on: :create validate :verify_original_password, if: :changing_password validates_presence_of :original_password, :new_password, if: :changing_password validates_confirmation_of :new_password, if: :changing_password validates_length_of :new_password, minimum: 6, if: :changing_password before_create :generate_token before_update :change_password, if: :changing_password has_one :profile accepts_nested_attributes_for :profile def subscribed subscribed_at end def subscribed=(checkbox) subscribed_at = Time.zone.now if checkbox == "1" end # Other methods omitted. end
By extracting these out into form objects we can clean up our model considerably. Before we dive into refactoring let’s take a look at the application, remembering that while refactoring it’s important that we focus on improving the code without changing the behaviour at all. Our application has a couple of forms. The first one is for signing up which accepts a new user’s details along with some profile details whose attributes belong to another model.
The second form is much simpler and allows an existing user to change their password. Here a user needs to enter their original password, along with a new password twice. What’s interesting about this is that we have two different forms working on the same model each of which is quite different from the other. Whenever we have this kind of situation it’s a good indication that a form object would improve the code. Let’s also take a look at the PasswordsController
which handles the form for changing a user’s password and processing the changes.
class PasswordsController < ApplicationController def new @user = current_user end def create @user = current_user @user.changing_password = true if @user.update_attributes(password_params) redirect_to current_user, notice: "Successfully changed password." else render "new" end end private def password_params params.require(:user).permit(:original_password, :new_password, :new_password_confirmation) end end
When we change a user’s password we set the changing_password
attribute for that user so it’s as if we’re putting the model into a different state for this behaviour. This is another pattern we should watch out for: if a controller ever tells a model to go into a different state this is a sign that maybe we should extract this state-specific behaviour out into a separate class and handle it outside the model. In our User
model we have a fairly big separation of concerns: each line of code here focusses either on the sign-up form or the form for changing a password. The accessors at the top of the model relate to changing the password and a number of the validators and one of the callbacks are also only called when the model is in its changing_password
state.
Refactoring With Form Objects
Let’s get started with our refactoring and extract this behaviour out into a form object. We’ll start by creating a new forms
directory under /app
, although this code can go anywhere you want it to. In it we’ll create a PasswordForm
class but what kind of behaviour should we put in here? Our goal is to have the controller interact with the class directly instead of with the User
object so before we start writing our PasswordsController
to use it.
def new @password_form = PasswordForm.new(current_user) end
This means that in the view layer where we display the “Change Password” form we can no longer pass the user in. Instead we’ll need to use our PasswordForm
object.
<%= form_for @password_form, url: passwords_path, method: :post do |f| %> <% if @password_form.errors.any? %> <div class="error_messages"> <h2>Form is invalid</h2> <ul> <% @password_form.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <!-- Rest of form omitted --> <% end %>
PasswordForm
needs to respond to each of the form’s attributes and behave like a model so that it can be used with form_for
. In Rails 4 it will be easy to get a Ruby object to behave like a model by including the ActiveModel::Model
module. In Rails 3 we can get by by including a few extra modules and by defining a persisted?
method. We’ll do this now in PasswordForm
.
class PasswordForm # Rails 4: include ActiveModel::Model extend ActiveModel::Naming include ActiveModel::Conversion include ActiveModel::Validations def persisted? false end end
Now we can do the fun part and copy the code over from the User
class.
class PasswordForm # Rails 4: include ActiveModel::Model extend ActiveModel::Naming include ActiveModel::Conversion include ActiveModel::Validations def persisted? false end attr_accessor :original_password, :new_password validate :verify_original_password validates_presence_of :original_password, :new_password validates_confirmation_of :new_password validates_length_of :new_password, minimum: 6 def initialize(user) @user = user end def verify_original_password unless @user.authenticate(original_password) errors.add :original_password, "is not correct" end end def change_password @user.password = new_password end end
Here we’ve moved the accessors, although we’ve removed changing_password
as this was used to say when we were changing the password, which our new class is dedicated to. We’ve also moved the validations related to password changing, removing the if
condition from them as they now always apply. We’ve also removed the callback that fires when the password changes as we won’t know the state of our database or when we’re updating it so we’ve copied over the change_password
method and we’ll trigger it manually. We’ve also copied the method that the custom validator uses. These two methods need to work a little differently as they call methods that now need to be called on a user, which we pass into this class through the initializer.
Let’s visit our application now and see if it works. We’ll need to restart our application so that new forms directory is picked up but now, when we visit the change password page, we still get the same form we saw before. If we look at the source, though, we’ll see that the name of the form fields has changed. Instead of having a user prefix they now begin with password_form
. This means that the params will include a password_form
hash when this form is submitted and we’ll have to account for this in the PasswordsController
. We’re using strong parameters in this controller and we have the strong_parameters
gem in the gemfile. If you want to know more about this, take a look at episode 371.
One useful feature of form objects is that they often remove the need for strong parameters so we can remove the password_params
method from the PasswordsController
and pass in the password_form
parameters directly when we update a user’s password. We’ll also need to change the create
method so that it works with a PasswordForm
instead of a User
and we’ll call submit
on it to submit changes, instead of update_attributes
, which is a method we’ll need to write.
class PasswordsController < ApplicationController def new @password_form = PasswordForm.new(current_user) end def create @password_form = PasswordForm.new(current_user) if @password_form.submit(params[:password_form]) redirect_to current_user, notice: "Successfully changed password." else render "new" end end end
This method will take a hash of parameters and will need to assign each of them to an attribute on the PasswordForm
object.
def submit(params) self.original_password = params[:original_password] self.new_password = params[:new_password] self.new_password_confirmation = params[:new_password_confirmation] if valid? @user.password = new_password @user.save! true else false end end
Assigning the attributes individually like this may feel a little verbose but it prevents the need to use strong parameters as each parameter is assigned directly instead of having to loop through a hash. Once we’ve assigned the parameters we check that our object is valid using the valid?
method that’s provided by ActiveModel::Validations
. If it is then we set that password using a line of code that we’ve moved from the change_password
method then save the user. This approach removes the need for the callback logic by moving the save code inline after doing the validation check. If we have a lot of attributes to set we could write an attribute setter method that has that behaviour in it. To see if this all works we’ll try changing our password but enter the wrong original password and a confirmation that doesn’t match the new password we want.
When we do this we get validation errors as we expect. If we correct these our password is changed.
Refactoring The Signup Form
Our refactoring has worked. The behaviour in the PasswordForm
class is all specific to the password form while our User model has been slimmed down. What, though, if we want to make another form object for the signup page? This may be taking form objects a little too far but let’s give it a go and see. We’ll create a new SignupForm
class for this. The reason we’ve called it that and not UserForm
is because that name implies that it would work for both creating and updating user records. Our form object will only work for creating user records as editing user records might function differently. In general it’s a good idea to create classes for one specific purpose and if we find later that another class has similar behaviour, for example the edit user form, we can abstract this behaviour our later. We’ll give this class the same model behaviour we had before and move over some of the code from the User
class. We’ll move the validations, removing the on: :create
option from one them as our new class only deals with creating new users. We can remove the before_create
callback and move the generate_token
method over as we’ll be calling it manually. The user class accepts nested attributes for a profile and normally when we have form objects we’d treat these as normal form fields and have the form object be responsible for splitting them out into multiple models. Finally we have our virtual attributes and the callback method that we’ll move over. Once we’ve finished our sign-up form will look like this:
class SignupForm # Rails 4: include ActiveModel::Model extend ActiveModel::Naming include ActiveModel::Conversion include ActiveModel::Validations validates_presence_of :username validates_uniqueness_of :username validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/ validates_length_of :password, minimum: 6 def persisted? false end def subscribed subscribed_at end def subscribed=(checkbox) subscribed_at = Time.zone.now if checkbox == "1" end def generate_token begin self.token = SecureRandom.hex end while User.exists?(token: token) end end
Our User
model is now much smaller.
class User < ActiveRecord::Base has_secure_password has_one :profile end
We’ve made good progress here but we’re still unsure how our form object will interact with the controller. We’ll start by taking a look at the new
action. This creates a new User
and then calls build_profile
on it. This is necessary because in the view we create a form for the user and we have a fields_for
section for the nested attributes. As we’re using a form object this is no longer necessary as our view no longer needs to concern itself with which attributes belong to which model, they will all be assigned to the form object. This means that we can remove the call to fields_for
.
<h3>Profile Details</h3> <div class="field"> <%= f.label :twitter_name %><br /> <%= f.text_field :twitter_name %> </div> <div class="field"> <%= f.label :github_name %><br /> <%= f.text_field :github_name %> </div> <div class="field"> <%= f.label :bio %><br /> <%= f.text_area :bio, rows: 5, cols: 40 %> </div>
At the top of the form we’ll alter the call to form_for
so that it uses the signup object instead of a user and change the validations in a similar way.
<%= form_for @signup_form, url: users_path do |f| %> <% if @signup_form.errors.any? %> <div class="error_messages"> <h2>Form is invalid</h2> <ul> <% @user.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <!-- Rest of form omitted --> <% end %>
As we’re using a different object with form_for
it won’t know that it has to pass it to the UsersController
so we’ve had to specify the url
option in form_for
. Our view now looks good but we still need to set up the @signup_form
in the controller action.
def new @signup_form = SignupForm.new end
We no longer need to worry about building a profile here as this will all be handled inside the form. What we have so far probably won’t work but let’s try it out to see.
It seems that the ActiveModel validations don’t support validates_uniqueness_of
as it relies on a database lookup. We’ll have to change our signup form so that it can ensure that a new user has a unique username. It would be better to add a database constraint for this to ensure that any new username is truly unique, but for now we’ll make our own manual validation that works in a similar way to validates_uniqueness_of
.
validate :verify_unique_username def verify_unique_username if User.exists? username: username errors.add :username, "has already been taken" end end
This validator simply checks to see if a user with the given username already exists and adds an error message if this is the case. With this in place we might expect the signup page to work but when we reload it we get a different error, this time complaining about an undefined username method on SignupForm
. The page is trying to access these attributes to display them on the form and there are a few ways that we can accomplish this. We do something similar to what we did with out PasswordForm
and make accessor methods for each field but we’ll need these attributes a lot and there are lot of them on the form. Managing them directly would take a lot of work so instead we’ll use delegation and set the attributes on the user and profile records directly and delegate the accessors to them.
delegate :username, :email, :password, :password_confirmation, to: :user delegate :twitter_name, :github_name, :bio, to: :profile
This means that we need to provide user
and profile
methods that set up the records we want.
def user @user ||= User.new end def profile @profile ||= user.build_profile end
In the user
method we just create a new User
, while in profile
we create a new profile through the user. We also need to alter the virtual attributes so that they go through the user object as this is where the attributes are defined and set.
def user @user ||= User.new end def profile @profile ||= user.build_profile end def subscribed user.subscribed_at end def subscribed=(checkbox) user.subscribed_at = Time.zone.now if checkbox == "1" end def generate_token begin user.token = SecureRandom.hex end while User.exists?(token: token) end
When we reload now our signup form it works. We’ve been using the browser a lot here to check if we’ve broken the application while we’ve been refactoring but normally we’d want to have a good test suite and rely on that while refactoring but that’s rather out of the scope of this episode.
Now that we have the signup form rendering correctly we’ll modify the create
action so that we can save new users. This will build a signup form like we did before and then submit it, passing in the parameters from the form. Let’s say, though, that we don’t want to use :signup_form
for the parameter name and want it to be :user
instead. We can customize this by going back to the signup form and defining a class method called model_name
. This method should return an instance of ActiveModel::Name
which we pass the a class, in this case self
, a prefix, which we won’t need and the name we want to use.
def self.model_name ActiveModel::Name.new(self, nil, "User") end
Passing "User"
as the third argument means that that will be used as the name for the parameters that are passed in to the form. This method is also used to determine the URL that the form is submitted to which means that we can remove the url:
option from the new user form. In our controller we can now submit the :user
parameters and we can use our form object whenever we need to reference the user in our controller. we can also the user_params
method as we’re no longer using strong parameters here.
def create @signup_form = SignupForm.new if @signup_form.submit(params[:user]) session[:user_id] = @signup_form.user.id redirect_to @signup_form.user, notice: "Thank you for signing up!" else render "new" end end
The last thing we need to do to get this working is to define the submit method on the form object. This method will take the params hash as an argument and we’ll need to some of these to the new user and some to their profile. We’ll slice the params hash to get the right keys, using the same keys that we use in the two delegate
calls and we’ll also set the virtual subscribed
attribute. If the form is valid we’ll then generate the token then save the user and their profile.
def submit(params) user.attributes = params.slice(:username, :email, :password, :password_confirmation) profile.attributes = params.slice(:twitter_name, :github_name, :bio) self.subscribed = params[:subscribed] if valid? generate_token user.save! profile.save! true else false end end
If we reload our signup form page now, fill it in then submit it we get an error message about an undefined variable or method called token
. This is simple to fix: the generate_token
method needs to use user.token
instead of token
.
def generate_token begin user.token = SecureRandom.hex end while User.exists?(token: user.token) end
When we try submitting the form again now it works.
With the refactoring we’ve done in this episode our User
model in now only four lines long. Is this a good thing? Our PasswordForm
object was a good extraction but our SignupForm
is something that we’d only do in more extreme cases where we’re trying to extract out as must as we can from the User model as there’s so much complexity. One of the biggest issues is that the validations have moved into the SignupForm
class. This may not be an issue if the validations are specific to a single form, but usually we want the data in our database to be consistent and have integrity so that whether we’re creating or updating records we’d need to go through these form objects. By contrast the validations in our PasswordForm
are specific to the form itself and aren’t necessarily about the integrity of the data, with the exception of the validation for the password length.
Generally we wouldn’t use form objects everywhere. In certain cases, though, they can work really well. In this episode we’ve created our form objects from scratch but there are several gems that can help us. The Virtus gem can help to manage attributes while the Reform gem provides a nice DSL for creating form objects.