Search code examples
ruby-on-railsactiverecordmodels

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?


Solution

  • 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.