We’ve talked before about form objects and how they can simplify our Rails views. Now I’d like to present a more complex scenario and one way to tackle it.
We have two associated models:
# app/models/user.rb
class User < ApplicationRecord
has_one :location
end
# app/models/location.rb
class Location < ApplicationRecord
belongs_to :user
endWe want to create one instance of each model using a single registration form.
If we reached for the Rails toolbox we would find the nested form®™: fields_for, accepts_nested_attributes_for, maybe even inverse_of. This would require the following code at the least:
# app/views/registration/new.html.erb
<%= form_for @user do |f| %>
<%= f.email_field :email %>
<%= f.fields_for @user.build_location do |g| %>
<%= g.text_field :country %>
<% end %>
<% end%>
# app/models/user.rb
class User
accepts_nested_attributes_for :location
endHere’s what I already don’t like about this approach:
- The view is coupled to the database structure. If we decide to make changes to the database schema later, the form will need to be updated.
- Whitelisting attributes with strong parameters gets more complicated.
- The
Userclass contains logic to deal withLocation’s attributes. This code is at odds with the Single Responsibility Principle. This is even more apparent when usingreject_if. - It’s unclear what happens when
saveis called. Iflocationis invalid, doesuserget saved? What if it’s the other way around?
So here’s an alternate proposal: use a form object! As we saw last time, all we need to do is to include ActiveModel::Model. In this case though, since we want to persist our data, we have to implement a save method:
class Registration
include ActiveModel::Model
attr_accessor :email, :password, :country, :city
def save
# Save User and Location here
end
endMeanwhile our view should look something like this:
<%= form_for @registration do |f| %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.input :password %>
<%= f.text_field :password %>
<%= f.input :country %>
<%= f.text_field :country %>
<%= f.input :city %>
<%= f.text_field :city %>
<%= f.button :submit, 'Create account' %>
<% end %>And our controller like this:
class RegistrationsController < ApplicationController
def create
@registration = Registration.new(params)
if @registration.save
redirect_to root_url, notice: 'Registration successful!'
else
render :new
end
end
endNow, save’s API goes like this: “return true if the model is saved and false if the model cannot be saved”. In our implementation we’ll return true if all models are saved and false if any of the models cannot be saved.
class Registration
# ...
def save
return false if invalid?
ActiveRecord::Base.transaction do
user = User.create!( email: email, password: password )
user.create_location!(country: country, city: city )
end
true
rescue ActiveRecord::StatementInvalid => e
# Handle exception that caused the transaction to fail
# e.message and e.cause.message can be helpful
errors.add(:base, e.message)
false
end
endThe trick here is to wrap the saving calls in a transaction and use create! instead of create. A Rails method with an exclamation point will usually throw an error on failure. And transactions are rolled back when an exception is raised. This means that if one model fails to save then none of the models are saved. Finally, rescuing the error and returning false will signal that something went wrong.
And that’s it!
Points worthy of note:
1. We can add an error not directly associated with an attribute by using the symbol :base:
validate :user_invitedef user_invite
errors.add(:base, 'Missing invite token') unless token?
end2. We can turn a database exception (like an email uniqueness constraint) into an error by doing something like:
rescue ActiveRecord::RecordNotUnique
errors.add(:email, :taken)
endFor a more in-depth look at reusing database errors as validation errors, I suggest reading about uniqueness validations, rescuing Postgres errors and parsing Postgres error messages.
3. By adding validations to form objects we effectively decouple validations from models. If we want to require users to enter their email we can add an email validation to the Registration form object. At the same time we can create a different sign up workflow (using a social network or a phone number) where users do not have to enter their email. This would be complicated to do if we added an email validation to the User model.
Now go on and create your own form objects. Add contextual validations. Simplify your code!
Have you used form objects to save multiple models? Have you promoted database errors to form object errors? We’d love to hear from you! Comment below with your experiences!
At Runtime Revolution we take our craft seriously and always go the extra mile to deliver a reliable, maintainable and testable product. Do you have a project to move forward or a product you’d like to launch? We would love to help you!
Runtime RevolutionWe are Rails, mobile and product development experts. We can build your product or work with you on your project.www.runtime-revolution.com