[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 Reviewable
s through Review
s.
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
Reviewable
and/orReviewer
object
Review.where(reviewable: restaurant, reviewer: user)
document.reviews.where(reviewer: organization)
- Filter reviews by a
Reviewable
and/orReviewer
type
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
...