Creating multiple models with form objects in Rails.

Update: User MelissaLiberty from Reddit pointed out how they would improve the form object and some of it faults. The form object has been updated to reflect their excellent points.

Often, when we start a new Rails app we start with simple controllers, and we start by generating everything with scaffolding. There is nothing wrong with this and it is a great way to be able to build your basic models and perform CRUD actions on them but it breaks down a bit when the controllers get more complex. For instance, you might be building a Twitter clone where start with a User who publishesTweets.

You start building this app by running:

rails g scaffold User username:string
rails g scaffold Tweet body:string

So now you can create new tweets through the TweetsController and new users through the UsersController. The problem is what to do when the boss comes to you and says they want a new user to create their first tweet when they signup?

“This is high priority! We gotta juice the engagement metrics for the investors. The business is on the line! I don’t care if it is a crappy hack!”

– Your boss

Your first thought might be to edit the UsersController so that the Tweet is created inline. It might look something like this:

class UsersController < ApplicationController
  #....

  def create
    @user = User.new(user_params)
    @tweet = Tweet.new(tweet_params)

    if @user.save && @tweet.save
      redirect_to @user, notice: 'User and tweet was successfully created.' 
    else
      render :new
    end
  end

  #....
end

It looks pretty much like a normal create action except for the additions of @tweet = Tweet.new(tweet_params) the && @tweet.save. Of course you would have additional changes to the form in the view and you would need to write the tweet_params method, but I see some bigger problems.

While this controller is still clearly understandable, it will grow with time and it doesn’t account for validation errors on the @tweet model. It also seems to already be incorrectly named. I would move this process to a new controller and leave the UsersController alone (or delete the create action if it will no longer be needed).

You can move the whole sign up process to a SignUpsController instead. Now you will be describing the process that is actually happening and if (when) the signup process changes in the future you will know where to put the changes. Lets start with the controller itself, it should look like this:

# app/controllers/sign_ups_controller.rb
class SignUpsController < ApplicationController
  def new
    @sign_up = SignUp.new
  end

  def create
    @sign_up = SignUp.new(sign_up_params)

    if @sign_up.save
      redirect_to root_url, notice: 'Sign Up was a Success'
    else
      render :new
    end
  end

  private

  def sign_up_params
    params.permit(:username, :first_tweet)
  end
end

This is the full controller and it is pretty simple, just like we like them. The new action renders a view with the @sign_up instance variable and the create action put the params on the @sign_up variable and saves it.

Note: You will also need to add resources :sign_ups and get "/signup", to: "sign_ups#new" to config/routes.rb to make the controller work.

So the magic must be in this SignUp object right? Let’s look at it.

# app/forms/sign_up.rb
class SignUp
  include ActiveModel::Model
  attr_accessor :username, :first_tweet

  def save
    ActiveRecord::Base.transaction do
      user = User.create(username: username)
      add_errors(user.errors) if user.invalid?
      user.save!
      tweet = Tweet.create(body: first_tweet, user_id: user.id)
      add_errors(tweet.errors) if tweet.invalid?
      tweet.save!
    end
  rescue ActiveRecord::RecordInvalid => exception
    return false
  end

  private

  def add_errors(model_errors)
    model_errors.each do |attribute, message|
      errors.add(attribute, message)
    end
  end
end

You can see that there isn’t too much that is special about this object but there are a few neat tricks that make it tick. The first is the line include ActiveModel::Model which is there to make the SignUp form object quack like an ActiveModel duck. From the Rails API about ActiveModel::Model:

That first, line along with attr_accessor :username, :first_tweet, is what lets it work with the form.

Active Model Basic Model:

Includes the required interface for an object to interact with Action Pack and Action View, using different Active Model modules. It includes model name introspections, conversions, translations and validations. Besides that, it allows you to initialize the object with a hash of attributes, pretty much like Active Record does.

Now, we have the :username and :first_tweet on the form object so it is time to create the underlying objects it is composed of. In a previous version of this blog post we did this in the initialize but that created the objects on the database without ensuring that they were all valid and could have left us in a state where we created one valid object and didn’t create one invalid object. One out of two objects created isn’t what we are going for so now we are creating the objects in the def save method.

def save
    ActiveRecord::Base.transaction do
      @user = User.create(username: username)
      add_errors(@user.errors) if @user.invalid?
      @user.save!
      @tweet = Tweet.create(body: first_tweet, user_id: @user.id)
      add_errors(@tweet.errors) if @tweet.invalid?
      @tweet.save!
    end
  rescue ActiveRecord::RecordInvalid => exception
    return false
  end

We create both of the models that this signup is composed of in the save method. This gives us the instance variables @user and @tweet that we will can check if the are valid. The @user.invalid? checks the model to make sure that it passes it’s own internal validation and if it doesn’t then add_errors(@user.errors) will add those errors to the form object. As an example if the User has a validation for a unique username then we want that user model and and the sign_up form to have the validation error on them if the username is not unique.

Furthermore, we have wrapped the @user.save! and @tweet.save! in an ActiveRecord::Base.transaction to ensure that one won’t save without the other.

If anything goes wrong with our transaction or validations then the rescue will return false and let the controller know that the form object did not save.

We have gone through the controller and the form object so let’s look at the view.

# app/views/sign_ups/new.html.erb
<p id="notice"><%= @sign_up.errors.messages if @sign_up.errors.any? %></p>

<h1>Sign Up Here</h1>

<%= form_for @sign_up do |f| %>
  <%= label_tag(:username, "username") %>
  <%= text_field_tag :username %>

  <%= label_tag(:first_tweet, "first_tweet") %>
  <%= text_field_tag :first_tweet %>
  
  <%= submit_tag("Create user & first tweet") %>
<% end %>

The first thing to notice is the line where we show off any errors. If our validations added some errors then @sign_up.errors.any? will now get them to show up on the page after @sign_up.save fails in the controller and the controller sends the person attempting to sign up back through the render :new line to the new view. We didn’t want to forget about showing the user their errors right?

Other than that this appears to be a normal, Rails form_for that is passed the @sign_up object. That ability comes from the include ActiveModel::Model and attr_accessor :username, :first_tweet lines we set earlier in the SignUp form object. We put in some labels and text_field_tags and a submit button and our form object is complete.

Questions? Comments? Let me know below and I’ll do my best to answer them.

I also will be writing some more posts about the further uses for form objects in the future. If you want to learn how they can simplify and clean up your code then drop your email in the box below.

Processing…
Success! You're on the list.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s