user2490003
user2490003

Reputation: 11890

ActiveRecord polymorphic association with unique constraint

I have a site that allows users to log in via multiple services (LinkedIn, Email, Twitter, etc..).

I have the below structure set up to model a User and their multiple identities. Basically a user can have multiple identieis, but only one of a given type (e.g. can't have 2 Twitter identiteis).

I decided to set it up as a polymorphic relationship, as drawn below. Basically there's a middle table identities that maps a User entry to multiple *_identity tables.

schema

The associations are as follows (shown only for LinkedInIdentity, but can be extrapolated)

# /app/models/user.rb
class User < ActiveRecord::Base
  has_many :identities
  has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"

  ...
end


# /app/models/identity
class Identity < ActiveRecord::Base
  belongs_to :user
  belongs_to :identity, polymorphic: true

  ...
end


# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
  has_one :identity, as: :identity
  has_one :user, through: :identity

  ...
end

The problem I'm running into is with the User model. Since it can have multiple identities, I use has_many :identities. However, for a given identity type (e.g. LinkedIn), I used has_one :linkedin_identity ....

The problem is that the has_one statement is through: :identity, and there's no singular association called :identity. There's only a plural :identities

> User.first.linkedin_identity
ActiveRecord::HasManyThroughAssociationNotFoundError: Could not find the association :identity in model User

Any way around this?

Upvotes: 1

Views: 929

Answers (2)

muichkine
muichkine

Reputation: 2940

Here is an idea that has worked wonderfully over here for such as case. (My case is a tad diffferent since all identites are in the same table, subclasses of the same base type).

class EmailIdentity < ActiveRecord::Base
  def self.unique_for_user
    false
  end

  def self.to_relation
    'emails'
  end
end

class LinkedinIdentity < ActiveRecord::Base
   def self.unique_for_user
     true
  end

  def self.to_relation
    'linkedin'
  end
end

class User < ActiveRecord::Base
   has_many :identities do
      [LinkedinIdentity EmailIdentity].each do |klass|
        define_method klass.to_relation do
          res = proxy_association.select{ |identity| identity.is_a? klass }
          res = res.first if klass.unique_for_user
          res
        end
      end
   end
end

You can then

@user.identities.emails
@user.identities.linkedin

Upvotes: 1

Max Williams
Max Williams

Reputation: 32933

I would do it like so - i've changed the relationship name between Identity and the others to external_identity, since saying identity.identity is just confusing, especially when you don't get an Identity record back. I'd also put a uniqueness validation on Identity, which will prevent the creation of a second identity of the same type for any user.

class User < ActiveRecord::Base
  has_many :identities
  has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"
end


# /app/models/identity
class Identity < ActiveRecord::Base
  #fields: user_id, external_identity_id
  belongs_to :user
  belongs_to :external_identity, polymorphic: true
  validates_uniqueness_of :external_identity_type, :scope => :user_id
  ...
end


# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
  # Force the table name to be singular
  self.table_name = "linkedin_identity"

  has_one :identity
  has_one :user, through: :identity

  ...
end

EDIT - rather than make the association for linkedin_identity, you could always just have a getter and setter method.

#User
def linkedin_identity
  (identity = self.identities.where(external_identity_type: "LinkedinIdentity").includes(:external_identity)) && identity.external_identity
end    

def linkedin_identity_id
  (li = self.linkedin_identity) && li.id
end    

def linkedin_identity=(linkedin_identity)
  self.identities.build(external_identity: linkedin_identity)
end

def linkedin_identity_id=(li_id)
  self.identities.build(external_identity_id: li_id)
end

EDIT2 - refactored the above to be more form-friendly: you can use the linkedin_identity_id= method as a "virtual attribute", eg if you have a form field like "user[linkedin_identity_id]", with the id of a LinkedinIdentity, you can then do @user.update_attributes(params[:user]) in the controller in the usual way.

Upvotes: 5

Related Questions