Mixing Join Models and Polymorphism for Flexible Data Modeling

Problem

Contributed by: Diego Scataglini

Your application contains models in a many-to-many relationship. The relationship exhibits important characteristics that merit the creation of a full-fledged model to describe them. For example, you want to model a reader's subscription to one or more entities such as newspaper, magazine, or blog.

Solution

For this recipe, create a Rails project called polymorphic:

$ rails polymorphic

From the root directory of the application, generate the following models:

$ ruby script/generate model Reader
 exists app/models/
... create db/migrate/001_create_readers.rb
$ ruby script/generate model Subscription
... create db/migrate/002_create_subscriptions.rb
$ ruby script/generate model Newspaper
... create db/migrate/003_create_newspapers.rb
$ ruby script/generate model Magazine
... create db/migrate/004_create_magazines.rb

Now, add table definitions for each of the migrations created by the generator:

db/migrate/001_create_readers.rb:

class CreateReaders < ActiveRecord::Migration
 def self.up
 create_table :readers do |t|
 t.column :full_name, :string
 end
 Reader.create(:full_name => "John Smith")
 Reader.create(:full_name => "Jane Doe")
 end
 def self.down
 drop_table :readers
 end end

db/migrate/002_create_subscriptions.rb:

class CreateSubscriptions < ActiveRecord::Migration
 def self.up
 create_table :subscriptions do |t|
 t.column :subscribable_id, :integer
 t.column :subscribable_type, :string
 t.column :reader_id, :integer
 t.column :subscription_type, :string
 t.column :cancellation_date, :date
 t.column :created_on, :date
 end
 end
 def self.down
 drop_table :subscriptions
 end end

db/migrate/003_create_newspapers.rb:

class CreateNewspapers < ActiveRecord::Migration
 def self.up
 create_table :newspapers do |t|
 t.column :name, :string
 end
 Newspaper.create(:name => "Rails Times")
 Newspaper.create(:name => "Rubymania")
 end
 def self.down
 drop_table :newspapers
 end end

db/migrate/004_create_magazines.rb:

class CreateMagazines < ActiveRecord::Migration
 def self.up
 create_table :magazines do |t|
 t.column :name, :string
 end
 Magazine.create(:name => "Script-generate")
 Magazine.create(:name => "Gem-Update")
 end
 def self.down
 drop_table :magazines
 end end

Ensure your database.yml file is configured to access your database, and migrate your database schema:

$ rake db:migrate

Define your Subscription model as a polymorphic model, and specify its relationship to Reader:

app/models/subscription.rb:

class Subscription < ActiveRecord::Base
 belongs_to :reader
 belongs_to :subscribable, :polymorphic => true end

Now reciprocate the relationship from the Reader side.

app/models/reader.rb:

class Reader < ActiveRecord::Base
 has_many :subscriptions end

Next, define your Magazine and Newspaper classes to have many subscriptions and subscribers:

app/models/magazine.rb:

class Magazine < ActiveRecord::Base
 has_many :subscriptions, :as => :subscribable
 has_many :readers, :through => :subscriptions end

app/models/newspaper.rb:

class Newspaper < ActiveRecord::Base
 has_many :subscriptions, :as => :subscribable
 has_many :readers, :through => :subscriptions end

Now update Subscription and Reader classes as follows:

app/model/subscription.rb:

class Subscription < ActiveRecord::Base
 belongs_to :reader
 belongs_to :subscribable, :polymorphic => true
 belongs_to :magazine, :class_name => "Magazine",
 :foreign_key => "subscribable_id"
 belongs_to :newspaper, :class_name => "Newspaper",
 :foreign_key => "subscribable_id"
end

app/models/reader.rb:

class Reader < ActiveRecord::Base
 has_many :subscriptions
 has_many :magazine_subscriptions, :through => :subscriptions, 
 :source => :magazine, 
 :conditions => "subscriptions.subscribable_type = 'Magazine'"
 has_many :newspaper_subscriptions, :through => :subscriptions, 
 :source => :newspaper, 
 :conditions => "subscriptions.subscribable_type = 'Newspaper'"
end

