Rails: Has One Through Polymorphic Relation
As I’ve described in my blog post “Rails Polymorphic Associations” I’ve stated that polymorphic relations in Rails are best for scenarios where a model may “belong to” one of many other kinds of model. So a Comment model with the polymorphic commentable relationship can belong to any other record in your Rails app. This is a child to be adopted by any of one many parents kind of relationship.
But recently I wanted to do the inverse of this and say that an Item model can be one of any Product. An Item is something you can keep in a Location and a Product really can be anything. So I did some digging and found a few short examples online suggesting using a middle-man model with to belongs_to relations with one being polymorphic to allow the polymorphic child type of relation. This is a Item has_many polymorphic Products through MiddleMan kind of thing. So I went ahead and tried it out and have been very successful in it’s design. So now I’m here to share with you how to do it as well. This will be more “reading source code” than commentary. You’re more than welcome to ask questions.
The Models
I’ll briefly show how I set up the models.
rails g model Location name:string:index # then modified the migration for the following # t.string :name, unique: true, null: false rails g model Item placeable:references{polymorphic} rails g model ItemProduct item_id:integer:index product:references{polymorphic} # then modified the migration for the following # t.integer :item_id, unique: true, null: false rails g model Book isbn:text:index title:text:index author:text:index publisher:text:index year:integer:index rails g model Movie title:text:index director:text:index writer:text:index year:integer:index length:text:index # then modified the migration for the following # t.text :title, null: false
I choose to use the ‘text’ type rather than ‘string’ because I’m using a PostgreSQL database and they’re optimized it in such a way where the ‘text’ type is as performant as ‘string’ and even has extra features you can take advantage of. I chose to give Item a polymorphic placeable reference for it’s location as locations will be marked shelves but items can be placed in containers on shelves and those can be labelled as a place which stores items.
So now we run rake db:create && rake db:migrate and all of our models are generated in the database. Next we need to edit the model relationships in app/models to infer what relationships these models have to each other. Here’s how I have them. Don’t mind the full model files here, I thought it would be nice to share everything I added to them.
# app/models/location.rb class Location < ActiveRecord::Base has_many :items, as: :placeable validates_presence_of :name validates :name, format: { with: /\A[A-Z0-9]+\z/, message: "only allow uppercase alphanumeric labels" } default_scope { order(name: :ASC) } end # app/models/item.rb class Item < ActiveRecord::Base belongs_to :placeable, polymorphic: true validates_presence_of :placeable_id has_one :item_product, dependent: :destroy has_one :book, through: :item_product, source: :product, source_type: 'Book' has_one :movie, through: :item_product, source: :product, source_type: 'Movie' accepts_nested_attributes_for :item_product, allow_destroy: true def self.kinds [Book, Movie] end def location placeable end def product item_product&.product end def product_name product&.class&.name&.downcase end end # app/models/item_product.rb class ItemProduct < ActiveRecord::Base belongs_to :item belongs_to :product, polymorphic: true, dependent: :destroy def place item.placeable end end # app/models/book.rb class Book < ActiveRecord::Base has_one :item_product, as: :product has_one :item, through: :item_product include Locatable # def location; item_product.item.placeable end include Referable # def ref; item_product.item end end # app/models/movie.rb class Movie < ActiveRecord::Base has_one :item_product, as: :product has_one :item, through: :item_product include Locatable # def location; item_product.item.placeable end include Referable # def ref; item_product.item end end
With the design being for a polymorphic child type of relationship I also wanted the page views to be polymorphic in design.
Controllers/Views
When I generated my structure I actually used rails generate resource rather than scaffolding. And to my surprise I found my controllers bare with no methods. I found this an interesting experience as it let me feel my way through and see how I might do things differently.
app/controllers/location_controller.rb
class LocationsController < ApplicationController before_action :set_location, only: [:edit, :show] def index @locations = Location.all end def new @location = Location.new end def create begin if location_params[:name][/\.\./] Range.new(*location_params[:name].split("..")).to_a else location_params[:name].del(" ").split(',') end end.each do |n| @location = Location.new(name: n) break unless @location.save end if @location.persisted? redirect_to "/" else render 'new' end end def show end private def location_params params.require(:location).permit(:id, :name) end def set_location @location = Location.where(id: params[:id].sift("0".."9")).first end end
You can see I got a bit creative with the create method. I decided I wanted to be able to create locations in batches so I can save time. The String#sift method in set_location, and the String#del method in create, are both from my gem MightyString and they allow you to filter your strings easily.
app/views/locations/index.html.erb
<% flash.each do |name, msg| -%> <%= content_tag :div, msg, class: name %> <% end -%> <div class="locations-main"> <h3>Locations [<%= link_to "New", new_location_path %>]</h3> <%= link_to "Items", items_path %> <ul> <% @locations.each do |location| %> <li> <%= link_to location.name, location_path(location) %> </li> <% end %> </ul> </div>
In building this project I really wanted to go with a bare bones functional design. I believe that’s how all projects should start. Getting into styling things too much early on just causes more work later. Here’s a bit of style for this page:
.locations-main ul li { font-family: helvetica; font-size: 20px; float: left; margin-right: 12px; display: inline; }
I find that using HTML lists permits a lot more flexibility with CSS style rather than using tables.
And here’s the new location page:
app/views/locations/new.html.erb
Accepts three different kinds of input. <ol> <li>Single location: <strong>A1</strong></li> <li>Comma separated locations: <strong>A1, A2, A3</strong></li> <li>Range of locations: <strong>A1..A9</strong></li> </ol> <%= form_for :location, url: locations_path do |f| %> <% if @location.errors.any? %> <div id="error_explanation"> <h2> <%= pluralize(@location.errors.count, "error") %> prohibited this article from being saved: </h2> <ul> <% @location.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <p> <%= f.label :Location %><br> <%= f.text_field :name %> </p> <p> <%= f.submit %> </p> <% end %> <%= link_to 'Back', locations_path %>
And that’s it for locations. It’s a very simple implementation. I don’t have any deletion or edit feature in it. I don’t need it right now. If I make a mistake I just open up the Rails console and look it up. Location.where(name: “A55”).first.update(name: “A5”)
Now comes the more complex controller/view…
Polymorphic Item Products
I’ll go over controller details before discussing the view on this one.
app/controllers/item_controller.rb
class ItemsController < ApplicationController def index @items = if Item.kinds.map {|k|k.to_s.downcase}.include? params[:kind] Item.joins(params[:kind].to_sym) else Item.all end.includes(:placeable).sort_by {|i| i.placeable.name } end def new @item = Item.new(placeable_id: item_params[:placeable_id]) @item.build_item_product( product: item_kind.new ) end def create @item = Item.new( placeable_id: item_params[:placeable_id], placeable_type: item_params[:placeable_type] || 'Location' ) @item.build_item_product( product: item_kind.new( item_params[:item_product_attributes] ) ) if @item.save redirect_to @item else flash[:error] = @item.errors.first redirect_to "/" end end def show @item = Item.find(params[:id]).item_product.product end def update item = Item.find(params[:id]) item.update!(item_params) redirect_to item end def destroy item = Item.find(params[:id]) item.destroy redirect_to "/" end private def item_params params.require(:item).permit(:kind, :placeable_id, :placeable_type, item_product_attributes: ipa) end def ipa item_kind&.column_defaults&.keys.try(:-,[:id,:created_at,:updated_at]) end def item_kind Item.kinds.detect {|k| k.name == params.dig(:item,:kind) } end end
The index checks the Item.kinds method to see if the parameter for a kind matches the predefined acceptable models for Item. If it does match the results will be filtered to only the kind given (e.g. Books or Movies). This allows the index page to switch the results of it’s content based on kinds, or everything. I found that using includes(:placeable) brought the query down from one for every item to just two simple queries to the DB.
The new method controller first creates a new Item with the location provided in the parameters. It then builds a ItemProduct with a new “product” created under the parameter {:product => thing} . The item_kind is a private method at the end of the controller which picks a valid model from the Item.kinds method and creates a new instance of it with item_kind.new . Seeing the use of the polymorphic attribute :product reference an object directly was something new to me build_item_product( product: item_kind.new ). It’s cool that polymorphic references give us that much flexibility.
The create method here is pretty much doing the same thing as new. Except that is has all the attributes and performs a save action.
I’ve implemented the show method a bit deceptively. I’ve kept the @item instance variable when the object being return is the product (a book or movie). You’ll just have to keep this in mind when we get to the view.
The item_params needed to be polymorphic in whichever model it supported nested attributes for, so for that I have the ipa method get all of the database column names for the current product and remove [:id, :created_at, :updated_at] . Congratulations! You now have polymorphic nested attributes permitted.
Now the views
app/views/items/index.html.erb
<h3><%= link_to "<", "/" %> Items</h3> <div id="items-menu"> <ul> <% Item.kinds.each do |kind| %> <li><%= link_to kind.to_s.pluralize, items_path(kind: kind.to_s.downcase) %></li> <% end %> </ul> </div> <hr/> <table id="location-catalog" border="2"> <% @items.each do |item| %> <tr> <td>L: <%= item.location&.name %></td> <td><%= link_to item.product.class.name, item_path(item) %></td> <% Hash(item.product&.attributes).each do |c, v| next if ["id", "created_at", "updated_at"].include?(c) %> <td><table border="0"> <tr> <td><strong><%= c.humanize %></strong>:</td> <td><%= v %></td> </tr> </table></td> <% end %> </tr> <% end %> </table>
This view is rather simple in structure. The first link is simply a link to home with a less than symbol < . The next links are to each of the kinds of product item supports. It links to the items_path with a parameter of :kind which will show up in the address bar’s url to filter the pages contents. And in the table I render out a row for every kind of product by getting their attributes in hash form. I use the c variable to represent column and v to represent value. And that’s how the index works.
Now for the polymorphic “new” product page
app/views/items/new.html.erb
<h3>New <%= @item.product_name %></h3> <%= form_for @item do |f| %> <%= f.hidden_field :placeable_id, value: @item.placeable_id %> <%= f.hidden_field :kind, value: @item.item_product.product_type %> <%= render partial: "partials/#{@item.product_name}_fields", locals: {f: f} %> <%= f.submit %> <% end %>
The important part here is rendering the partial based on what the product name is. @item.product_name is a method in the Item model from earlier. It simply converts the model name to a string and down-cases it. So if the item is a book this page will fill in the rest of the form from the partial view at app/views/partials/_book_fields.html.erb and pass the form variable f into the partial’s scope. For each of the models I do create their own unique form fields and I keep it simple. Here’s the one for Book:
app/views/partials/_book_fields.html.erb
<ol> <%= f.fields_for :item_product, @item.product do |ip| %> <li>ISBN: <%= ip.text_field :isbn %></li> <li>TITLE: <%= ip.text_field :title %></li> <li>AUTHOR: <%= ip.text_field :author %></li> <li>PUBLISHER <%= ip.text_field :publisher %></li> <li>YEAR: <%= ip.text_field :year %></li> <% end %> </ol>
It’s the same design for all other model partials.
The show path
app/views/items/show.html.erb
<h3><%= link_to "<", location_path(@item.location.id) %> <%= @item.class.name %></h3> <table> <tr> <td> <strong>Location:</strong> </td> <td><strong> <%= dynaspan_select(@item.ref, :placeable_id, { choices: options_for_select(@item.location.class.pluck(:name,:id)), }) %> </strong></td> </tr> </table> <ol> <% @item.attributes.each do |c,v| %> <li><strong><%= c.humanize %></strong>: <%= v %></li> <% end %> </ol> <%= link_to :Remove, item_path(@item.ref), method: :delete, data: { confirm: "Are you sure?" } %>
Still keeping it simple. The item attributes are shown polymorphically with the hash and the c, v local variables representing column and value. One thing might jump out to you is the dynaspan_select method. This is a gem (DynaSpan) I’ve written that lets you “magically” click on text to change to an input field where all changes get submitted to the server without the page navigating away by submitting record updates via AJAX and then the input field vanishes like Houdini to the text of the new value. This method is just my way of simplifying something awesome and really sticking to the motto of “Keep code beautiful. There’s beauty in simplicity.” The use of dynaspan_select here allows the user to click the location, get a drop down menu, and instantly change the items location. 🙂
app/views/locations/show.html.erb
<h3><%= link_to "<", "/" %> Location: <%= @location.name %></h3> <%= form_tag(new_item_path, method: :get) do %> <%= hidden_field_tag 'item[placeable_id]', @location.id %> New Item: <%= select_tag 'item[kind]', options_for_select(Item.kinds.map(&:to_s)) %> <%= submit_tag("Create") %> <% end %> <hr/> <table id="location-catalog" border="2"> <% @location.items.each do |item| %> <tr> <td><%= link_to item.product.class.name, item_path(item) %></td> <% Hash(item.product&.attributes).each do |c, v| next if ["id", "created_at", "updated_at"].include?(c) %> <td><table border="0"> <tr> <td><strong><%= c.humanize %></strong>:</td> <td><%= v %></td> </tr> </table></td> <% end %> </tr> <% end %> </table>
This view has the only form you will need to select creating a new product as it will set which kind of item in the /items/new resource.
And that’s it!
This was a minimalist project for me to inventory my own belongings. It was enjoyable to do, educational, and helps me put all my warehouse experience to work in organizing. The website experience is seamless, simple, and easy on the eyes. I did not expect learning the use of a has_X-through scenario would work out so easily for me. But overall this was a great experience for me. I’m sure there are plenty of questions to ask about the code here so feel free to jump in!
After this went so well I thought I’d give a shot at converting an existing polymorphic relation with many has_one-throughs already with an extra middleman in it in a big project. Keep in mind that all the pages for the model are already built. Let me say it was not seamless. I put about 4 hours into it and got the 1st level of relations through it working (e.g. Contact through ProfileReference to Profile). But I didn’t get the Profile’s nested relations working and I already hacked through a lot of code. I ended up throwing all that work away by git stashing it and deleting the stash. Still a good learning experience.
As always I hope you’ve enjoyed this! Please comment, share, subscribe to my RSS Feed, and follow me on twitter @6ftdan!
God Bless!
-Daniel P. Clark
Image by Thomas Hawk via the Creative Commons Attribution-NonCommercial 2.0 Generic License.
makabde
April 3, 2016 - 11:04 am
This is exactly what I needed. I am currently building an API in rails and could not find a proper way of achieving this type of association.
I am wondering how you would do, so that the `show` method in the ItemsController returns not only a `Product` attributes but also the attributes of the `Item`.
Thanks!
Daniel P. Clark
April 3, 2016 - 4:08 pm
In that case I would change the show method in the controller to
And in the view simply treat @item for the item attributes @item.name, @item.description, etc. And for the product details along with it you could use the `product` method in the Item model which points to `item_product.product` and get the attributes along with the @item: eg) @item.product.keywords, @item.product.location, etc.
Assuming something may go wrong and you don’t want it to break when product is nil (this should never happen you should want it to break). You can write the view in such a way that it safely handles nil.
This is typically good design for has_many situations but you can use it for has_one.
Stephen Murdoch
January 17, 2017 - 3:56 am
Excellent writeup, I have a question though:
When I visit ‘/items/new’ I get “undefined method `new’ for nil:NilClass”
I know that this is caused by this line:
`@item.build_item_product( product: item_kind.new )`
This is happening because params[‘item’][‘kind’] (called by the **item_kind** method) is nil when you GET the :new action.
I know I can make it work by visiting /items/new?[item][kind]=Book but I don’t like that.
What technique are you using to ensure that item_kind returns something other than nil when visiting /items/new?
Cheers
Daniel P. Clark
January 17, 2017 - 8:25 am
There are three ways I might go about doing this. And in my opinion
can be very useful here.
If you want to have a default if no kind is given you can do
Daniel P. Clark
January 17, 2017 - 8:42 am
Looking at my code again I realized that item_kind should not be returning
nil
for you. This resource should only be loaded when a form has sent the proper parameters. If you are getting nil I would rewrite it for a redirect.This site wasn’t designed for visiting /items/new directly.
Daniel P. Clark
January 17, 2017 - 9:01 am
That was my mistake I forgot to post the locations view. I’ve updated the post and added it to the end. You should never visit /items/new directly (it wasn’t designed that way).
Stephen Murdoch
January 17, 2017 - 9:01 pm
Thanks! Everything works perfectly. Awesome article.