Topic: How To: Direct access to Polymorph models w/ Join Models

I'm new to rails and I spent the better part of the day learning how to make Polymorphs and Join Models work together like I wanted so I figured I'd post my solution for anybody who might also like this kind of functionality.

Here's the idea.  I want to create a central location where all of my site's content lives.  Content I'm storing includes items like "videos", "images", and "documents".  This content will be referenced by recipient models such as "products", "blog_posts", and "user_profiles" and be able to be ordered.  Rather than create join tables for every content type, I use a polymorphic table called "resources" which refers to the different content types and which then has join models like "product_resources" and "blog_post_resources".  At the end of the day, I want my recipient models to be able to directly access collections for the different content types.

Here are the migrations for the Polymorphic holder table, resources and the content type tables.

class CreateResources < ActiveRecord::Migration
  def self.up
    create_table :resources do |t|
      t.integer :item_id
      t.string :item_type, :name
      t.text :description, :teaser
      t.timestamps
    end
  end

  def self.down
    drop_table :resources
  end
end

class CreateImages < ActiveRecord::Migration
  def self.up
    create_table :images do |t|
      t.string :src
    end
  end

  def self.down
    drop_table :images
  end
end

class CreateVideos < ActiveRecord::Migration
  def self.up
    create_table :videos do |t|
      t.string :url, :content_type
      t.timestamps
    end
  end

  def self.down
    drop_table :videos
  end
end

class CreateDocuments < ActiveRecord::Migration
  def self.up
    create_table :documents do |t|
      t.string :content_type, :attachment
      t.binary :binary_data
      t.timestamps
    end
  end

  def self.down
    drop_table :documents
  end
end


Here's the migration for the recipient model I've implemented "product".
class CreateProducts < ActiveRecord::Migration
  def self.up
    create_table :products do |t|
      t.string :name, :sku
      t.decimal :price, :precision => 10, :scale => 2
      t.text :description
      t.timestamps
    end
  end

  def self.down
    drop_table :products
  end
end


And here's the migration for the ProductResources Join Model.
class CreateProductResources < ActiveRecord::Migration
  def self.up
    create_table :product_resources do |t|
      t.integer :product_id, :resource_id, :position
      t.timestamps
    end
  end

  def self.down
    drop_table :product_resources
  end
end


Ok, so now that we know what the schema looks like, let's have a look at the associations we set up with our models.  Everything is pretty straightforward at first.  Let's start with the Resource model.
class Resource < ActiveRecord::Base
  belongs_to :item, :polymorphic => true
  has_many :product_resources
  has_many :products, :through => :product_resources
end

Now let's take a look at the content type models, Image, Video, and Document. 
class Image < ActiveRecord::Base
  has_one :resource, :as => :item
end

class Video < ActiveRecord::Base
   has_one :resource, :as => :item
end

class Document < ActiveRecord::Base
  has_one :resource, :as => :item
end


Now let's look at the Join Model, ProductResources.
class ProductResource < ActiveRecord::Base
  belongs_to :product
  belongs_to :resource
end

Ok, so here's where it gets complicated.  Initally here's what I had for the Product model. 
class Product < ActiveRecord::Base
  has_many :product_resources
  has_many :resources,
           :through => :product_resources
end

With this code I was able to make calls like:
myProduct = Product.find(:id)
myResources = myProduct.resources

But what I really wanted to be able to do was calls like:
#WON'T WORK...yet
myProduct = Product.find(:id)
myImages = myProduct.images
myDocuments = myProduct.documents
myVideos = myProduct.videos

#And as a bonus, calls like
myFirstVideo = myProduct.video
myFirstImage = myProduct.image
myFirstDocument = myProduct.document


Ok, so here's the code to make that happen.  To create the relationships to the inner resource items, you could do it a couple of ways.  I went with a custom "has_resources" association, the inspiration for which I got from http://weblog.jamisbuck.org/2007/1/9/ex … ociations.  Here's the code.  It belongs at the end of config/environment.rb.
# at the very bottom of config/environment.rb, after the last "end"
class Module
  def has_resources(name)
    class_eval <<-STR
      def #{name}(reload=false)
        @#{name} ||= nil
        if @#{name}.nil? || reload
          @#{name} = []
          resources.find_all_by_item_type('#{name.to_s.classify}').each{|i|
            i.item.resource = i
            @#{name} << i.item
          }
        end
        @#{name}
      end
      def #{name.to_s.singularize}
        #{name}[0]
      end
    STR
  end
end

It looks kind of crazy, but it saves us some time from having to create 2 def blocks for each different content type.  What this does is use the find_by_item_type method of the resources class to filter down to individual content types.  We then loop through the record set and convert all the resource objects to their appropriate type by calling their item method, storing the result in an array and finally returning the array.  To save db queries, we check to make sure array hasn't already been loaded before and if it has, we just return the array, unless we want to force a reload by passing true to the function (ie "myProduct.images(true)").  With this block, we can now make calls like "myProduct.images" and "myProduct.documents"

The second def block creates our singular methods which just grab the first element returned by the plural method.  With this block, you can now make calls like "myProduct.image" and get an Image object.

With the has_resources association now available, let's go back to the the Product model and add a few more lines.

class Product < ActiveRecord::Base
  has_many :product_resources
  has_many :resources,
           :through => :product_resources,
           :select => 'product_resources.position, resources.*',
           :order => 'position'
  has_resources :images
  has_resources :documents
  has_resources :links
  has_resources :sounds
  has_resources :videos
end

In addition to the has_resources associations, we also added some more logic into the has_many :resources call.  The :select => declaration allows us access to the position variable stored in the join table which lets us then order on that position number.  This then makes the singular methods (.image, .video, .document) relevant since we now are getting the item with the highest priority.

A few things to keep in mind here.  You aren't getting full has_many type of functionality out of this.  All that comes back for the myProduct.images is a collection of images.  You also can't really save to the images collection directly (ie. myProduct.images << myImage won't save permanently).  You'd want to do myProduct.resources << myImage.resource then if you needed to reload the images collection you could call myProduct.images(true) and you'd get an updated image collection.

Also, if anyone has a better way to do this, I'd love to hear it.  Like I said, rails is brand new to me, so I'm open to any and all suggestions and advice.  Hope this helps someone.

Last edited by shoeman22 (2007-12-12 04:06:28)