ActiveRecord polymorphic association with unique constraint - ruby-on-rails

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

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.

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

Related

Can't get STI to act as polymorphic association on model

I have a User model that can have an email and a phone number, both of which are models of their own as they both require some form of verification.
So what I'm trying to do is attach Verification::EmailVerification as email_verifications and Verification::PhoneVerification as phone_verifications, which are both STIs of Verification.
class User < ApplicationRecord
has_many :email_verifications, as: :initiator, dependent: :destroy
has_many :phone_verifications, as: :initiator, dependent: :destroy
attr_accessor :email, :phone
def email
#email = email_verifications.last&.email
end
def email=(email)
email_verifications.new(email: email)
#email = email
end
def phone
#phone = phone_verifications.last&.phone
end
def phone=(phone)
phone_verifications.new(phone: phone)
#phone = phone
end
end
class Verification < ApplicationRecord
belongs_to :initiator, polymorphic: true
end
class Verification::EmailVerification < Verification
alias_attribute :email, :information
end
class Verification::PhoneVerification < Verification
alias_attribute :phone, :information
end
However, with the above setup I get the error uninitialized constant User::EmailVerification. I'm unsure of where I'm going wrong.
How I structure this so that I can access email_verifications and phone_verifications on the User model?
When using STI you don't need (or want) polymorphic associations.
Polymorphic associations are a hack around the object-relational impedance mismatch problem used to setup a single association that points to multiple tables. For example:
class Video
has_many :comments, as: :commentable
end
class Post
has_many :comments, as: :commentable
end
class Comment
belongs_to :commentable, polymorphic: true
end
The reason they should be used sparingly is that there is no referential integrity and there are numerous problems related to joining and eager loading records which STI does not have since you have a "real" foreign key column pointing to a single table.
STI in Rails just uses the fact that ActiveRecord reads the type column to see which class to instantiate when loading records which is also used for polymorphic associations. Otherwise it has nothing to do with polymorphism.
When you setup an association to a STI model you just have to create an association to the base inheritance class and rails will handle resolving the types by reading the type column when it loads the associated records:
class User < ApplicationRecord
has_many :verifications
end
class Verification < ApplicationRecord
belongs_to :user
end
module Verifications
class EmailVerification < ::Verification
alias_attribute :email, :information
end
end
module Verifications
class PhoneVerification < ::Verification
alias_attribute :email, :information
end
end
You should also nest your model in modules and not classes. This is partially due to a bug in module lookup that was not resolved until Ruby 2.5 and also due to convention.
If you then want to create more specific associations to the subtypes of Verification you can do it by:
class User < ApplicationRecord
has_many :verifications
has_many :email_verifications, ->{ where(type: 'Verifications::EmailVerification') },
class_name: 'Verifications::EmailVerification'
has_many :phone_verifications, ->{ where(type: 'Verifications::PhoneVerification') },
class_name: 'Verifications::PhoneVerification'
end
If you want to alias the association user and call it initiator you do it by providing the class name option to the belongs_to association and specifying the foreign key in the has_many associations:
class Verification < ApplicationRecord
belongs_to :initiator, class_name: 'User'
end
class User < ApplicationRecord
has_many :verifications, foreign_key: 'initiator_id'
has_many :email_verifications, ->{ where(type: 'Verifications::EmailVerification') },
class_name: 'Verifications::EmailVerification',
foreign_key: 'initiator_id'
has_many :phone_verifications, ->{ where(type: 'Verifications::PhoneVerification') },
class_name: 'Verifications::PhoneVerification',
foreign_key: 'initiator_id'
end
This has nothing to do with polymorphism though.

How can I implement a has_many :through association two ways?

