Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Multi model saving, how to wrap in transaction and report errors

I have a registration form model that takes the users input during registration:

class RegForm
    include ActiveModel::Model
    include ActiveModel::Validations

    attr_accessor :company_name, :email, :password
    validates_presence_of # ...

end

During this registration process I have multiple models that need to be created, and I am not sure how to properly display error messages and how to bubble the model error messages back to the UI.

if @reg_form.valid?
   account = Account.create!(@reg_form)
else 
...

Account.create! looks like:

def self.create!(reg_form)
  account = Account.create_from_reg!(reg_form)
  location = location.create_from_reg!(account, reg_form)
  ..
  ..
  account.location = location
  ..
  account.save!

  account
end
  1. So I'm confused how to display error messages for all these models that are saving
  2. how to display or fail validation if the reg_form doesn't have the correct data for all the other models.
  3. how to ensure this is wrapped in a transaction so I don't save anything if any model fails to save during registration.
like image 985
Blankman Avatar asked Aug 01 '16 01:08

Blankman


1 Answers

Let's start from the beginning.

We want our registration form object to have the same API as any other ActiveRecord model:

// view.html
<%= form_for(@book) do |f| %>
<% end %>

# controller.rb
def create
  @book = Book.new(book_params)

  if @book.save
    redirect_to @book, notice: 'Book was successfully created.'
  else
    render :new
  end
end

To do that, we create the following object:

class RegForm
  include ActiveModel::Model

  attr_accessor :company_name, :email, :password

  def save
    # Save Location and Account here
  end
end

Now, by including ActiveModel::Model, our RegForm gains a ton of functionality, including showing errors and validating attributes (yes, it's unnecessary to include ActiveModel::Validations). In this next step we add some validations:

validates :email, :password, presence: true

And we change save so that it runs those validations:

def save
  validate
  # Save Location and Account here
end

validate returns true if all validations pass and false otherwise.

validate also adds errors to the @reg_form (All ActiveRecord models have an errors hash which is populated when a validation fails). This means that in the view we can do any of these:

@reg_form.errors.messages
#=> { email: ["can't be blank"], password: ["can't be blank"] }

@reg_form.errors.full_messages
#=> ["Email can't be blank", "Password can't be blank"]

@reg_form.errors[:email]
#=> ["can't be blank"]

@reg_form.errors.full_messages_for(:email)
#=> ["Email can't be blank"]

Meanwhile, our RegistrationsController should look something like this:

def create
  @reg_form = RegForm.new(reg_params)

  if @reg_form.save
    redirect_to @reg_form, notice: 'Registration was successful'
  else
    render :new
  end
end

We can clearly see that when @reg_form.save returns false, the new view is re-rendered.

Finally, we change save so that our models save calls are wrapped inside a transaction:

def save
  if valid?
    ActiveRecord::Base.transaction do
      location = Location.create!(location_params)
      account = location.create_account!(account_params)
    end
    true
  end
rescue ActiveRecord::StatementInvalid => e
  # Handle database exceptions not covered by validations.
  # e.message and e.cause.message can help you figure out what happened
end

Points worthy of note:

  1. create! is used instead of create. The transaction is only rolled back if an exception is raised (which methods with a bang usually do).

  2. validate is just an alias for valid?. To avoid all that indentation we could instead use a guard at the top of the save method:

    return if invalid?
    
  3. 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)
    end
    
  4. We can add an error not directly associated with an attribute by using the symbol :base:

    errors.add(:base, 'Company and Email do not match')
    
like image 142
Ashitaka Avatar answered Sep 17 '22 15:09

Ashitaka