You now have a bidirectional relationships between your Reader model and the periodicals Newspaper and Magazine.

>> reader = Reader.find(1)
>> newspaper = Newspaper.find(1)
>> magazine = Magazine.find(1)
>> Subscription.create(:subscribable => newspaper, :reader => reader,
 :subscription_type => "Monthly")
>> Subscription.create(:subscribable => magazine, :reader => reader,

 :subscription_type => "Weekly")
)
>> reader.newspaper_subscriptions
=> [#<Newspaper:0x36c1008 @attributes={"name"=>"Rails Times", 
"id"=>"1"}>]
>> reader.magazine_subscriptions
=> [#<Magazine:0x36bca30 @attributes={"name"=>"Script-generate", 
"id"=>"1"}>]
>> newspaper.readers
=> [#<Reader:0x36a3314 ...
>> magazine.readers
=> [#<Reader:0x36a3314 ...

Discussion


In this example, you created relationships between the polymorphic models Magazine and Newspaper, and Reader. Polymorphic associations through a full-fledged model can be tricky to set up correctly but can help to model your domain more accurately. The key to specifying the relationship between Reader and Magazine was to use the :source option to identify the Magazine class, and the :tHRough option to specify that a Subscription links a Reader to a Magazine. Spend some time studying the previous code, and be sure to use the console to explore the model objects.
The combined power of has_many :through and polymorphic associations provides you with a slew of dynamic methods to experiment with. The easiest way to figure out what methods are available is to grep them.
First, open a Rails console:
$ ruby script/console

Then enter the following command to view the dynamic methods:
>> puts reader.methods.grep(/subscri/).sort
add_magazine_subscriptions add_newspaper_subscriptions add_subscriptions build_to_magazine_subscriptions build_to_newspaper_subscriptions build_to_subscriptions create_in_magazine_subscriptions create_in_newspaper_subscriptions create_in_subscriptions find_all_in_magazine_subscriptions find_all_in_newspaper_subscriptions find_all_in_subscriptions find_in_magazine_subscriptions find_in_newspaper_subscriptions find_in_subscriptions has_magazine_subscriptions?
has_newspaper_subscriptions?
has_subscriptions?
remove_magazine_subscriptions remove_newspaper_subscriptions remove_subscriptions magazine_subscriptions magazine_subscriptions_count newspaper_subscriptions newspaper_subscriptions_count subscription_ids=
subscriptions subscriptions=
subscriptions_count validate_associated_records_for_subscriptions

>> puts magazine.methods.grep(/(reade|subscri)/).sort
add_readers add_subscriptions build_to_readers build_to_subscriptions create_in_readers create_in_subscriptions find_all_in_readers find_all_in_subscriptions find_in_readers find_in_subscriptions generate_read_methods generate_read_methods=
has_readers?
has_subscriptions?
readers readers_count remove_readers remove_subscriptions subscription_ids=
subscriptions subscriptions=
subscriptions_count validate_associated_records_for_subscriptions

Because you used a join model for your many-to-many relationship setup, you can easily add both data and behavior to the subscriptions.
If you look back at db/migrate/002_create_subscriptions.rb, you'll see that you gave the subscription model attributes of its own. It doesn't just link records to each other; it holds important information, such as the date the subscription was created, the date the subscription expires, and the type of subscription (monthly or weekly).
You can refine the models even further. Say you want to give Magazine a method to return subscription cancellations:
app/models/magazine.rb:
class Magazine < ActiveRecord::Base
 has_many :subscriptions, :as => :subscribable
 has_many :subscribers, :through => :subscriptions 
 has_many :cancellations, :as => :subscribable,
 :class_name => "Subscription" ,
 :conditions => "cancellation_date is not null"
end

Test your new methods in script/console:
>> Magazine.find(:first).cancellations_count
=> 0
>> m = Magazine.find(:first).subscriptions.first
=> #<Subscription:0x32d8a18 @attributes={"cre ....
>> m.cancellation_date = Date.today
=> #<Date: 4908027/2,0,2299161>
>> m.save
=> true
>> Magazine.find(:first).cancellations_count
=> 1

See Also