I have a property model and a user model.
A user with the role of 'admin', which is represented by a column on the users table, can have many properties.
A user with a role of 'guest' can also belong to a property, which gives them access to that property.
How should I do this in Rails?
authorizations table -> user_id, property_id
class Authorization < ActiveRecord::Base
belongs_to :user
belongs_to :property
end
class User < ActiveRecord::Base
has_many :authorizations
has_many :properties, through: :authorizations
end
class Property < ActiveRecord::Base
has_many :authorizations
has_many :users, through: :authorizations
end
then you can do User.find(id).properties
First, you need a has_many :through association between your models User and Property. So, create a new table properties_users with columns user_id and propety_id. And do following changes to the models:
class PropertiesUser < ActiveRecord::Base
belongs_to :user
belongs_to :property
end
class User < ActiveRecord::Base
has_many :properties_users
has_many :properties, through: :properties_users
end
class Property < ActiveRecord::Base
has_many :properties_users
has_many :users, through: :properties_users
end
Now, we need to make sure that a guest user does not have more than one property. For that we can add a validation to model PropertiesUser like below:
class PropertiesUser < ActiveRecord::Base
validate :validate_property_count_for_guest
private
def validate_property_count_for_guest
return unless user && user.guest?
if user.properties.not(id: self.id).count >= 1
self.errors.add(:base, 'guest user cannot have more than one properties')
end
end
end
class User < ActiveRecord::Base
def guest?
# return `true` if user is guest
end
end
Finally, to access a guest user's property, define a dedicated method in model User:
class User < ActiveRecord::Base
def property
# Raise error if `property` is called on non-guest users
raise 'user has multiple properties' unless guest?
properties.first
end
end
Now, you can fetch a guest user's property by running:
user = User.first
user.guest?
=> true
user.property
=> <#Property 1> # A record of Property

The Rails way to validate a web of classes

My app has many interrelationships like:
# Company
has_many :programs
has_many :projects
has_many :users
# Project
has_many :users
has_many :programs
belongs_to :company
# User
belongs_to :project
has_many :programs
belongs_to :company
# Program
belongs_to :project
belongs_to :user
belongs_to :company
Every program must belong to a project and user, BOTH OF WHICH belong to current_user.company.
Approach 1 - controller upon create/update
#program = Program.new(program_params)
#program.company = current_user.company
#allowed_projects = current_user.company.projects
unless #allowed_projects.include? #program.project
raise Exception
end
Approach 2 - model-based validation
before_save :ensure_all_allowed
def ensure_all_allowed
current_user = ???
self.company_id = current_user.company_id
# Then a similar validation to above for self.project_id
end
I feel these are both awkward and not 'the Rails way'.
I assume Approach 2 is the better method because it'll save all this awkward controller code and hold better to the MVC standard.
How can I validate these items correctly?
It's actually somewhat problematic to access current user in a model. It's not impossible, but it requires an around_action that will load the current user in the model class in a thread safe way.
Better would be to assign the current user in the controller
#program.user = current_user
#program.company = #program.user.company
Then do the validation in the model
validate :project_must_be_allowed
def project_must_be_allowed
unless company.projects include project
errors.add(:project, "Project is not valid for company.")
end
end
However, it would be a more normalized setup if you did through relationships
class Company
has_many :users
has_many :projects, through: :users
That way your 'projects' table doesn't need a company_id
You could still do the validation as I described but you'd have to add one method to the model...
def company
user.company
end
or more simply...
delegate :company, to: :user
Since Program has a belongs to relation to both user a and project you can setup some simple validations without worrying about the current_user. This is desirable from a MVC standpoint models should not be aware of the session or the request.
class Program < ActiveRecord::Base
# ...
validates_presence_of :user, :company, :project
# the unless conditions are there avoid the program blowing
# up with nil errors - but the presence validation above covers
# those scenarios
validate :user_must_belong_to_company,
unless: -> { company.nil? || user.nil? }
validate :project_must_belong_to_company,
unless: -> { company.nil? || project.nil? }
def user_must_belong_to_company
unless self.company == self.user.company
errors.add(:user, "must belong to same company as user.")
end
end
def project_must_belong_to_company
unless self.company == self.project.company
errors.add(:company, "must belong to same company as project.")
end
end
end
But I'm thinking that this is just a symtom of some bad relation design choices.
What you probably need is a series of many to many relations - it does not seem very realistic that a project can only have one user or a program either for that part.
class Company
has_many :users
has_many :projects
has_many :assignments, through :projects
has_many :programs, through :projects
end
class User
belongs_to :company
has_many :projects, through: :assignments
end
class Project
has_many :assignments, class_name: 'ProjectAssignment'
has_many :users, through: :assignments
belongs_to :company
end
# you can really call this whatever floats you boat
class ProjectAssignment
belongs_to :user
belongs_to :project
end
class Program
belongs_to :project
has_one :company, through: :project
has_many :assignments, class_name: 'ProgramAssignment'
has_many :users, through: :assignments
end
# you can really call this whatever floats you boat
class ProgramAssignment
belongs_to :user
belongs_to :program
end
That would automatically eliminate the problem with the company since it gets it through a parent relation.
The second problem that a user should not be able to create programs in a project he / she is not a member of sounds like something which should instead be handled on the authorization level - not in a validation.
Pundit example:
class ProgramPolicy < ApplicationPolicy
# ...
def create?
record.project.users.include?(user)
end
end
CanCanCan example:
class Ability
include CanCan::Ability
def initialize(user)
user ||= User.new # guest user (not logged in)
can :create, Program do |p|
p.project.users.include?(user)
end
end
end

