[Rails Notes] Doubly polymorphic has_many: :through association

[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 Reviewable and/or Reviewer object

Review.where(reviewable: restaurant, reviewer: user)
document.reviews.where(reviewer: organization)
  • Filter reviews by a Reviewable and/or Reviewer 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 ReviewableEvent, 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
  ...

References:

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.