Dex
Dex

Reputation: 12759

Handling Unique Record Exceptions in a Controller

I have a model called Subscription that has a unique index on the fields [:email, :location]. This means one email address can subscribe per location.

In my model:

class Subscription < ActiveRecord::Base
  validates :email, :presence => true, :uniqueness => true, :email_format => true, :uniqueness => {:scope => :location}
end

In my create method. I want to handle the the exception ActiveRecord::RecordNotUnique differently than a regular error. How would I add that in to this generic create method?

  def create
    @subscription = Subscription.new(params[:subscription])
    respond_to do |format|
      if @subscription.save
        format.html { redirect_to(root_url, :notice => 'Subscription was successfully created.') }
      else
        format.html { render :action => 'new' }
      end
    end
  end

Upvotes: 11

Views: 24257

Answers (5)

Yan Pritzker
Yan Pritzker

Reputation: 151

This gem rescues the constraint failure at the model level and adds a model error (model.errors) so that it behaves like other validation failures. Enjoy! https://github.com/reverbdotcom/rescue-unique-constraint

Upvotes: 5

LinuCC
LinuCC

Reputation: 440

Adding to Chirantans answer, with Rails 5 (or 3/4, with this Backport) you can also use the new errors.details:

begin
  @subscription.save!
rescue ActiveRecord::RecordInvalid => e
  e.record.errors.details
  # => {"email":[{"error":"taken","value":"[email protected]"}]}
end

Which is very handy for differentiating between the different RecordInvalid types and does not require relying on the exceptions error-message.

Note that it includes all errors reported by the validation-process, which makes handling multiple uniqueness-validation-errors much easier.

For example, you can check if all validation-errors for a model-attribute are just uniqueness-errors:

exception.record.errors.details.all? do |hash_element|
  error_details = hash_element[1]  
  error_details.all? { |detail| detail[:error] == :taken }
end

Upvotes: 5

Chirantan
Chirantan

Reputation: 15634

I don't think there is a way to have an exception thrown just for a single type of validation failure. Either you can do a save! which would raise exceptions for all save errors (including all validation errors) and have them handled separately.

What you can do is handle the exception ActiveRecord::RecordInvalid and match the exception message with Validation failed: Email has already been taken and then handle it separately. But this also means that you would have to handle other errors too.

Something like,

begin
  @subscription.save!
rescue ActiveRecord::RecordInvalid => e
  if e.message == 'Validation failed: Email has already been taken'
    # Do your thing....
  else
    format.html { render :action => 'new' }
  end
end
format.html { redirect_to(root_url, :notice => 'Subscription was successfully created.') }

I'm not sure if this is the only solution to this though.

Upvotes: 18

Brandon
Brandon

Reputation: 2604

A couple things I would change about the validation:

  1. Do the presence, uniqueness, and format validations in separate validations. (Your uniqueness key in the attributes hash you are passing to "validates" is being overwritten in your validation). I would make it look more like:

    validates_uniqueness_of :email, :scope => :location

    validates_presence_of :email

    validates_format_of :email, :with => RFC_822 # We use global validation regexes

  2. Validations are Application level, one of the reasons you should separate them is because the presence and format validations can be done without touching the database. The uniqueness validation will touch the database, but won't use the unique index that you setup. Application level validations don't interact with the database internals they generate SQL and based on the query results make a determination of validity. You can leave the validates_uniqueness_of but be prepared for race conditions in your application.

Since the validation is application level it will request the row (something like "SELECT * FROM subscriptions WHERE email = 'email_address' LIMIT 1"), if a row is returned then the validation fails. If a row is not returned then it is considered valid.

However, if at the same time someone else signs up with the same email address and they both do not return a row before creating a new one then the 2nd "save" commit will trigger the uniqueness Database index constraint without triggering the validation in the application. (Since most likely they are running on different application servers or at least different VM's or processes).

ActiveRecord::RecordInvalid is raised when the validation fails, not when the unique index constraint on the database is violated. (There are multiple levels of ActiveRecord Exceptions that can be triggered at different points in the request/response lifecycle)

RecordInvalid is raised at the first level (Application level) whereas RecordNotUnique can be raised after the submission is attempted and the database server determines the transaction does not meet the index constraint. (ActiveRecord::StatementInvalid is the parent of the post fetch Exception that will be raised in this instance and you should rescue it if you are actually trying to get the database feedback and not the Application level validation)

If you are in your controller "rescue_from" (as outlined by The Who) should work just fine to recover from these different types of errors and it looks like the initial intent was to handle them differently so you can do so with multiple "rescue_from" calls.

Upvotes: 9

The Who
The Who

Reputation: 6622

You will want to use rescue_from

In your controller

 rescue_from ActiveRecord::RecordNotUnique, :with => :my_rescue_method

 ....

 protected

 def my_rescue_method
   ...
 end

However, wouldn't you want to invalidate your record rather than throwing an exception?

Upvotes: 10

Related Questions