Keeping your Polymorphic "like"s Code DRY
I was recently setting up a social media app with BlogPosts, PhotoPosts, and Comments – and I needed to have users be able to “like” and “comment” on all three of those models. My first crack at it involved some duplicate code. And that’s fine. In Sandi Metz’s POODR, she recommends waiting until you see a pattern 3 times before refactoring. And that’s exactly what happened, so here are some ways I DRYed up my code.
Approach 1: Extract class methods into a Concern module
Instead of having all 3 models have duplicate code like this:
# app/models/blog_post.rb
class BlogPost < ApplicationRecord
belongs_to :user
has_many :likes, as: :likeable
has_many :comments, as: :commentable
# ...
validates :user_id, :user, :body, presence: true
def has_likes?
likes.any?
end
def has_comments?
comments.any?
end
end
# app/models/photo_post.rb
class PhotoPost < ApplicationRecord
# ...
belongs_to :user
has_many :likes, as: :likeable
has_many :comments, as: :commentable
def has_likes?
likes.any?
end
def has_comments?
comments.any?
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
has_many :likes, as: :likeable
# ...
def has_likes?
likes.any?
end
end
You can extract the duplicated likes
and comments
code into their own concern files like this:
# app/models/concerns/liking.rb
module Liking
extend ActiveSupport::Concern
included do
has_many :likes, as: :likeable
end
def has_likes?
likes.any?
end
end
# app/models/concerns/commenting.rb
module Commenting
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable
end
def has_comments?
comments.any?
end
end
And then DRY up those models with include
statements like this:
# app/models/blog_post.rb
class BlogPost < ApplicationRecord
belongs_to :user
include Liking
include Commenting
# ...
validates :user_id, :user, :body, presence: true
end
# app/models/photo_post.rb
class PhotoPost < ApplicationRecord
# ...
belongs_to :user
include Liking
include Commenting
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
include Liking
end
Wow. That’s much drier.
Approach 2: Adapt Methods to Handle Multiple Types of Objects
There are plenty of ways to do this, some of which get into metaprogramming (which is thrilling, but sometimes a little dense). Here I’ll show you an example of a simple refactor and one with some metaprogramming.
Example 1: The Generic Object
Much like above, here are 3 view helpers with really similar methods.
# app/helpers/blog_posts_helper.rb
module BlogPostsHelper
def display_users_who_liked_blog_post(blog_post)
blog_post.likes.map do |like|
link_to like.user.name, like.user
end
end
# ...
end
# app/helpers/photo_posts_helper.rb
module PhotoPostsHelper
def display_users_who_liked_photo_post(photo_post)
photo_post.likes.map do |like|
link_to like.user.name, like.user
end
end
# ...
end
# app/helpers/comments_helper.rb
module CommentsHelper
def display_users_who_liked_comment(comment)
comment.likes.map do |like|
link_to like.user.name, like.user
end
end
# ...
end
Not only are those method names really long and specialized, they’re repeated 3 times. Ack! Fortunately, Ruby lets you call methods on variables all over the place, so using a generic object in place of a specific one aint no thang. Just ensure you have the same relationships to likes
across each model receiving the call (which is streamlined if you’ve employed the concern approach above), then use a generic object like this:
# app/helpers/likes_helper.rb
module LikesHelper
def display_users_who_liked(object)
object.likes.map do |like|
link_to like.user.name, like.user
end
end
# ...
end
Ahhhhhh… much drier.
Are you ready to get a little meta?
Example 2: Getting Meta by Extracting a Class from an Object and Using send
Using send
is probably one of my favorite means of metaprogramming. It’s so flexible! Every time I find myself thinking “aww jee, if I could only customize this method and it would solve everything,” it usually means send
is in order. Personally, I think you have to strike a balance between DRY and readable. Since we spend most of our time reading code, it makes sense to have easily readable code. Future me always appreciates when past me has been thoughtful in this regard. Though this method gets a little dense, I think it’s still readable enough.
When a user is looking at a stream of content (blog posts, photos, and their respective comments), I needed to give them the option to Like
and Unlike
any of these objects. This helper method toggles the Like
/ Unlike
links.
# app/helpers/likes_helper.rb
module LikesHelper
def display_like_unlike(object)
# capture the class of the object being passed in to the method
klass = object.class.to_s
# The User model has an association for each object it likes (posts_they_like,
# photos_they_like, comments_they_like). Using `send` here allows us to build
# a string to match that association
if current_user.send("#{klass.downcase.pluralize}_they_like").include?(object)
# Here we grab the current_user's `like` from the list of `like` for this object
like = Like.current_user_like(object, current_user)
# then we use the `klass` again to locate the record on the `likeable` table info
link_to 'Unlike', polymorphic_url([current_user, object, like], likeable: klass), method: :delete
else
link_to 'Like', polymorphic_url([current_user, object, :likes], likeable: klass), method: :post
end
end
end
Okay, so it’s a little dense and requires some research on building polymorphic_url
s, but I like it better than the crazy conditional that would have been in its place. Like
s are a little complex, and I enjoyed working through this puzzle. I hope it sheds some light on some approach options for you too.