[Rails Notes] Rails Concerns, and an Example of Wrapping Polymorphic Association

[Rails Notes] Rails Concerns, and an Example of Wrapping Polymorphic Association

Sometimes, we would want to DRY the code a little bit by extracting some common behaviors/functions. If the entities where these common behaviors belong are similar in essence, we may consider using a parent class. But in other cases, it’s not an easy job to find a parent class for something that share common behavior, say, “fly”, but are totally different in essence – like bird, kite and plane.

In Java, a common practice is to use interface. Basically, defining common functions (optionally with a default implementation) in the interface and implement them in concrete classes. In Ruby, Module is playing the similar role. Moreover, Rails provide a nice wrapper for that – ActiveSupport::Concern.

Some notes on using Concern:

  • Purpose: extract common methods/behaviors.
  • Conditions: entities, who have those common behaviors, should be different in nature
  • It should be domain independent, i.e. not coupled with any class that include itself.

An example use of Rails Concern – polymorphic associations

Scenario:

Users can review multiple types of things – Document, Restaurant, etc. On the other hand, Document, Restaurant, etc. receives reviews from multiple users, and has methods – #positive? and #score – to return aggregated reviews performance.

We want a has_many ... through: :reviews relations between User and “reviewable”s, such as Document and Restaurant.

We certainly can do

has_many :reviews
has_many :documents, through: :reviews
has_many :restaurants, through: :reviews
...

in user.rb, and

has_many :reviews, dependent: :destroy
has_many :users, through: :reviews

def positive?
  ...
end

def score
  ...
end

for every “reviewable” model. But that is not DRY. We would want to build a polymorphic association, where users have many “reviewable” through Review. Also, suppose what we care about each “reviewable” is the overall review, i.e. #positive? and #score, but not the exact type of each one being review.

The use of polymorphism here exemplifies one of design principles:

Depend on abstractions, not concrete classes.

where Reviewable Concern acts as the abstraction layer.

Solution

In Review model (which is the “join table”):

class Review < ActiveRecord::Base
  belongs_to :user
  belongs_to :reviewable, polymorphic: :true
  ...
end

In Reviewable concern – set up common associations, extract abstract methods for the reviewables:

module Reviewable
  extend ActiveSupport::Concern

  included do
    has_many :reviews, as: :reviewable, dependent: :destroy
    has_many :users, through: :reviews
  end

  def score
    raise NotImplementedError
  end

  def positive?
    raise NotImplementedError
  end
end

Then, in reviewable classes, include the Reviewable concern, and override methods:

class Document < ActiveRecord::Base
  include Reviewable
  ...
end
class Restaurant < ActiveRecord::Base
  include Reviewable
  ...
end

Lastly on the users side:

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

Another thing that might be a little tricky, is the schema of Review DB table. Migration:

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

Pros & Cons in this example

By wrapping polymorphic association in Rails Concern, it DRY the code on the reviewables side. However, it does not reduce the manual work on the users side. Every time a new reviewable is associated, a new has_many ... line should be manually added. Also, the abstraction is on application layer. In other words, it does not add a DB table, and thus does not add the functionality of fetching all associated reviewables (i.e. user.reviewables) automatically.

Related links & references

Leave a Reply

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