Posted over 4 years ago. Visible to the public.

Form Objects and Transactions (multiple models)

Let's say we have two associated models:

Copy
# app/models/user.rb class User < ApplicationRecord has_one :location end # app/models/location.rb class Location < ApplicationRecord belongs_to :user end

We want to create one instance of each model using a single registration form.
Ruby on Rails out of the box way is the _nested form_®™: fields_for, accepts_nested_attributes_for, maybe even inverse_of. This would require the following code at the least:

Copy
# 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 end

Here’s what I already don’t like about this approach:

  1. 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.
  2. Whitelisting attributes with strong parameters gets more complicated.
  3. The User class contains logic to deal with Location’s attributes. This code is at odds with the Single Responsibility Principle. This is even more apparent when using reject_if.
  4. It’s unclear what happens when save is called. If location is invalid, does user get saved? What if it’s the other way around?

So here’s an alternate proposal: use a form object!

Copy
class Registration include ActiveModel::Model attr_accessor :email, :password, :country, :city def save # Save User and Location here end end

Meanwhile our view should look something like this:

Copy
<%= 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:

Copy
class RegistrationsController < ApplicationController def create @registration = Registration.new(params) if @registration.save redirect_to root_url, notice: 'Registration successful!' else render :new end end end

In our implementation we’ll return true from the save method if all models are saved and false if any of the models cannot be saved.

Copy
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 end

The trick here is to wrap the saving calls in a transaction and use create! instead of create. The 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.

Points worthy of note:

  • We can add an error not directly associated with an attribute by using the symbol :base:
Copy
validate :user_invite def user_invite errors.add(:base, 'Missing invite token') unless token? end
  • We can turn a database exception (like an email uniqueness constraint) into an error by doing something like:
Copy
rescue ActiveRecord::RecordNotUnique errors.add(:email, :taken) end

For a more in-depth look at reusing database errors as validation errors, I suggest reading about uniqueness validations Archive

Owner of this card:

Avatar
Alexander M
Last edit:
over 4 years ago
by Alexander M
Tags:
Software-Architecture
Posted by Alexander M to Ruby and RoR knowledge base
This website uses short-lived cookies to improve usability.
Accept or learn more