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 publishes
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. Let’s 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 that 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
: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
@tweet that we can check to see if they are valid. The
@user.invalid? checks the model to make sure that it passes its 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 the sign_up form to have the validation error on them if the username is not unique.
Furthermore, we have wrapped the
@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.