Alex Stockwell

UX, DevOps, Fatherhood, Miscellany. Not in that order.

Polymorphic Associations in Rails 4, Part 2

Pain point

This is a follow-up on Part 1, primarily to address a common headache with creating/updating polymorphic models with a single route. It starts with an error:

Error: Cannot build association medium. Are you trying to build a polymorphic one-to-one?

Relief

Briefly, here’s our starting point (see Part 1 for more details on arriving at this configuration):

# painting.rb
class Painting < ActiveRecord::Base
    belongs_to :medium, polymorphic: true
    accepts_nested_attributes_for :medium
end

# water_painting.rb
class WaterPainting < ActiveRecord::Base
    # For your sanity, you may want to use
    # :medium instead of :details
    has_one :painting, as: :details, dependent: :destroy
end

# oil_painting.rb
class OilPainting < ActiveRecord::Base
    # For your sanity, you may want to use
    # :medium instead of :details
    has_one :painting, as: :details, dependent: :destroy
end

# your_paintings_controller.rb

# ... snip ...
# GET /your_paintings/water/new
def new_water
    @painting = Painting.new(medium: WaterPainting.new)
end
# ... snip ...
# GET /your_paintings/oil/new
def new_oil
    @painting = Painting.new(medium: OilPainting.new)
end
# ... snip ...

In all likelihood, since you have different types of paintings, they will have different attributes/fields and you’ll want to be able to render different views for each.

The trouble comes when we want to have a generic create method/route for creating any type of painting:

# your_paintings_controller.rb

# POST /your_paintings/create
def create
    # This will throw an error!
    @painting = Painting.new your_painting_params

    respond_to do |format|
        if @painting.save
            format.html { redirect_to your_painting_path(@painting.id), notice: 'Your painting was successfully created.' }
        else
            format.html { render :new }
        end
    end
end
# ... snip ...
def your_painting_params
    params.require(:painting).permit!
end

The error is thrown when the POST request comes back to the server (running the generic create method we have above), and there’s no state on the server or in the params that Rails can use to determine which type of painting you intended to create.

I wanted to avoid having n create handlers (n being the total number of painting types) that were basically identical, and realized that I just needed to get the desired type communicated to the POST handler and everything thing could be DRYed out.

I updated each painting type’s new view with the following hidden field:

# new_water.html.erb
<h1>New Water Painting</h1>

<%= simple_form_for @painting, url: { controller: "your_paintings", action: "create" } do |f| %>

    <%= f.input :title %>

    <%= f.simple_fields_for :medium do |v| %>
        <%= v.input :source %>
    <% end %>

    # The all-important medium_type!
    <%= f.hidden_field :medium_type %>

    <%= f.button :submit %>
<% end %>

To leverage that field value on the server, I used the following approach to safely create the correct painting type:

# your_paintings_controller.rb

# All the different painting types' models:
PAINTING_TYPES = [WaterPainting, OilPainting]
# ... snip ...
def create

    begin
        medium_klass = PAINTING_TYPES.detect { |m| your_painting_params[:medium_type].classify.constantize == m }
    ensure
        unless medium_klass
            redirect_to your_paintings_water_path, alert: "Incorrect painting type (klass)" and return
        end
    end

    # Can't use these bad boys yet, Rails thinks
    # you're just adding random extra attrs to the
    # base Painting model, and doesn't like it much.
    # Save them for later though:
    medium_params = your_painting_params.delete :medium_attributes

    @painting = Painting.new your_painting_params

    # Ah! Now bring in the painting type's attrs:
    @painting.medium = medium_klass.new medium_params

    respond_to do |format|
        if @painting.save
            format.html { redirect_to your_painting_path(@painting.id), notice: 'Your painting was successfully created.' }
        else
            format.html { render :new }
        end
    end
end
# ... snip ...
def your_painting_params
    params.require(:painting).permit!
end

This approach creates the Painting base object, then adds the correct association object type and passes the correct attrs to each.