[Rails Notes] Doubly polymorphic has_many: :through association
One of principles in OO design that has been frequently mentioned, is that “Depend on abstractions, not implementations”. In Rails, we may want abstractions into has_many: :through associations. In a previous post, I have discussed one example where User has many abstract Reviewables through Reviews.
Quick Recap:
# schema of review table
class CreateReviews < ActiveRecord::Migration
def change
create_table :reviews do |t|
t.integer :user_id
t.integer :reviewable_id
t.string :reviewable_type
t.timestamps null: false
end
end
end
# in models/review.rb
class Review < ActiveRecord::Base
belongs_to :user
belongs_to :reviewable, polymorphic: :true
...
end
# in models/concerns/reviewable.rb
module Reviewable
extend ActiveSupport::Concern
included do
has_many :reviews, as: :reviewable, dependent: :destroy
has_many :users, through: :reviews
end
end
# in concrete reviewable classes
class Document < ActiveRecord::Base
include Reviewable
...
end
class Restaurant < ActiveRecord::Base
include Reviewable
...
end
# in models/user.rb
class User < ActiveRecord::Base
has_many :reviews
has_many :documents, through: :reviews, source: :reviewable, source_type: :'Document'
has_many :restaurants, through: :reviews, source: :reviewable, source_type: :'Restaurant'
...
end
Abstraction on both sides
In the example above, the abstraction/polymorphism only happens on the Reviewable side. On the other side, the entity is a concrete class, User. While not common, we may want the abstraction on both sides in certain extremely complex cases. In this case, we assume Organization is another type of reviewer.
To achieve that, create a new abstraction Reviewer, and take similar approach on the Reviewable side.
The first step is to update the schema of Review – it will need associated entity id and type on both ends:
# in db/schema.rb
create_table "reviews", force: :cascade do |t|
t.integer "reviewer_id"
t.string "reviewer_type"
t.integer "reviewable_id"
t.string "reviewable_type"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "content"
end
Create a Concern – Reviewer – just to DRY the code.
module Reviewer
extend ActiveSupport::Concern
included do
has_many :reviews, as: :reviewer, dependent: :destroy
has_many :documents, through: :reviews, source: :reviewable, source_type: :'Document'
has_many :restaurants, through: :reviews, source: :reviewable, source_type: :'Restaurant'
end
def review(reviewable, content)
reviews.create(reviewable: reviewable, content: content)
end
end
Update the Reviewable Concern, to specify the associated concrete classes. Specifically, source and source_type options are required:
module Reviewable
extend ActiveSupport::Concern
included do
has_many :reviews, as: :reviewable, dependent: :destroy
# before:
# has_many :users, through: :reviews
# ----
# after:
has_many :users, through: :reviews, source: :reviewer, source_type: 'User'
has_many :organizations, through: :reviews, source: :reviewer, source_type: 'Organization'
end
end
Then include Reviewer Concern in corresponding entities:
class Organization < ActiveRecord::Base
validates :name, presence: true
include Reviewer
end
class User < ActiveRecord::Base
validates :name, presence: true
include Reviewer
end
Operations on entities
With the setup above, we can perform certain operations on these entities:
- Call #review (wrapped in Reviewer Concern) from user
user.review(restaurant, 'some comments')
- Display all reviews from/to a
Reviewer/Reviewable
user.reviews
document.reviews
- Filter reviews by a
Reviewableand/orReviewerobject
Review.where(reviewable: restaurant, reviewer: user)
document.reviews.where(reviewer: organization)
- Filter reviews by a
Reviewableand/orReviewertype
Review.where(reviewable_type: Document)
user.reviews.where(reviewable_type: Restaurant)
Caveats
Every time a new entity type is added to either side, it needs to be manually added to the concern. For example, if we want to add a new Reviewable – Event, we need to add below line to Reviewer Concern:
# in models/concerns/reviewer.rb
included do
...
has_many :events, through: :reviews, source: :reviewable, source_type: :'Event'
end
...

