Search code examples
ruby-on-railspolymorphic-associationssti

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?


Solution

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