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:
- What are all possible subject types?
- 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