has_one association with chaining include

We have a Company, CompanyUser, User and Rating model defined like this:
Company model
class Company < ActiveRecord::Base
has_many :company_users
has_many :users, through: :company_users
has_one :company_owner, where(is_owner: true), class_name: 'CompanyUser', foreign_key: :user_id
has_one :owner, through: :company_owner
end
There is an is_owner flag in the company_users table to identify the owner of the company.
CompanyUser model
class CompanyUser < ActiveRecord::Base
belongs_to :company
belongs_to :user
belongs_to :owner, class_name: 'User', foreign_key: :user_id
end
User model
class User < ActiveRecord::Base
has_many :company_users
has_many :companies, through: :company_users
has_many :ratings
end
Rating model
class Rating
belongs_to :user
belongs_to :job
end
I am able to find the owner of a company, by the following code:
#owner = #company.owner
I need to get the ratings and the jobs of the owner along with the owner. I can do this
#owner = #company.owner
#ratings = #owner.ratings.includes(:job)
But we have already used #owner.ratings at many places in the view, and it is difficult to change all the references in the views as it is a pretty big view spanning in several partials. I tried the following to get the ratings along with the owner
#owner = #company.owner.includes(:ratings => :job)
But this gives me error as #company.owner seems to give a User object and it does not seem to support chaining.
Is there a way I can get the included associations (ratings and job) inside the #owner object?
You should be able to do this with:
#owner = Company.where(id: #company.id).includes(owner: {ratings: :job}).owner
However this is not very clean. Much better would be to actually change #company variable:
#company = Company.includes(owner: {ratings: :job}).find(params[:company_id]) # or params id or any other call you're currently using to get the company.
Company built that way will already have everything included, so:
#owner = #company.owner
will pass a model with preloaded associations.

Any shortcut for updating join table when creating one of the models

For example, let us say we have
class User < ActiveRecord::Base
has_many :networks, through: user_networks
has_many :user_networks
end
class Network< ActiveRecord::Base
has_many :users, through: user_networks
has_many :user_networks
end
class UserNetwork < ActiveRecord::Base
belongs_to :user
belongs_to :network
end
Is there a shortcut for doing the following in a controller:
#network = Network.create(params[:network])
UserNetwork.create(user_id: current_user.id, network_id: #network.id)
Just curious and I doubt it.
This should work:
current_user.networks.create(params[:network])
But your code implies you are not using strong_parameters, or checking the validation of your objects. Your controller should contain:
def create
#network = current_user.networks.build(network_params)
if #network.save
# good response
else
# bad response
end
end
private
def network_params
params.require(:network).permit(:list, :of, :safe, :attributes)
end

Resources