Rails STI and form_for

Written . Tagged Ruby on Rails, STI.

Rails’ form_for conveniently uses “record identification” to figure out the correct URL based on the model:

_form.html.haml
1
2
= form_for([:admin, @item]) do |f|
  = f.text_field :name

It’s also used to figure out the HTTP verb, the field name prefixes, HTML classes and HTML id.

So this form would POST to admin_items_path for a new record, or PUT to admin_item_path(@item) for an existing record.

But it can break when you use STI (Single Table Inheritance).

items_controller.rb
1
2
3
def edit
  @item = Item.find(123)  # A SpecialItem record.
end
_form.html.haml
1
2
3
= form_for([:admin, @item]) do |f|
  = f.text_field :name
  = f.select :type, [Item.name, SpecialItem.name]

You want this form to PUT to admin_item_path(@item) but Rails will PUT to the non-existent admin_special_item_path(@item).

The Rails form helper guide says you can’t rely on record identification with STI.

You can, though, with some fiddling. This is what I just did:

_form.html.haml
1
2
3
4
= form_for([:admin, @item.becomes(Item)]) |f|
  - f.object = @item.becomes(@item.class)
  = f.text_field :name
  = f.select :type, [Item.name, SpecialItem.name]

#becomes changes the SpecialItem to an Item for the benefit of the record identification.

But then the form will have a plain Item, so our type dropdown won’t pre-select “SpecialItem”. So to fix that, we restore the form object on line 2.

Because #becomes mutates the attributes inside the original @item, we must use #becomes again on line 2. My first attempt was to do @item.dup.becomes(Item) on line 1 so the @item is untouched. But that breaks the form’s new_record? check…

Whatever you go with, make sure to test your forms, as this is fragile territory.