6

I have a problem with the scoped uniqueness validation in Rails for nested attributes with a parent of parent.

Background

I have a rails 4 application with 3 models :

#app/models/account.rb
class Account < ActiveRecord::Base
  has_many :contacts, dependent: :destroy
end

#app/models/contact.rb
class Contact < ActiveRecord::Base
  belongs_to :account
  has_many :email_addresses, dependent: :destroy, validate: :true, inverse_of: :contact
  accepts_nested_attributes_for :email_addresses,allow_destroy: true
  validates :email_addresses, presence: true
end

#app/models/email_address.rb
class EmailAddress  < ActiveRecord::Base
  belongs_to :contact, inverse_of: :email_addresses

  validates :label, presence: true
  validates :contact, presence: true
  validates :email, uniqueness: true, presence: true
  validates_email_format_of :email
end

Issue

I want make a scope, so as to make sure the attribute :email of the model EmailAddress is unique at the Account Level (Account is parent of Contact, which is itself parent of EmailAddress).

As suggested at http://guides.rubyonrails.org/active_record_validations.html, I tried :

 class EmailAddress  < ActiveRecord::Base
  belongs_to :contact, inverse_of: :email_addresses

  validates :label, presence: true
  validates :contact, presence: true
  validates :email, presence: true, uniqueness: { scope: :account, 
                    message: "This contact email is already taken" }
  validates_email_format_of :email
 end

This raises the error "column email_addresses.account does not exist" What should I do ?

Thanks for you help !

Nobigie
  • 203
  • 1
  • 3
  • 10
  • May be this work.Add this to your `EmailAddress` model `validates :email, :uniqueness => {:scope => :contact_id}` – Pavan Jun 25 '14 at 19:09
  • 1
    In fact, I would like to have a scope for the account and not for a contact – Nobigie Jun 25 '14 at 19:12
  • You should add `belongs_to :account` to `EmailAddress` model to do that. – Pavan Jun 25 '14 at 19:16
  • This will complexify my db schema. Is there a more beautilul way to do this scope without changing the db schema? – Nobigie Jun 25 '14 at 19:36
  • I'd suggest you add a nested association in you account.rb. More specifically `has_many :email_addresses, through: :contacts` to start with. – Ruby Racer Jun 25 '14 at 19:36

2 Answers2

12

A better option in terms of performances is described below. It is tested and works just fine.

Why?

Mapping emails can consume a lot of ressources when a lot of emails are at stake, so its better to perform the scope directly with the database.

How?

Cashing the account_id in the EmailAddress model and performing a before validation method.

1) Create a migration :

change_table :email_addresses do |t|
  t.references :account, index: true
end
add_index :email_addresses, [:account_id, :email], unique: true

2) Migrate

3) Update the EmailAddress model

#app/models/email_address.rb

class EmailAddress < ActiveRecord::Base
  belongs_to :contact, inverse_of: :email_addresses
  belongs_to :account

  validates :label, presence: true
  validates :contact, presence: true
  validates_email_format_of :email
  validates_uniqueness_of :email, allow_blank: false, scope: :account

  before_validation do
    self.account = contact.account if contact
  end

end
Nobigie
  • 203
  • 1
  • 3
  • 10
  • This is indeed a fine solution. The scope for the addresses however of my solution was rather small, so the only actual difference here is the index. A better solution nonetheless. Upvoting. – Ruby Racer Jun 27 '14 at 20:50
  • Thanks :) Ruby Racer – Nobigie Jun 30 '14 at 07:35
7

I'll supply one possible solution. Not tested, but it should work, with a custom validation and an extra association.

In your Account model:

has_many :email_addresses, through: :contacts

In your EmailAddress model:

validate :uniqueness_of_mail

private
def uniqueness_of_mail
    account = contact.account
    if account.email_addresses.map(&:email).includes? email
        errors.add(email, 'Contact already has this email address')
        false
    else
        true
    end
end
Ruby Racer
  • 5,690
  • 1
  • 26
  • 43
  • Thanks for this answer. It works just fine. Just a question: is there an issue if in the EmailAddress we don't add belongs_to :account ? – Nobigie Jun 26 '14 at 08:27
  • I don't think you can have a `belongs_to :account`, because it's derived association. But you can do `def account \n contact.account \n end` to have `@emailaddress.account`. Otherwise, it's `@emailaddress.contact.account` as it is right now. – Ruby Racer Jun 26 '14 at 08:34
  • `email in account.email_addresses.map(&:email)` fails on my lasted version of ruby / rails. Try `account.email_addresses.map(&:email).includes? email` instead. – Dan Tappin Oct 09 '19 at 16:45
  • It should be `include?`, not `includes?` – Caleb Sep 02 '21 at 20:40