/ rails

Improve ActiveRecord polymorphic associations

Polymorphic associations in ActiveRecord are very useful. Especially for attachable models like Comments, Attachments, Likes. However, sometimes we define them too loosely.

Here is an example of a Comment model. Its subject is polymorphic, allowing us to add comments to any ActiveRecord model.

class Comment < ApplicationRecord
  belongs_to :subject, polymorphic: true, inverse_of: :comments
end

Two things are bothering me with this code:

  1. What are all possible subject types?
  2. Any model can have comments. What if I don't want that?

To address my worries, I explicitly list and validate all allowed classes.

class Comment < ApplicationRecord
   SUBJECT_TYPES = [Post, Message, Category, Discussion::Thread]
   
   belongs_to :subject, polymorphic: true, inverse_of: :comments

   validates :subject_type, inclusion: { in: SUBJECT_TYPES.map(&:name) } 

This solves my issues.

I need this functionality basically everywhere where I have a polymorphic association. So, I moved it to "ApplicationRecord".

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  class << self
    def belongs_to_polymorphic(name, allowed_classes:, **options)
      belongs_to name, polymorphic: true, **options
      validates "#{name}_type", inclusion: { in: allowed_classes.map(&:name) }
      define_singleton_method("#{name}_types") { allowed_classes }
    end
  end
end

All ActiveRecord models inherit from "ApplicationRecord":

class Comment < ApplicationRecord
  belongs_to_polymorphic :subject, allowed_classes: [Post, Message, Category, Discussion::Thread]
end

When I extracted this. I noticed another pattern, I almost always do with polymorphic associations. Adding named scopes for each of the corresponding models.

class Comment < ApplicationRecord
  belongs_to_polymorphic :subject, allowed_classes: [Post, Message, Category, Discussion::Thread]

  scope :with_subject, ->(subject_class) { where(subject_type: subject_class.name) }
  scope :with_subject_post, -> { with_subject(Post) }
  scope :with_subject_message, -> { with_subject(Message) }
  scope :with_subject_category, -> { with_subject(Category) }
end

Since we already have ApplicationRecord.belongs_to_polymorphic we can add this functionality there.

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  class << self
    def belongs_to_polymorphic(name, allowed_classes:, **options)
      belongs_to name, polymorphic: true, **options
      validates "#{name}_type", inclusion: { in: allowed_classes.map(&:name) }
      define_singleton_method("#{name}_types") { allowed_classes }
      
      # generates a generic finder method
      define_singleton_method("with_#{name}") do |type|
        type = case type
               when Class then type.name
               when String then type
               else type.class.name
               end
        where("#{name}_type" => type)
      end

      # generates scope for each allowed class
      allowed_classes.each do |model|
        scope "with_#{name}_#{model.name.underscore.tr('/', '_')}", -> { where("#{name}_type" => model.name) }
      end
    end
  end
end

Conclusion

Having belongs_to_polymorphic cleaned up a lot of my models.

  • I can clearly see what models are used as a subject for the relationship
  • I have validations to ensure the proper models are being used
  • I have a standardly named scope and finder methods